<?phpnamespace App\Controller\Api\WhileProject;use App\Entity\WhileProject\MissionsCategories;use Doctrine\ORM\EntityManagerInterface;use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;use Symfony\Component\HttpFoundation\JsonResponse;use Symfony\Component\HttpFoundation\Request;use Symfony\Component\HttpFoundation\Response;/** * Gestion des catégories de missions. * * Modèle : "1 row = 1 langue" (locale + code stable cross-langue). * Hiérarchie via self-FK `parent`. L'arbre parent/enfants est reconstruit * côté front (le backend renvoie une liste plate avec `parent_code`). * * Public : * - GET /missions/categories → liste publique (locale fr par défaut) * - GET /missions/categories/by-slug/{slug} → catégorie + enfants + breadcrumb * * Admin : * - GET /missions/categories/all → liste complète (toutes locales, désactivées incl.) * - POST /missions/categories → créer * - POST /missions/categories/{id} → mettre à jour * - DELETE /missions/categories/{id} → supprimer * - GET /missions/categories/export → export JSON * - POST /missions/categories/import → import JSON (mode merge) */class CategoriesController extends AbstractController{ private EntityManagerInterface $em; public function __construct(EntityManagerInterface $em) { $this->em = $em; } /** * Liste publique : catégories actives, filtrées par locale (défaut: fr). * GET /missions/categories?locale=fr */ public function list(Request $request): JsonResponse { $locale = strtolower(trim((string) $request->query->get('locale', 'fr'))); $rows = $this->em->createQueryBuilder() ->select('c') ->from(MissionsCategories::class, 'c') ->leftJoin('c.parent', 'p')->addSelect('p') ->where('c.enabled = :ok') ->andWhere('c.locale = :loc') ->setParameter('ok', true) ->setParameter('loc', $locale) ->orderBy('c.position', 'ASC') ->addOrderBy('c.name', 'ASC') ->getQuery()->getResult(); $array = []; foreach ($rows as $c) { $array[] = $this->serialize($c); } return new JsonResponse($array); } /** * Lookup public par slug. * GET /missions/categories/by-slug/{slug}?locale=fr * * Retourne la catégorie, ses sous-catégories directes, et son fil d'ariane * (parent direct si présent). */ public function bySlug(Request $request, string $slug): JsonResponse { $locale = strtolower(trim((string) $request->query->get('locale', 'fr'))); $repo = $this->em->getRepository(MissionsCategories::class); $category = $repo->findOneBy([ 'locale' => $locale, 'slug' => $slug, 'enabled' => true, ]); if (!$category) { return new JsonResponse(['error' => 'not_found'], Response::HTTP_NOT_FOUND); } // Sous-catégories directes $childrenRows = $repo->findBy( ['parent' => $category, 'enabled' => true], ['position' => 'ASC', 'name' => 'ASC'] ); $children = []; foreach ($childrenRows as $c) { $children[] = $this->serialize($c); } // Breadcrumb (parent direct) $breadcrumb = []; $parent = $category->getParent(); if ($parent) { $breadcrumb[] = $this->serialize($parent); } return new JsonResponse([ 'category' => $this->serialize($category), 'children' => $children, 'breadcrumb' => $breadcrumb, ]); } /** * Liste complète admin (inclut désactivées, toutes locales). * GET /missions/categories/all */ public function listAll(): JsonResponse { if (!$this->isAdmin()) { return new JsonResponse(['error' => 'Accès interdit'], Response::HTTP_FORBIDDEN); } $rows = $this->em->createQueryBuilder() ->select('c') ->from(MissionsCategories::class, 'c') ->leftJoin('c.parent', 'p')->addSelect('p') ->orderBy('c.locale', 'ASC') ->addOrderBy('c.position', 'ASC') ->addOrderBy('c.name', 'ASC') ->getQuery()->getResult(); $array = []; foreach ($rows as $c) { $array[] = $this->serialize($c); } return new JsonResponse($array); } /** * Création d'une catégorie (admin). * POST /missions/categories * Body : { locale, code, slug?, name, description?, icon?, color?, * seo_title?, seo_description?, position?, enabled?, parent_code? } */ public function create(Request $request): JsonResponse { if (!$this->isAdmin()) { return new JsonResponse(['error' => 'Accès interdit'], Response::HTTP_FORBIDDEN); } $data = json_decode($request->getContent(), true) ?: []; if (empty($data['name'])) { return new JsonResponse(['error' => 'Le nom est requis'], Response::HTTP_BAD_REQUEST); } $locale = strtolower(trim((string) ($data['locale'] ?? 'fr'))); $code = !empty($data['code']) ? trim($data['code']) : $this->slugify($data['name']); $slug = !empty($data['slug']) ? $this->slugify($data['slug']) : $this->slugify($data['name']); $repo = $this->em->getRepository(MissionsCategories::class); if ($repo->findOneBy(['locale' => $locale, 'code' => $code])) { return new JsonResponse(['error' => "Une catégorie avec ce code existe déjà en {$locale}"], Response::HTTP_BAD_REQUEST); } if ($repo->findOneBy(['locale' => $locale, 'slug' => $slug])) { return new JsonResponse(['error' => "Une catégorie avec ce slug existe déjà en {$locale}"], Response::HTTP_BAD_REQUEST); } $cat = new MissionsCategories(); $cat->setLocale($locale); $cat->setCode($code); $cat->setSlug($slug); $cat->setName(trim($data['name'])); $cat->setDescription($data['description'] ?? null); $cat->setIcon($data['icon'] ?? null); $cat->setColor($data['color'] ?? null); $cat->setSeoTitle($data['seo_title'] ?? null); $cat->setSeoDescription($data['seo_description'] ?? null); $cat->setPosition((int) ($data['position'] ?? 0)); $cat->setEnabled((bool) ($data['enabled'] ?? true)); if (!empty($data['parent_code'])) { $parent = $repo->findOneBy(['locale' => $locale, 'code' => trim($data['parent_code'])]); if (!$parent) { return new JsonResponse(['error' => "Parent introuvable : {$data['parent_code']}"], Response::HTTP_BAD_REQUEST); } $cat->setParent($parent); } $this->em->persist($cat); $this->em->flush(); return new JsonResponse(['success' => true, 'category' => $this->serialize($cat)], Response::HTTP_CREATED); } /** * Mise à jour (admin). * POST /missions/categories/{id} */ public function update(Request $request, int $id): JsonResponse { if (!$this->isAdmin()) { return new JsonResponse(['error' => 'Accès interdit'], Response::HTTP_FORBIDDEN); } $repo = $this->em->getRepository(MissionsCategories::class); $cat = $repo->find($id); if (!$cat) { return new JsonResponse(['error' => 'Catégorie introuvable'], Response::HTTP_NOT_FOUND); } $data = json_decode($request->getContent(), true) ?: []; if (isset($data['name'])) $cat->setName(trim($data['name'])); if (array_key_exists('description', $data)) $cat->setDescription($data['description']); if (array_key_exists('icon', $data)) $cat->setIcon($data['icon']); if (array_key_exists('color', $data)) $cat->setColor($data['color']); if (array_key_exists('seo_title', $data)) $cat->setSeoTitle($data['seo_title']); if (array_key_exists('seo_description', $data)) $cat->setSeoDescription($data['seo_description']); if (isset($data['position'])) $cat->setPosition((int) $data['position']); if (isset($data['enabled'])) $cat->setEnabled((bool) $data['enabled']); if (isset($data['code'])) { $newCode = trim($data['code']); $existing = $repo->findOneBy(['locale' => $cat->getLocale(), 'code' => $newCode]); if ($existing && $existing->getId() !== $cat->getId()) { return new JsonResponse(['error' => 'Code déjà utilisé en cette locale'], Response::HTTP_BAD_REQUEST); } $cat->setCode($newCode); } if (isset($data['slug'])) { $newSlug = $this->slugify($data['slug']); $existing = $repo->findOneBy(['locale' => $cat->getLocale(), 'slug' => $newSlug]); if ($existing && $existing->getId() !== $cat->getId()) { return new JsonResponse(['error' => 'Slug déjà utilisé en cette locale'], Response::HTTP_BAD_REQUEST); } $cat->setSlug($newSlug); } if (array_key_exists('parent_code', $data)) { $pc = $data['parent_code']; if ($pc === null || $pc === '') { $cat->setParent(null); } else { $parent = $repo->findOneBy(['locale' => $cat->getLocale(), 'code' => trim($pc)]); if (!$parent) { return new JsonResponse(['error' => "Parent introuvable : {$pc}"], Response::HTTP_BAD_REQUEST); } if ($parent->getId() === $cat->getId()) { return new JsonResponse(['error' => 'Une catégorie ne peut pas être son propre parent'], Response::HTTP_BAD_REQUEST); } $cat->setParent($parent); } } $cat->setUpdatedAt(new \DateTime('now')); $this->em->persist($cat); $this->em->flush(); return new JsonResponse(['success' => true, 'category' => $this->serialize($cat)]); } /** * Suppression (admin). * DELETE /missions/categories/{id} */ public function delete(int $id): JsonResponse { if (!$this->isAdmin()) { return new JsonResponse(['error' => 'Accès interdit'], Response::HTTP_FORBIDDEN); } $cat = $this->em->getRepository(MissionsCategories::class)->find($id); if (!$cat) { return new JsonResponse(['error' => 'Catégorie introuvable'], Response::HTTP_NOT_FOUND); } $this->em->remove($cat); $this->em->flush(); return new JsonResponse(['success' => true]); } /** * Export JSON (admin). * GET /missions/categories/export */ public function export(): JsonResponse { if (!$this->isAdmin()) { return new JsonResponse(['error' => 'Accès interdit'], Response::HTTP_FORBIDDEN); } $rows = $this->em->createQueryBuilder() ->select('c') ->from(MissionsCategories::class, 'c') ->leftJoin('c.parent', 'p')->addSelect('p') ->orderBy('c.locale', 'ASC') ->addOrderBy('c.position', 'ASC') ->getQuery()->getResult(); $array = []; foreach ($rows as $c) { $array[] = [ 'locale' => $c->getLocale(), 'code' => $c->getCode(), 'slug' => $c->getSlug(), 'name' => $c->getName(), 'description' => $c->getDescription(), 'icon' => $c->getIcon(), 'color' => $c->getColor(), 'seo_title' => $c->getSeoTitle(), 'seo_description' => $c->getSeoDescription(), 'position' => $c->getPosition(), 'enabled' => $c->isEnabled(), 'parent_code' => $c->getParent() ? $c->getParent()->getCode() : null, ]; } return new JsonResponse([ 'exported_at' => (new \DateTime('now'))->format('c'), 'count' => count($array), 'categories' => $array, ]); } /** * Import JSON (admin) — délégué au service dédié. * POST /missions/categories/import */ public function import(Request $request, \App\Services\WhileProject\CategoriesImportExportService $importer): JsonResponse { if (!$this->isAdmin()) { return new JsonResponse(['error' => 'Accès interdit'], Response::HTTP_FORBIDDEN); } $result = $importer->importFromJson($request->getContent()); return new JsonResponse(['success' => true] + $result); } // ─────────────────── Helpers ─────────────────── private function isAdmin(): bool { $user = $this->getUser(); return $user && in_array('ROLE_ADMIN', $user->getRoles(), true); } private function slugify(string $input): string { $slug = transliterator_transliterate('Any-Latin; Latin-ASCII; Lower()', $input) ?: strtolower($input); $slug = preg_replace('/[^a-z0-9]+/', '-', $slug); $slug = trim($slug, '-'); return mb_substr($slug ?: 'categorie', 0, 140); } /** * Sérialisation principale — renvoie TOUS les champs nécessaires * au front (locale, code, parent_code) pour reconstruire l'arbre. */ private function serialize(MissionsCategories $c): array { return [ 'id' => $c->getId(), 'locale' => $c->getLocale(), 'code' => $c->getCode(), 'slug' => $c->getSlug(), 'name' => $c->getName(), 'description' => $c->getDescription(), 'icon' => $c->getIcon(), 'color' => $c->getColor(), 'seo_title' => $c->getSeoTitle(), 'seo_description' => $c->getSeoDescription(), 'position' => $c->getPosition(), 'enabled' => $c->isEnabled(), 'parent_code' => $c->getParent() ? $c->getParent()->getCode() : null, 'parent_id' => $c->getParent() ? $c->getParent()->getId() : null, 'created_at' => $c->getCreatedAt() ? $c->getCreatedAt()->format(\DateTime::ATOM) : null, 'updated_at' => $c->getUpdatedAt() ? $c->getUpdatedAt()->format(\DateTime::ATOM) : null, ]; }}