src/Controller/Api/WhileProject/CategoriesController.php line 44

Open in your IDE?
  1. <?php
  2. namespace App\Controller\Api\WhileProject;
  3. use App\Entity\WhileProject\MissionsCategories;
  4. use Doctrine\ORM\EntityManagerInterface;
  5. use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
  6. use Symfony\Component\HttpFoundation\JsonResponse;
  7. use Symfony\Component\HttpFoundation\Request;
  8. use Symfony\Component\HttpFoundation\Response;
  9. /**
  10. * Gestion des catégories de missions.
  11. *
  12. * Modèle : "1 row = 1 langue" (locale + code stable cross-langue).
  13. * Hiérarchie via self-FK `parent`. L'arbre parent/enfants est reconstruit
  14. * côté front (le backend renvoie une liste plate avec `parent_code`).
  15. *
  16. * Public :
  17. * - GET /missions/categories → liste publique (locale fr par défaut)
  18. * - GET /missions/categories/by-slug/{slug} → catégorie + enfants + breadcrumb
  19. *
  20. * Admin :
  21. * - GET /missions/categories/all → liste complète (toutes locales, désactivées incl.)
  22. * - POST /missions/categories → créer
  23. * - POST /missions/categories/{id} → mettre à jour
  24. * - DELETE /missions/categories/{id} → supprimer
  25. * - GET /missions/categories/export → export JSON
  26. * - POST /missions/categories/import → import JSON (mode merge)
  27. */
  28. class CategoriesController extends AbstractController
  29. {
  30. private EntityManagerInterface $em;
  31. public function __construct(EntityManagerInterface $em)
  32. {
  33. $this->em = $em;
  34. }
  35. /**
  36. * Liste publique : catégories actives, filtrées par locale (défaut: fr).
  37. * GET /missions/categories?locale=fr
  38. */
  39. public function list(Request $request): JsonResponse
  40. {
  41. $locale = strtolower(trim((string) $request->query->get('locale', 'fr')));
  42. $rows = $this->em->createQueryBuilder()
  43. ->select('c')
  44. ->from(MissionsCategories::class, 'c')
  45. ->leftJoin('c.parent', 'p')->addSelect('p')
  46. ->where('c.enabled = :ok')
  47. ->andWhere('c.locale = :loc')
  48. ->setParameter('ok', true)
  49. ->setParameter('loc', $locale)
  50. ->orderBy('c.position', 'ASC')
  51. ->addOrderBy('c.name', 'ASC')
  52. ->getQuery()->getResult();
  53. $array = [];
  54. foreach ($rows as $c) {
  55. $array[] = $this->serialize($c);
  56. }
  57. return new JsonResponse($array);
  58. }
  59. /**
  60. * Lookup public par slug.
  61. * GET /missions/categories/by-slug/{slug}?locale=fr
  62. *
  63. * Retourne la catégorie, ses sous-catégories directes, et son fil d'ariane
  64. * (parent direct si présent).
  65. */
  66. public function bySlug(Request $request, string $slug): JsonResponse
  67. {
  68. $locale = strtolower(trim((string) $request->query->get('locale', 'fr')));
  69. $repo = $this->em->getRepository(MissionsCategories::class);
  70. $category = $repo->findOneBy([
  71. 'locale' => $locale,
  72. 'slug' => $slug,
  73. 'enabled' => true,
  74. ]);
  75. if (!$category) {
  76. return new JsonResponse(['error' => 'not_found'], Response::HTTP_NOT_FOUND);
  77. }
  78. // Sous-catégories directes
  79. $childrenRows = $repo->findBy(
  80. ['parent' => $category, 'enabled' => true],
  81. ['position' => 'ASC', 'name' => 'ASC']
  82. );
  83. $children = [];
  84. foreach ($childrenRows as $c) {
  85. $children[] = $this->serialize($c);
  86. }
  87. // Breadcrumb (parent direct)
  88. $breadcrumb = [];
  89. $parent = $category->getParent();
  90. if ($parent) {
  91. $breadcrumb[] = $this->serialize($parent);
  92. }
  93. return new JsonResponse([
  94. 'category' => $this->serialize($category),
  95. 'children' => $children,
  96. 'breadcrumb' => $breadcrumb,
  97. ]);
  98. }
  99. /**
  100. * Liste complète admin (inclut désactivées, toutes locales).
  101. * GET /missions/categories/all
  102. */
  103. public function listAll(): JsonResponse
  104. {
  105. if (!$this->isAdmin()) {
  106. return new JsonResponse(['error' => 'Accès interdit'], Response::HTTP_FORBIDDEN);
  107. }
  108. $rows = $this->em->createQueryBuilder()
  109. ->select('c')
  110. ->from(MissionsCategories::class, 'c')
  111. ->leftJoin('c.parent', 'p')->addSelect('p')
  112. ->orderBy('c.locale', 'ASC')
  113. ->addOrderBy('c.position', 'ASC')
  114. ->addOrderBy('c.name', 'ASC')
  115. ->getQuery()->getResult();
  116. $array = [];
  117. foreach ($rows as $c) {
  118. $array[] = $this->serialize($c);
  119. }
  120. return new JsonResponse($array);
  121. }
  122. /**
  123. * Création d'une catégorie (admin).
  124. * POST /missions/categories
  125. * Body : { locale, code, slug?, name, description?, icon?, color?,
  126. * seo_title?, seo_description?, position?, enabled?, parent_code? }
  127. */
  128. public function create(Request $request): JsonResponse
  129. {
  130. if (!$this->isAdmin()) {
  131. return new JsonResponse(['error' => 'Accès interdit'], Response::HTTP_FORBIDDEN);
  132. }
  133. $data = json_decode($request->getContent(), true) ?: [];
  134. if (empty($data['name'])) {
  135. return new JsonResponse(['error' => 'Le nom est requis'], Response::HTTP_BAD_REQUEST);
  136. }
  137. $locale = strtolower(trim((string) ($data['locale'] ?? 'fr')));
  138. $code = !empty($data['code']) ? trim($data['code']) : $this->slugify($data['name']);
  139. $slug = !empty($data['slug']) ? $this->slugify($data['slug']) : $this->slugify($data['name']);
  140. $repo = $this->em->getRepository(MissionsCategories::class);
  141. if ($repo->findOneBy(['locale' => $locale, 'code' => $code])) {
  142. return new JsonResponse(['error' => "Une catégorie avec ce code existe déjà en {$locale}"], Response::HTTP_BAD_REQUEST);
  143. }
  144. if ($repo->findOneBy(['locale' => $locale, 'slug' => $slug])) {
  145. return new JsonResponse(['error' => "Une catégorie avec ce slug existe déjà en {$locale}"], Response::HTTP_BAD_REQUEST);
  146. }
  147. $cat = new MissionsCategories();
  148. $cat->setLocale($locale);
  149. $cat->setCode($code);
  150. $cat->setSlug($slug);
  151. $cat->setName(trim($data['name']));
  152. $cat->setDescription($data['description'] ?? null);
  153. $cat->setIcon($data['icon'] ?? null);
  154. $cat->setColor($data['color'] ?? null);
  155. $cat->setSeoTitle($data['seo_title'] ?? null);
  156. $cat->setSeoDescription($data['seo_description'] ?? null);
  157. $cat->setPosition((int) ($data['position'] ?? 0));
  158. $cat->setEnabled((bool) ($data['enabled'] ?? true));
  159. if (!empty($data['parent_code'])) {
  160. $parent = $repo->findOneBy(['locale' => $locale, 'code' => trim($data['parent_code'])]);
  161. if (!$parent) {
  162. return new JsonResponse(['error' => "Parent introuvable : {$data['parent_code']}"], Response::HTTP_BAD_REQUEST);
  163. }
  164. $cat->setParent($parent);
  165. }
  166. $this->em->persist($cat);
  167. $this->em->flush();
  168. return new JsonResponse(['success' => true, 'category' => $this->serialize($cat)], Response::HTTP_CREATED);
  169. }
  170. /**
  171. * Mise à jour (admin).
  172. * POST /missions/categories/{id}
  173. */
  174. public function update(Request $request, int $id): JsonResponse
  175. {
  176. if (!$this->isAdmin()) {
  177. return new JsonResponse(['error' => 'Accès interdit'], Response::HTTP_FORBIDDEN);
  178. }
  179. $repo = $this->em->getRepository(MissionsCategories::class);
  180. $cat = $repo->find($id);
  181. if (!$cat) {
  182. return new JsonResponse(['error' => 'Catégorie introuvable'], Response::HTTP_NOT_FOUND);
  183. }
  184. $data = json_decode($request->getContent(), true) ?: [];
  185. if (isset($data['name'])) $cat->setName(trim($data['name']));
  186. if (array_key_exists('description', $data)) $cat->setDescription($data['description']);
  187. if (array_key_exists('icon', $data)) $cat->setIcon($data['icon']);
  188. if (array_key_exists('color', $data)) $cat->setColor($data['color']);
  189. if (array_key_exists('seo_title', $data)) $cat->setSeoTitle($data['seo_title']);
  190. if (array_key_exists('seo_description', $data)) $cat->setSeoDescription($data['seo_description']);
  191. if (isset($data['position'])) $cat->setPosition((int) $data['position']);
  192. if (isset($data['enabled'])) $cat->setEnabled((bool) $data['enabled']);
  193. if (isset($data['code'])) {
  194. $newCode = trim($data['code']);
  195. $existing = $repo->findOneBy(['locale' => $cat->getLocale(), 'code' => $newCode]);
  196. if ($existing && $existing->getId() !== $cat->getId()) {
  197. return new JsonResponse(['error' => 'Code déjà utilisé en cette locale'], Response::HTTP_BAD_REQUEST);
  198. }
  199. $cat->setCode($newCode);
  200. }
  201. if (isset($data['slug'])) {
  202. $newSlug = $this->slugify($data['slug']);
  203. $existing = $repo->findOneBy(['locale' => $cat->getLocale(), 'slug' => $newSlug]);
  204. if ($existing && $existing->getId() !== $cat->getId()) {
  205. return new JsonResponse(['error' => 'Slug déjà utilisé en cette locale'], Response::HTTP_BAD_REQUEST);
  206. }
  207. $cat->setSlug($newSlug);
  208. }
  209. if (array_key_exists('parent_code', $data)) {
  210. $pc = $data['parent_code'];
  211. if ($pc === null || $pc === '') {
  212. $cat->setParent(null);
  213. } else {
  214. $parent = $repo->findOneBy(['locale' => $cat->getLocale(), 'code' => trim($pc)]);
  215. if (!$parent) {
  216. return new JsonResponse(['error' => "Parent introuvable : {$pc}"], Response::HTTP_BAD_REQUEST);
  217. }
  218. if ($parent->getId() === $cat->getId()) {
  219. return new JsonResponse(['error' => 'Une catégorie ne peut pas être son propre parent'], Response::HTTP_BAD_REQUEST);
  220. }
  221. $cat->setParent($parent);
  222. }
  223. }
  224. $cat->setUpdatedAt(new \DateTime('now'));
  225. $this->em->persist($cat);
  226. $this->em->flush();
  227. return new JsonResponse(['success' => true, 'category' => $this->serialize($cat)]);
  228. }
  229. /**
  230. * Suppression (admin).
  231. * DELETE /missions/categories/{id}
  232. */
  233. public function delete(int $id): JsonResponse
  234. {
  235. if (!$this->isAdmin()) {
  236. return new JsonResponse(['error' => 'Accès interdit'], Response::HTTP_FORBIDDEN);
  237. }
  238. $cat = $this->em->getRepository(MissionsCategories::class)->find($id);
  239. if (!$cat) {
  240. return new JsonResponse(['error' => 'Catégorie introuvable'], Response::HTTP_NOT_FOUND);
  241. }
  242. $this->em->remove($cat);
  243. $this->em->flush();
  244. return new JsonResponse(['success' => true]);
  245. }
  246. /**
  247. * Export JSON (admin).
  248. * GET /missions/categories/export
  249. */
  250. public function export(): JsonResponse
  251. {
  252. if (!$this->isAdmin()) {
  253. return new JsonResponse(['error' => 'Accès interdit'], Response::HTTP_FORBIDDEN);
  254. }
  255. $rows = $this->em->createQueryBuilder()
  256. ->select('c')
  257. ->from(MissionsCategories::class, 'c')
  258. ->leftJoin('c.parent', 'p')->addSelect('p')
  259. ->orderBy('c.locale', 'ASC')
  260. ->addOrderBy('c.position', 'ASC')
  261. ->getQuery()->getResult();
  262. $array = [];
  263. foreach ($rows as $c) {
  264. $array[] = [
  265. 'locale' => $c->getLocale(),
  266. 'code' => $c->getCode(),
  267. 'slug' => $c->getSlug(),
  268. 'name' => $c->getName(),
  269. 'description' => $c->getDescription(),
  270. 'icon' => $c->getIcon(),
  271. 'color' => $c->getColor(),
  272. 'seo_title' => $c->getSeoTitle(),
  273. 'seo_description' => $c->getSeoDescription(),
  274. 'position' => $c->getPosition(),
  275. 'enabled' => $c->isEnabled(),
  276. 'parent_code' => $c->getParent() ? $c->getParent()->getCode() : null,
  277. ];
  278. }
  279. return new JsonResponse([
  280. 'exported_at' => (new \DateTime('now'))->format('c'),
  281. 'count' => count($array),
  282. 'categories' => $array,
  283. ]);
  284. }
  285. /**
  286. * Import JSON (admin) — délégué au service dédié.
  287. * POST /missions/categories/import
  288. */
  289. public function import(Request $request, \App\Services\WhileProject\CategoriesImportExportService $importer): JsonResponse
  290. {
  291. if (!$this->isAdmin()) {
  292. return new JsonResponse(['error' => 'Accès interdit'], Response::HTTP_FORBIDDEN);
  293. }
  294. $result = $importer->importFromJson($request->getContent());
  295. return new JsonResponse(['success' => true] + $result);
  296. }
  297. // ─────────────────── Helpers ───────────────────
  298. private function isAdmin(): bool
  299. {
  300. $user = $this->getUser();
  301. return $user && in_array('ROLE_ADMIN', $user->getRoles(), true);
  302. }
  303. private function slugify(string $input): string
  304. {
  305. $slug = transliterator_transliterate('Any-Latin; Latin-ASCII; Lower()', $input) ?: strtolower($input);
  306. $slug = preg_replace('/[^a-z0-9]+/', '-', $slug);
  307. $slug = trim($slug, '-');
  308. return mb_substr($slug ?: 'categorie', 0, 140);
  309. }
  310. /**
  311. * Sérialisation principale — renvoie TOUS les champs nécessaires
  312. * au front (locale, code, parent_code) pour reconstruire l'arbre.
  313. */
  314. private function serialize(MissionsCategories $c): array
  315. {
  316. return [
  317. 'id' => $c->getId(),
  318. 'locale' => $c->getLocale(),
  319. 'code' => $c->getCode(),
  320. 'slug' => $c->getSlug(),
  321. 'name' => $c->getName(),
  322. 'description' => $c->getDescription(),
  323. 'icon' => $c->getIcon(),
  324. 'color' => $c->getColor(),
  325. 'seo_title' => $c->getSeoTitle(),
  326. 'seo_description' => $c->getSeoDescription(),
  327. 'position' => $c->getPosition(),
  328. 'enabled' => $c->isEnabled(),
  329. 'parent_code' => $c->getParent() ? $c->getParent()->getCode() : null,
  330. 'parent_id' => $c->getParent() ? $c->getParent()->getId() : null,
  331. 'created_at' => $c->getCreatedAt() ? $c->getCreatedAt()->format(\DateTime::ATOM) : null,
  332. 'updated_at' => $c->getUpdatedAt() ? $c->getUpdatedAt()->format(\DateTime::ATOM) : null,
  333. ];
  334. }
  335. }