src/Controller/Api/Cvs/CandidatesController.php line 398

Open in your IDE?
  1. <?php
  2. namespace App\Controller\Api\Cvs;
  3. use App\Entity\Core\Users;
  4. use App\Entity\Core\UsersHasFiches;
  5. use App\Entity\Cvs\Candidates;
  6. use App\Entity\Cvs\CandidatesHasLikes;
  7. use App\Entity\Cvs\CandidatesHasStatistics;
  8. use App\Entity\Dossiers\Fiches;
  9. use App\Entity\Dossiers\FichesCategories;
  10. use App\Entity\Dossiers\FichesHasDetails;
  11. use App\Entity\Dossiers\FichesHasGroups;
  12. use App\Entity\Dossiers\TemplatesHasDetails;
  13. use App\Entity\Dossiers\TemplatesHasGroups;
  14. use App\Entity\Messenger\Groups;
  15. use App\Entity\Messenger\Messages;
  16. use App\Entity\Pages\Templates;
  17. use App\Services\Dossiers;
  18. use Sensio\Bundle\FrameworkExtraBundle\Configuration\Cache;
  19. use Sensio\Bundle\FrameworkExtraBundle\Configuration\IsGranted;
  20. use Sensio\Bundle\FrameworkExtraBundle\Configuration\ParamConverter;
  21. use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
  22. use Symfony\Component\EventDispatcher\EventDispatcherInterface;
  23. use Symfony\Component\HttpFoundation\Request;
  24. use Symfony\Component\HttpFoundation\Response;
  25. use Symfony\Component\Routing\Annotation\Route;
  26. use Symfony\Component\HttpFoundation\JsonResponse;
  27. use Doctrine\ORM\EntityManagerInterface;
  28. use Knp\Component\Pager\PaginatorInterface;
  29. use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
  30. use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
  31. use Symfony\Component\Form\Extension\Core\Type\DateTimeType;
  32. use Symfony\Component\Form\Extension\Core\Type\EmailType;
  33. use Symfony\Component\Form\Extension\Core\Type\HiddenType;
  34. use Symfony\Component\Form\Extension\Core\Type\NumberType;
  35. use Symfony\Component\Form\Extension\Core\Type\SubmitType;
  36. use Symfony\Component\Form\Extension\Core\Type\TextareaType;
  37. use Symfony\Component\Form\Extension\Core\Type\TextType;
  38. use Symfony\Component\HttpFoundation\Session\Session;
  39. use Symfony\Component\HttpFoundation\File\UploadedFile;
  40. class CandidatesController extends AbstractController
  41. {
  42.     private $em;
  43.     private $paginator;
  44.     private $dossier;
  45.     public function __construct(EntityManagerInterface $emPaginatorInterface $paginatorDossiers $dossier) {
  46.         $this->em $em;
  47.         $this->paginator $paginator;
  48.         $this->dossier $dossier;
  49.     }
  50.     /**
  51.      * Liste des candidats.
  52.      * @param Request $request
  53.      * @param $start
  54.      * @param $limit
  55.      * @return JsonResponse
  56.      */
  57.     public function recents(Request $request$start$limit)
  58.     {
  59.         $user $this->getUser();
  60.         $queries $this->em->getRepository(Candidates::class)->recents($start$limit);
  61.         if ($queries === null) {
  62.             return new JsonResponse(null);
  63.         }
  64.         $array = [];
  65.         foreach ($queries as $query) {
  66.             $array[] = $this->serializeCandidateForRecruiter($query$user);
  67.         }
  68.         $chs = new CandidatesHasStatistics();
  69.         $chs->setCandidate(null);
  70.         $chs->setType("list_candidates");
  71.         $chs->setUser($user);
  72.         $this->em->persist($chs);
  73.         $this->em->flush();
  74.         return new JsonResponse($array);
  75.     }
  76.     /**
  77.      * Liste des likes.
  78.      * @param Request $request
  79.      * @param $start
  80.      * @param $limit
  81.      * @return JsonResponse
  82.      */
  83.     public function likes(Request $request$start$limit)
  84.     {
  85.         $user $this->getUser();
  86.         $queries $this->em->getRepository(Candidates::class)->searchLikes($usernull$start$limit);
  87.         if ($queries === null) {
  88.             return new JsonResponse(null);
  89.         }
  90.         $array = [];
  91.         foreach ($queries as $query) {
  92.             $array[] = $this->serializeCandidateForRecruiter($query$user);
  93.         }
  94.         $chs = new CandidatesHasStatistics();
  95.         $chs->setCandidate(null);
  96.         $chs->setType("list_favorites");
  97.         $chs->setUser($user);
  98.         $this->em->persist($chs);
  99.         $this->em->flush();
  100.         return new JsonResponse($array);
  101.     }
  102.     /**
  103.      * Rechercher un candidat favoris
  104.      * @param Request $request
  105.      * @param $start
  106.      * @param $limit
  107.      * @return JsonResponse
  108.      */
  109.     public function searchLikes(Request $request$start$limit)
  110.     {
  111.         $user $this->getUser();
  112.         $data json_decode($request->getContent(), true);
  113.         $term $data['term'] ?? '';
  114.         if (empty($term)) {
  115.             return new JsonResponse([
  116.                 'error' => 'Le paramètre "term" est requis'
  117.             ], 400);
  118.         }
  119.         $queries $this->em->getRepository(Candidates::class)->searchLikes($user$term$start$limit);
  120.         if ($queries === null) {
  121.             return new JsonResponse(null);
  122.         }
  123.         $array = [];
  124.         foreach ($queries as $query) {
  125.             $array[] = $this->serializeCandidateForRecruiter($query$user);
  126.         }
  127.         $chs = new CandidatesHasStatistics();
  128.         $chs->setCandidate(null);
  129.         $chs->setType("search_favorites");
  130.         $chs->setUser($user);
  131.         $this->em->persist($chs);
  132.         $this->em->flush();
  133.         return new JsonResponse($array);
  134.     }
  135.     /**
  136.      * Recherche un candidat
  137.      * @param Request $request
  138.      * @param $start
  139.      * @param $limit
  140.      * @return JsonResponse
  141.      */
  142.     public function searchRecents(Request $request$start$limit)
  143.     {
  144.         $user $this->getUser();
  145.         $data json_decode($request->getContent(), true);
  146.         $term $data['term'] ?? '';
  147.         if (empty($term)) {
  148.             return new JsonResponse([
  149.                 'error' => 'Le paramètre "term" est requis'
  150.             ], 400);
  151.         }
  152.         $queries $this->em->getRepository(Candidates::class)->searchRecents($term$start$limit);
  153.         if ($queries === null) {
  154.             return new JsonResponse(null);
  155.         }
  156.         $array = [];
  157.         foreach ($queries as $query) {
  158.             $array[] = $this->serializeCandidateForRecruiter($query$user);
  159.         }
  160.         $chs = new CandidatesHasStatistics();
  161.         $chs->setCandidate(null);
  162.         $chs->setType("search_candidates");
  163.         $chs->setUser($user);
  164.         $this->em->persist($chs);
  165.         $this->em->flush();
  166.         return new JsonResponse($array);
  167.     }
  168.     /**
  169.      * Lire un CV
  170.      * @param Request $request
  171.      * @param Candidates $candidate
  172.      * @return JsonResponse
  173.      */
  174.     public function readCV(Request $requestCandidates $candidate)
  175.     {
  176.         $user $this->getUser();
  177.         $cv64 null;
  178.         $source null// 'generated' | 'uploaded' | null
  179.         // ─── Priorité 1 : CV généré (tunnel V2, PDF sur disque) ───
  180.         $pdfPath $candidate->getCvPdfPath();
  181.         if ($pdfPath && file_exists($pdfPath)) {
  182.             try {
  183.                 $pdfContent = @file_get_contents($pdfPath);
  184.                 if ($pdfContent !== false && strlen($pdfContent) > 0) {
  185.                     $cv64 base64_encode($pdfContent);
  186.                     $source 'generated';
  187.                 }
  188.             } catch (\Exception $e) {
  189.                 // Fallback sur CV uploadé si lecture échoue
  190.                 $cv64 null;
  191.             }
  192.         }
  193.         // ─── Priorité 2 : CV uploadé par le candidat ───
  194.         if ($cv64 === null
  195.             && $candidate->getImage() !== null
  196.             && $candidate->getImage()->getName() !== null) {
  197.             $cv64 $candidate->getImageBase64($this->getParameter('kernel.project_dir'));
  198.             if ($cv64) {
  199.                 $source 'uploaded';
  200.             }
  201.         }
  202.         // ─── Statistique de consultation ───
  203.         $chs = new CandidatesHasStatistics();
  204.         $chs->setCandidate($candidate);
  205.         $chs->setType("readcv");
  206.         $chs->setUser($user);
  207.         $this->em->persist($chs);
  208.         $this->em->flush();
  209.         return new JsonResponse([
  210.             "file"   => $cv64,
  211.             "source" => $source// informatif : 'generated', 'uploaded' ou null
  212.         ]);
  213.     }
  214.     /**
  215.      * Nouvelle conversation.
  216.      * @param Request $request
  217.      * @param Candidates $candidate
  218.      * @return JsonResponse
  219.      */
  220.     public function newConversation(Request $requestCandidates $candidate)
  221.     {
  222.         $user $this->getUser();
  223.         $userCandidate $this->em->getRepository(Users::class)->findOneBy(['candidate' => $candidate->getId()]);
  224.         $groups $this->em->getRepository(Groups::class)->findOrCreateConversation($user,$userCandidate);
  225.         if($groups == null) {
  226.             $hash Groups::generateConversationHash($user$userCandidate);
  227.             $groups = new Groups();
  228.             $groups->setUserOne($user);
  229.             $groups->setUserTwo($user);
  230.             $groups->setConversationHash($hash);
  231.             $groups->setTitle("");
  232.             $this->em->persist($groups);
  233.             $this->em->flush();
  234.         }
  235.         $chs = new CandidatesHasStatistics();
  236.         $chs->setCreatedAt(new \DateTime("now"));
  237.         $chs->setUpdatedAt(new \DateTime("now"));
  238.         $chs->setCandidate($userCandidate->getCandidate());
  239.         $chs->setType("new_conversation");
  240.         $chs->setUser($user);
  241.         $this->em->persist($chs);
  242.         $this->em->flush();
  243.         return new JsonResponse(["group" => $groups->getId()]);
  244.     }
  245.     /**
  246.      * Like un candidat.
  247.      * @param Request $request
  248.      * @param Candidates $candidate
  249.      * @return JsonResponse
  250.      */
  251.     public function like(Request $requestCandidates $candidate)
  252.     {
  253.         $user $this->getUser();
  254.         $like $this->em->getRepository(CandidatesHasLikes::class)->findOneBy(['candidate' =>$candidate]);
  255.         if($like !== null) {
  256.             $this->em->remove($like);
  257.             $this->em->flush();
  258.             return new JsonResponse(["success" => true'like' => false]);
  259.         }
  260.         $like = new CandidatesHasLikes();
  261.         $like->setCandidate($candidate);
  262.         $like->setUser($user);
  263.         $this->em->persist($like);
  264.         $this->em->flush();
  265.         $chs = new CandidatesHasStatistics();
  266.         $chs->setCandidate($candidate);
  267.         $chs->setType("like");
  268.         $chs->setUser($user);
  269.         $this->em->persist($chs);
  270.         $this->em->flush();
  271.         return new JsonResponse(["success" => true'like' => true]);
  272.     }
  273.     /**
  274.      * Visionner une conversation ?
  275.      * @param Request $request
  276.      * @param Groups $group
  277.      * @return JsonResponse
  278.      */
  279.     public function show(Request $request,Groups $group)
  280.     {
  281.         $user $this->getUser();
  282.         $noWay false;
  283.         if($group->getUserOne() === $user) {
  284.             $noWay true;
  285.         }
  286.         if($group->getUserTwo() === $user) {
  287.             $noWay true;
  288.         }
  289.         if($noWay === false) {
  290.             return new JsonResponse(null);
  291.         }
  292.         $queries $this->em->getRepository(Messages::class)->findBy(['group' => $group],['createdAt'=>'ASC']);
  293.         $array = [];
  294.         foreach ($queries as $query) {
  295.             $createdAt "";
  296.             if($query->getCreatedAt() !== null) {
  297.                 $createdAt $query->getCreatedAt()->format('c');
  298.             }
  299.             $array[] = [
  300.                 "id" => (string)$query->getId(),
  301.                 "message" => (string)$query->getMessage(),
  302.                 "createdAt" => $createdAt
  303.             ];
  304.         }
  305.         return new JsonResponse($array);
  306.     }
  307.     /**
  308.      * Fiche d'un candidat
  309.      * @param Request $request
  310.      * @return JsonResponse
  311.      */
  312.     public function info(Request $request): JsonResponse
  313.     {
  314.         $user $this->getUser();
  315.         $candidate $user->getCandidate();
  316.         $userCandidate $this->em->getRepository(Users::class)->findoneBy(['candidate' => $candidate->getId()]);
  317.         $createdAt "";
  318.         if($candidate->getCreatedAt() !== null) {
  319.             $createdAt $candidate->getCreatedAt()->format('c');
  320.         }
  321.         $updatedAt "";
  322.         if($candidate->getUpdatedAt() !== null) {
  323.             $updatedAt $candidate->getUpdatedAt()->format('c');
  324.         }
  325.         $cv64 "";
  326.         $haveCV false;
  327.         if($candidate->getImage() !== null && $candidate->getImage()->getName() !== null){
  328.             $cv64 $candidate->getImageBase64($this->getParameter('kernel.project_dir'));
  329.             $haveCV true;
  330.         }
  331.         $avatar64 "";
  332.         if($userCandidate->getImage() !== null && $userCandidate->getImage()->getName() !== null){
  333.             $avatar64 $userCandidate->getImageBase64($this->getParameter('kernel.project_dir'));
  334.         }
  335.         $array = [
  336.             "id" => (string)$candidate->getId(),
  337.             "createdAt" => $createdAt,
  338.             "updatedAt" => $updatedAt,
  339.             "fullname" => ucfirst(strtolower($userCandidate->getName()))." ".strtoupper($userCandidate->getLastname()),
  340.             "avatar" => $avatar64,
  341.             "name" => (string)$userCandidate->getName(),
  342.             "lastname" => (string)$userCandidate->getLastname(),
  343.             "defaultLocale" => $candidate->getDefaultLocale(),
  344.             "frLanguage" => (bool)$candidate->isFrench(),
  345.             "enLanguage" => (bool)$candidate->isEnglish(),
  346.             "defaultLanguage" => (bool)$candidate->isDefault(),
  347.             "resumeRecruiterFr" => (string)$candidate->getResumeRecruiterFr(),
  348.             "resumeRecruiterEn" => (string)$candidate->getResumeRecruiterEn(),
  349.             "resumeRecruiterDefault" => (string)$candidate->getResumeRecruiterDefault(),
  350.             "resumeExpFr" => (string)$candidate->getResumeExpFr(),
  351.             "resumeExpEn" => (string)$candidate->getResumeExpEn(),
  352.             "resumeExpDefault" => (string)$candidate->getResumeExpDefault(),
  353.             "resumeProjectsFr" => (string)$candidate->getResumeProjectsFr(),
  354.             "resumeProjectsEn" => (string)$candidate->getResumeProjectsEn(),
  355.             "resumeProjectsDefault" => (string)$candidate->getResumeProjectsDefault(),
  356.             "presentationFr" => (string)$candidate->getPresentationFr(),
  357.             "presentationEn" => (string)$candidate->getPresentationEn(),
  358.             "presentationDefault" => (string)$candidate->getPresentationDefault(),
  359.             "haveCV" => $haveCV,
  360.             "hardSkillsFr" => json_decode($candidate->getHardSkillsFr()),
  361.             "hardSkillsEn" =>  json_decode($candidate->getHardSkillsEn()),
  362.             "hardSkillsDefault" => json_decode($candidate->getHardSkillsDefault()),
  363.             "softSkillsFr" => json_decode($candidate->getSoftSkillsFr()),
  364.             "softSkillsEn" => json_decode($candidate->getSoftSkillsEn()),
  365.             "softSkillsDefault" => json_decode($candidate->getSoftSkillsDefault()),
  366.             "sectorFr" => (string)$candidate->getSectorFr(),
  367.             "sectorEn" => (string)$candidate->getSectorEn(),
  368.             "sectorDefault" => (string)$candidate->getSectorDefault(),
  369.             "jobTitleFr" => (string)$candidate->getTitlejobFr(),
  370.             "jobTitleEn" => (string)$candidate->getTitlejobEn(),
  371.             "jobTitleDefault" => (string)$candidate->getTitlejobDefault(),
  372.             "futurTitlejobFr" => (string)$candidate->getFuturTitlejobFr(),
  373.             "futurTitlejobEn" => (string)$candidate->getFuturTitlejobEn(),
  374.             "futurTitlejobDefault" => (string)$candidate->getFuturTitlejobDefault(),
  375.             "availability" => $candidate->getAvailability(),
  376.             "cv" => "",  // Chargé à la demande via GET /info/uploaded-cv-base64
  377.             "address" => $candidate->getAddress(),
  378.             "zipcode" => $candidate->getZipcode(),
  379.             "country" => $candidate->getCountry(),
  380.             'city' => $candidate->getCity(),
  381.             'salary' => $candidate->isSalary(),
  382.             'freelance' => $candidate->isFreelance(),
  383.             'stage' => $candidate->isStage(),
  384.             'alternance' => $candidate->isAlternance(),
  385.             "websiteUrl" => $candidate->getWebsiteUrl(),
  386.             "linkedinUrl" => $candidate->getLinkedinUrl(),
  387.             "videoFrUrl" => (string)$candidate->getVideoFrUrl(),
  388.             "videoEnUrl" => (string)$candidate->getVideoEnUrl(),
  389.             "videoDefaultUrl" => (string)$candidate->getVideoDefaultUrl(),
  390.             "experience" => $candidate->getExpYears() ?: 0,
  391.             "formAiAnalysis" => $candidate->getFormAiAnalysis() ? json_decode($candidate->getFormAiAnalysis(), true) : null,
  392.             "formAiAnalysisStatus" => $candidate->getFormAiAnalysisStatus(),
  393.             "formAiAnalysisUpdatedAt" => $candidate->getFormAiAnalysisUpdatedAt() ? $candidate->getFormAiAnalysisUpdatedAt()->format('c') : null,
  394.             // ── CV généré via le tunnel V2 (distinct du CV uploadé dans "cv") ──
  395.             "haveGeneratedCv" => ($candidate->getCvPdfPath() && file_exists($candidate->getCvPdfPath())),
  396.             "generatedCvUrl"  => ($candidate->getCvPdfPath() && file_exists($candidate->getCvPdfPath()))
  397.                 ? rtrim($_ENV['CV_PDF_PUBLIC_URL'] ?? '''/') . '/' basename($candidate->getCvPdfPath())
  398.                 : null,
  399.         ];
  400.         $chs = new CandidatesHasStatistics();
  401.         $chs->setCandidate($candidate);
  402.         $chs->setType("view_candidate");
  403.         $chs->setUser($user);
  404.         $this->em->persist($chs);
  405.         $this->em->flush();
  406.         return new JsonResponse($array);
  407.     }
  408.     /**
  409.     /**
  410.      * Retourne le CV uploadé encodé en base64.
  411.      * Endpoint dédié pour ne pas alourdir /info avec un gros payload.
  412.      * GET /api/cvs/candidates/info/uploaded-cv-base64
  413.      */
  414.     public function getUploadedCvBase64(Request $request): JsonResponse
  415.     {
  416.         $user $this->getUser();
  417.         if (!$user) return new JsonResponse(['error' => 'Unauthorized'], 401);
  418.         $candidate $user->getCandidate();
  419.         if (!$candidate) return new JsonResponse(['error' => 'Candidat introuvable'], 404);
  420.         if ($candidate->getImage() === null || $candidate->getImage()->getName() === null) {
  421.             return new JsonResponse(['success' => false'error' => 'Aucun CV uploadé'], 404);
  422.         }
  423.         try {
  424.             $cv64 $candidate->getImageBase64($this->getParameter('kernel.project_dir'));
  425.             if (empty($cv64)) {
  426.                 return new JsonResponse(['success' => false'error' => 'Fichier CV introuvable sur le disque'], 404);
  427.             }
  428.             return new JsonResponse([
  429.                 'success'  => true,
  430.                 'base64'   => $cv64,
  431.                 'filename' => $candidate->getImage()->getName(),
  432.             ]);
  433.         } catch (\Throwable $e) {
  434.             return new JsonResponse(['success' => false'error' => $e->getMessage()], 500);
  435.         }
  436.     }
  437.     /**
  438.      * Enregistrer le CV
  439.      * @param Request $request
  440.      * @return JsonResponse
  441.      */
  442.     public function uploadCV(Request $request): JsonResponse
  443.     {
  444.         if (!$request->isMethod('POST')) {
  445.             return new JsonResponse(['error' => 'Method not allowed'], 405);
  446.         }
  447.         $user $this->getUser();
  448.         if (!$user) {
  449.             return new JsonResponse(['error' => 'Unauthorized'], 401);
  450.         }
  451.         $candidate $user->getCandidate();
  452.         if (!$candidate) {
  453.             return new JsonResponse(['error' => 'Candidate not found'], 404);
  454.         }
  455.         $data $request->toArray();
  456.         if (!empty($data['cv'])) {
  457.             try {
  458.                 $pdfInfo $this->detectPdfTypeFromBase64($data['cv']);
  459.                 // Décoder le base64
  460.                 $decodedPdf base64_decode($pdfInfo['data']);
  461.                 if ($decodedPdf === false) {
  462.                     return new JsonResponse(['error' => 'Invalid base64 PDF'], 400);
  463.                 }
  464.                 // Créer un fichier temporaire avec un nom unique
  465.                 $tmpFilePath sys_get_temp_dir() . '/' uniqid('cv_'true) . '.pdf';
  466.                 // Écrire le PDF décodé dans le fichier temporaire
  467.                 $bytesWritten file_put_contents($tmpFilePath$decodedPdf);
  468.                 if ($bytesWritten === false) {
  469.                     return new JsonResponse(['error' => 'Failed to write temporary file'], 500);
  470.                 }
  471.                 // ✅ IMPORTANT : Créer un objet UploadedFile pour VichUploader
  472.                 $uploadedFile = new UploadedFile(
  473.                     $tmpFilePath,           // Chemin du fichier temporaire
  474.                     'cv.pdf',               // Nom original du fichier
  475.                     'application/pdf',      // Type MIME
  476.                     null,                   // Erreur (null = pas d'erreur)
  477.                     true                    // test mode = true (fichier temporaire)
  478.                 );
  479.                 // Assigner le fichier à l'entité via VichUploader
  480.                 $candidate->setImageFile($uploadedFile); // ⚠️ Adapter selon votre nom de méthode (setCvFile?)
  481.                 // ✅ IMPORTANT : Mettre à jour le timestamp pour forcer VichUploader à détecter le changement
  482.                 $candidate->setUpdatedAt(new \DateTimeImmutable());
  483.             } catch (\Exception $e) {
  484.                 // Nettoyer le fichier temporaire en cas d'erreur
  485.                 if (isset($tmpFilePath) && file_exists($tmpFilePath)) {
  486.                     @unlink($tmpFilePath);
  487.                 }
  488.                 return new JsonResponse(['error' => 'Upload failed: ' $e->getMessage()], 500);
  489.             }
  490.         }
  491.         $this->em->persist($candidate);
  492.         $this->em->flush();
  493.         // Nettoyer le fichier temporaire après le flush
  494.         if (isset($tmpFilePath) && file_exists($tmpFilePath)) {
  495.             @unlink($tmpFilePath);
  496.         }
  497.         return new JsonResponse(['success' => true]);
  498.     }
  499.     /**
  500.      * Analyse du CV  si c'est finis ou en cours.
  501.      * @param Request $request
  502.      * @return JsonResponse
  503.      */
  504.     public function analyseCV(Request $request): JsonResponse
  505.     {
  506.         $user $this->getUser();
  507.         $candidate $user->getCandidate();
  508.         return new JsonResponse([
  509.             "analyse" => $candidate->getAnalyse(),
  510.         ]);
  511.     }
  512.     /**
  513.      * Exécution de l'analyse par Python.
  514.      * @param Request $request
  515.      * @return JsonResponse
  516.      */
  517.     public function executionCV(Request $request): JsonResponse
  518.     {
  519.         $user $this->getUser();
  520.         $candidate $user->getCandidate();
  521.         $pathPython $_ENV["PYTHON_PATH"];
  522.         //if ($candidate->getAnalyse() === true) {
  523.         $pathPythonScript $_ENV["PYTHON_SCRIPT_PATH"];
  524.         $cvID $candidate->getId();
  525.         $command escapeshellcmd($pathPython " " $pathPythonScript " " $cvID);
  526.         shell_exec($command " > /dev/null 2>/dev/null &");
  527.         $candidate->setAnalyse(false);
  528.         $this->em->persist($candidate);
  529.         $this->em->flush();
  530.         /*}
  531.         if ($candidate->getAnalyse() !== true) {
  532.             $pathPythonScript = $_ENV["PYTHON_SCRIPT_PATH"];
  533.             $cvID = $candidate->getId();
  534.             $command = escapeshellcmd($pathPython . " " . $pathPythonScript . " " . $cvID);
  535.             shell_exec($command . " > /dev/null 2>/dev/null &");
  536.             $candidate->setAnalyse(false);
  537.             $this->em->persist($candidate);
  538.             $this->em->flush();
  539.         }*/
  540.         return new JsonResponse(["success" => true]);
  541.     }
  542.     /**
  543.      * Mettre à jour la localisation
  544.      * @param Request $request
  545.      * @return JsonResponse
  546.      */
  547.     public function updateLocalisation(Request $request)
  548.     {
  549.         if (!$request->isMethod('POST')) {
  550.             return new JsonResponse(['error' => 'Method not allowed'], 405);
  551.         }
  552.         $user $this->getUser();
  553.         if (!$user) {
  554.             return new JsonResponse(['error' => 'Unauthorized'], 401);
  555.         }
  556.         $candidate $user->getCandidate();
  557.         $data $request->toArray();
  558.         $candidate->setAddress($data['address']);
  559.         $candidate->setCity($data['city']);
  560.         $candidate->setZipcode($data['zipcode']);
  561.         $candidate->setCountry($data['country']);
  562.         $candidate->setPointX(null);
  563.         $candidate->setPointY(null);
  564.         $this->em->persist($candidate);
  565.         $this->em->flush();
  566.         /*
  567.         $uhf = $this->em->getRepository(UsersHasFiches::class)->findOneBy(['user' => $user]);
  568.         if($uhf == null) {
  569.             $tag = "users";
  570.             $category = $this->em->getRepository(FichesCategories::class)->findOneBy(['identifiant' => $tag]);
  571.             $template = $this->em->getRepository(Templates::class)->findOneBy(['tag' => $tag]);
  572.             $yearNumber = $this->dossier->getYearNumber($category);
  573.             $fiche = new Fiches();
  574.             $fiche->setTitle('Nouveau');
  575.             $fiche->setCategory($category);
  576.             $fiche->setIdentifiant($yearNumber);
  577.             $this->em->persist($fiche);
  578.             $groups = $this->em->getRepository(TemplatesHasGroups::class)->findBy(['template' => $template]);
  579.             if($groups != null) {
  580.                 foreach ($groups as $group) {
  581.                     $newGroup = new FichesHasGroups();
  582.                     $newGroup->setTitle($group->getTitle());
  583.                     $newGroup->setIdentifiant($group->getIdentifiant());
  584.                     $newGroup->setSequence($group->getSequence());
  585.                     $newGroup->setFiche($fiche);
  586.                     $this->em->persist($newGroup);
  587.                     $items = $this->em->getRepository(TemplatesHasDetails::class)->findBy(['group' => $group]);
  588.                     if($items != null) {
  589.                         foreach ($items as $item) {
  590.                             $newItem = new FichesHasDetails();
  591.                             $newItem->setTitle($item->getTitle());
  592.                             $newItem->setCreatedAt(new \DateTime('now'));
  593.                             $newItem->setUpdatedAt(new \DateTime('now'));
  594.                             $newItem->setSequence($item->getSequence());
  595.                             $newItem->setIdentifiant($item->getIdentifiant());
  596.                             $newItem->setAction($item->getAction());
  597.                             $newItem->setSize($item->getSize());
  598.                             $newItem->setTypeValues($item->getTypeValues());
  599.                             $newItem->setValues($item->getValues());
  600.                             $newItem->setFiche($fiche);
  601.                             $newItem->setGroup($newGroup);
  602.                             $this->em->persist($newItem);
  603.                         }
  604.                     }
  605.                 }
  606.             }
  607.             $uhf = new UsersHasFiches();
  608.             $uhf->setUser($user);
  609.             $uhf->setFiche($fiche);
  610.             $uhf->setCreatedAt(new \DateTime('now'));
  611.             $uhf->setUpdatedAt(new \DateTime('now'));
  612.             $this->em->persist($uhf);
  613.             $this->em->flush();
  614.         }
  615.         $fiche = $uhf->getFiche();
  616.         $this->dossier->updateItem($fiche,"address",(string)$data['address']);
  617.         $this->dossier->updateItem($fiche,"zipcode",(string)$data['zipcode']);
  618.         $this->dossier->updateItem($fiche,"city",(string)$data['city']);
  619.         $this->dossier->updateItem($fiche,"country",(string)$data['country']);
  620.         */
  621.         return new JsonResponse(['candidate' => $candidate->getId(), 'success' => true]);
  622.     }
  623.     /**
  624.      * Mise à jour du job.
  625.      * @param Request $request
  626.      * @return JsonResponse
  627.      */
  628.     public function updateJob(Request $request): JsonResponse
  629.     {
  630.         if (!$request->isMethod('POST')) {
  631.             return new JsonResponse(['error' => 'Method not allowed'], 405);
  632.         }
  633.         $user $this->getUser();
  634.         if (!$user) {
  635.             return new JsonResponse(['error' => 'Unauthorized'], 401);
  636.         }
  637.         $candidate $user->getCandidate();
  638.         $data $request->toArray();
  639.         $candidate->setTitlejobFr($data['titlejobFr']);
  640.         $candidate->setTitlejobEn($data['titlejobEn']);
  641.         $candidate->setTitlejobDefault($data['titlejobDefault']);
  642.         $candidate->setFuturTitlejobFr($data['futurTitlejobFr']);
  643.         $candidate->setFuturTitlejobEn($data['futurTitlejobEn']);
  644.         $candidate->setFuturTitlejobDefault($data['futurTitlejobDefault']);
  645.         $candidate->setSectorFr($data['sectorFr']);
  646.         $candidate->setSectorEn($data['sectorEn']);
  647.         $candidate->setSectorDefault($data['sectorDefault']);
  648.         $this->em->persist($candidate);
  649.         $this->em->flush();
  650.         return new JsonResponse([
  651.             'candidate' => $candidate->getId(),
  652.             'success' => true
  653.         ]);
  654.     }
  655.     /**
  656.      * Mis à jour des liens.
  657.      * @param Request $request
  658.      * @return JsonResponse
  659.      */
  660.     public function updateWebsite(Request $request): JsonResponse
  661.     {
  662.         if (!$request->isMethod('POST')) {
  663.             return new JsonResponse(['error' => 'Method not allowed'], 405);
  664.         }
  665.         $user $this->getUser();
  666.         if (!$user) {
  667.             return new JsonResponse(['error' => 'Unauthorized'], 401);
  668.         }
  669.         $candidate $user->getCandidate();
  670.         $data $request->toArray();
  671.         $candidate->setWebsiteUrl($data['websiteUrl']);
  672.         $candidate->setLinkedinUrl($data['linkedinUrl']);
  673.         $this->em->persist($candidate);
  674.         $this->em->flush();
  675.         return new JsonResponse([
  676.             'candidate' => $candidate->getId(),
  677.             'success' => true
  678.         ]);
  679.     }
  680.     /**
  681.      * Mise à jour de l'expérience
  682.      * @param Request $request
  683.      * @return JsonResponse
  684.      */
  685.     public function updateExperience(Request $request): JsonResponse
  686.     {
  687.         if (!$request->isMethod('POST')) {
  688.             return new JsonResponse(['error' => 'Method not allowed'], 405);
  689.         }
  690.         $user $this->getUser();
  691.         if (!$user) {
  692.             return new JsonResponse(['error' => 'Unauthorized'], 401);
  693.         }
  694.         $candidate $user->getCandidate();
  695.         $data $request->toArray();
  696.         $candidate->setExpYears($data['exp']);
  697.         $this->em->persist($candidate);
  698.         $this->em->flush();
  699.         return new JsonResponse(['candidate' => $candidate->getId(), 'success' => true]);
  700.     }
  701.     /**
  702.      * Mise à jour de la présentation
  703.      * @param Request $request
  704.      * @return JsonResponse
  705.      */
  706.     public function updatePresentation(Request $request): JsonResponse
  707.     {
  708.         if (!$request->isMethod('POST')) {
  709.             return new JsonResponse(['error' => 'Method not allowed'], 405);
  710.         }
  711.         $user $this->getUser();
  712.         if (!$user) {
  713.             return new JsonResponse(['error' => 'Unauthorized'], 401);
  714.         }
  715.         $candidate $user->getCandidate();
  716.         $data $request->toArray();
  717.         $candidate->setPresentationDefault($data['presentationDefault']);
  718.         $candidate->setPresentationFr($data['presentationFr']);
  719.         $candidate->setPresentationEn($data['presentationEn']);
  720.         $this->em->persist($candidate);
  721.         $this->em->flush();
  722.         return new JsonResponse(['candidate' => $candidate->getId(), 'success' => true]);
  723.     }
  724.     /**
  725.      * Statut du candidat
  726.      * @param Request $request
  727.      * @return JsonResponse
  728.      */
  729.     public function getStatus(Request $request): JsonResponse
  730.     {
  731.         $user $this->getUser();
  732.         if (!$user) {
  733.             return new JsonResponse(['error' => 'Unauthorized'], 401);
  734.         }
  735.         $candidate $user->getCandidate();
  736.         return new JsonResponse([
  737.             'status' => [
  738.                 'salary' => $candidate->isSalary(),
  739.                 'freelance' => $candidate->isFreelance(),
  740.                 'stage' => $candidate->isStage(),
  741.                 'alternance' => $candidate->isAlternance(),
  742.             ]
  743.         ]);
  744.     }
  745.     /**
  746.      * Mise à jour du status du candidat
  747.      * @param Request $request
  748.      * @return JsonResponse
  749.      */
  750.     public function updateStatus(Request $request): JsonResponse
  751.     {
  752.         if (!$request->isMethod('POST')) {
  753.             return new JsonResponse(['error' => 'Method not allowed'], 405);
  754.         }
  755.         $user $this->getUser();
  756.         if (!$user) {
  757.             return new JsonResponse(['error' => 'Unauthorized'], 401);
  758.         }
  759.         $candidate $user->getCandidate();
  760.         $data $request->toArray();
  761.         if (isset($data['status']) && is_array($data['status'])) {
  762.             $statuses $data['status'];
  763.             $candidate->setSalary(false);
  764.             $candidate->setFreelance(false);
  765.             $candidate->setStage(false);
  766.             $candidate->setAlternance(false);
  767.             foreach ($statuses as $status) {
  768.                 switch ($status) {
  769.                     case 'salaried':
  770.                         $candidate->setSalary(true);
  771.                         break;
  772.                     case 'freelance':
  773.                         $candidate->setFreelance(true);
  774.                         break;
  775.                     case 'internship':
  776.                         $candidate->setStage(true);
  777.                         break;
  778.                     case 'apprenticeship':
  779.                         $candidate->setAlternance(true);
  780.                         break;
  781.                 }
  782.             }
  783.         }
  784.         $this->em->persist($candidate);
  785.         $this->em->flush();
  786.         return new JsonResponse([
  787.             'candidate' => $candidate->getId(),
  788.             'success' => true,
  789.             'status' => [
  790.                 'salary' => $candidate->isSalary(),
  791.                 'freelance' => $candidate->isFreelance(),
  792.                 'stage' => $candidate->isStage(),
  793.                 'alternance' => $candidate->isAlternance(),
  794.             ]
  795.         ]);
  796.     }
  797.     /**
  798.      * MIse à jour des disponibbilités
  799.      * @param Request $request
  800.      * @return JsonResponse
  801.      */
  802.     public function updateAvailability(Request $request): JsonResponse
  803.     {
  804.         if (!$request->isMethod('POST')) {
  805.             return new JsonResponse(['error' => 'Method not allowed'], 405);
  806.         }
  807.         $user $this->getUser();
  808.         if (!$user) {
  809.             return new JsonResponse(['error' => 'Unauthorized'], 401);
  810.         }
  811.         $candidate $user->getCandidate();
  812.         $data $request->toArray();
  813.         $candidate->setAvailability($data['availability']);
  814.         if($candidate->getAvailability() === "offline") {
  815.             $candidate->setOnline(false);
  816.         } else {
  817.             $candidate->setOnline(true);
  818.         }
  819.         $this->em->persist($candidate);
  820.         $this->em->flush();
  821.         return new JsonResponse(['candidate' => $candidate->getId(), 'success' => true]);
  822.     }
  823.     /**
  824.      * Mise à jour CV vidéo
  825.      * @param Request $request
  826.      * @return JsonResponse
  827.      */
  828.     public function updateVideo(Request $request): JsonResponse
  829.     {
  830.         if (!$request->isMethod('POST')) {
  831.             return new JsonResponse(['error' => 'Method not allowed'], 405);
  832.         }
  833.         $user $this->getUser();
  834.         if (!$user) {
  835.             return new JsonResponse(['error' => 'Unauthorized'], 401);
  836.         }
  837.         $candidate $user->getCandidate();
  838.         $data $request->toArray();
  839.         $candidate->setVideoEnUrl($data['videoEnUrl']);
  840.         $candidate->setVideoFrUrl($data['videoFrUrl']);
  841.         $candidate->setVideoDefaultUrl($data['videoDefaultUrl']);
  842.         $this->em->persist($candidate);
  843.         $this->em->flush();
  844.         return new JsonResponse(['candidate' => $candidate->getId(), 'success' => true]);
  845.     }
  846.     /**
  847.      * Mise à jour d'une seule vidéo CV par langue.
  848.      * locale = 'fr' | 'en' | 'default'
  849.      * Body   : { videoUrl: string }
  850.      *
  851.      * Différence avec updateVideo() : ne touche qu'une seule URL,
  852.      * pratique pour le dashboard candidat avec 3 onglets de langue.
  853.      *
  854.      * @param Request $request
  855.      * @param string  $locale
  856.      * @return JsonResponse
  857.      */
  858.     public function updateVideoByLocale(Request $requeststring $locale): JsonResponse
  859.     {
  860.         if (!$request->isMethod('POST')) {
  861.             return new JsonResponse(['error' => 'Method not allowed'], 405);
  862.         }
  863.         $user $this->getUser();
  864.         if (!$user) {
  865.             return new JsonResponse(['error' => 'Unauthorized'], 401);
  866.         }
  867.         $candidate $user->getCandidate();
  868.         if (!$candidate) {
  869.             return new JsonResponse(['error' => 'Candidate not found'], 404);
  870.         }
  871.         $data     $request->toArray();
  872.         $videoUrl = isset($data['videoUrl']) ? trim((string)$data['videoUrl']) : '';
  873.         // Validation souple : URL valide OU chaîne vide (= effacer la vidéo)
  874.         if ($videoUrl !== '' && !filter_var($videoUrlFILTER_VALIDATE_URL)) {
  875.             return new JsonResponse(['error' => 'Invalid URL'], 400);
  876.         }
  877.         switch ($locale) {
  878.             case 'fr':
  879.                 $candidate->setVideoFrUrl($videoUrl);
  880.                 break;
  881.             case 'en':
  882.                 $candidate->setVideoEnUrl($videoUrl);
  883.                 break;
  884.             case 'default':
  885.                 $candidate->setVideoDefaultUrl($videoUrl);
  886.                 break;
  887.             default:
  888.                 return new JsonResponse(['error' => 'Invalid locale'], 400);
  889.         }
  890.         $this->em->persist($candidate);
  891.         $this->em->flush();
  892.         return new JsonResponse([
  893.             'candidate' => $candidate->getId(),
  894.             'success'   => true,
  895.             'locale'    => $locale,
  896.             'videoUrl'  => $videoUrl,
  897.         ]);
  898.     }
  899.     /**
  900.      * Publication du CV
  901.      * @param Request $request
  902.      * @return JsonResponse
  903.      */
  904.     public function publish(Request $request): JsonResponse
  905.     {
  906.         $user $this->getUser();
  907.         if (!$user) {
  908.             return new JsonResponse(['error' => 'Unauthorized'], 401);
  909.         }
  910.         $user->setFirst(false);
  911.         $this->em->persist($user);
  912.         $this->em->flush();
  913.         $candidate $user->getCandidate();
  914.         // Statut "open" par défaut uniquement si aucun statut n'est défini (null/vide).
  915.         // Si le candidat a déjà choisi un statut ("open", "ecoute" ou "offline"),
  916.         // on respecte son choix existant.
  917.         if (empty($candidate->getAvailability())) {
  918.             $candidate->setAvailability('open');
  919.         }
  920.         $candidate->setOnline(true);
  921.         $this->em->persist($candidate);
  922.         $this->em->flush();
  923.         return new JsonResponse(['candidate' => $candidate->getId(), 'success' => true]);
  924.     }
  925.     /**
  926.      * Dépublication du CV
  927.      * @param Request $request
  928.      * @return JsonResponse
  929.      */
  930.     public function unpublish(Request $request): JsonResponse
  931.     {
  932.         $user $this->getUser();
  933.         if (!$user) {
  934.             return new JsonResponse(['error' => 'Unauthorized'], 401);
  935.         }
  936.         $user->setFirst(false);
  937.         $this->em->persist($user);
  938.         $this->em->flush();
  939.         $candidate $user->getCandidate();
  940.         $candidate->setAvailability("offline");
  941.         $candidate->setOnline(false);
  942.         $this->em->persist($candidate);
  943.         $this->em->flush();
  944.         return new JsonResponse(['candidate' => $candidate->getId(), 'success' => true]);
  945.     }
  946.     /**
  947.      * CV PDF
  948.      * @param string $base64String
  949.      * @return array|string[]
  950.      */
  951.     private function detectPdfTypeFromBase64(string $base64String): array
  952.     {
  953.         // Supprimer le préfixe data:application/pdf;base64, si présent
  954.         if (preg_match('/^data:application\/pdf;base64,(.+)$/'$base64String$matches)) {
  955.             return [
  956.                 'data' => $matches[1]
  957.             ];
  958.         }
  959.         return ['data' => $base64String];
  960.     }
  961.     /**
  962.      * Sérialise un candidat pour le dashboard recruteur.
  963.      * Renvoie ANCIENS champs multilingues + NOUVEAUX champs (cvData,
  964.      * cvSettings, formAiAnalysis + PDF généré) en parallèle.
  965.      */
  966.     private function serializeCandidateForRecruiter($query$recruiter null): array
  967.     {
  968.         $createdAt $query->getCreatedAt() ? $query->getCreatedAt()->format('c') : '';
  969.         $updatedAt $query->getUpdatedAt() ? $query->getUpdatedAt()->format('c') : '';
  970.         $like false;
  971.         $likeEntity $this->em->getRepository(CandidatesHasLikes::class)
  972.             ->findOneBy(['candidate' => $query->getId()]);
  973.         if ($likeEntity !== null) {
  974.             $like true;
  975.         }
  976.         $haveCV false;
  977.         if ($query->getImage() !== null && $query->getImage()->getName() !== null) {
  978.             $haveCV true;
  979.         }
  980.         $userCandidate $this->em->getRepository(Users::class)
  981.             ->findOneBy(['candidate' => $query->getId()]);
  982.         $avatar64 "";
  983.         if ($userCandidate && $userCandidate->getImage() !== null
  984.             && $userCandidate->getImage()->getName() !== null) {
  985.             $avatar64 $userCandidate->getImageBase64($this->getParameter('kernel.project_dir'));
  986.         }
  987.         $fullname $userCandidate
  988.             ucfirst(strtolower($userCandidate->getName()))." ".strtoupper($userCandidate->getLastname())
  989.             : '';
  990.         // ─── Nouveaux champs (tunnel V2 + IA) ───
  991.         $decode = function(?string $raw) {
  992.             if (!$raw) return null;
  993.             $v json_decode($rawtrue);
  994.             return (json_last_error() === JSON_ERROR_NONE) ? $v null;
  995.         };
  996.         // ⚠️ Filtrage PII : le recruteur ne doit PAS voir email/tel/adresse
  997.         $cvDataDecoded $this->stripSensitiveFields($decode($query->getCvData()));
  998.         $cvSettingsDecoded $this->stripSensitiveFields($decode($query->getCvSettings()));
  999.         $formAiAnalysisDecoded $this->stripSensitiveFields($decode($query->getFormAiAnalysis()));
  1000.         $pdfPath $query->getCvPdfPath();
  1001.         $haveGeneratedCv $pdfPath && file_exists($pdfPath);
  1002.         $generatedCvUrl $haveGeneratedCv
  1003.             rtrim($_ENV['CV_PDF_PUBLIC_URL'] ?? '''/') . '/' basename($pdfPath)
  1004.             : null;
  1005.         return [
  1006.             // ═══ Base ═══
  1007.             "id"                        => (string)$query->getId(),
  1008.             "createdAt"                 => $createdAt,
  1009.             "updatedAt"                 => $updatedAt,
  1010.             "fullname"                  => $fullname,
  1011.             "avatar"                    => $avatar64,
  1012.             "name"                      => $userCandidate ? (string)$userCandidate->getName() : '',
  1013.             "lastname"                  => $userCandidate ? (string)$userCandidate->getLastname() : '',
  1014.             "like"                      => $like,
  1015.             "haveCV"                    => $haveCV || $haveGeneratedCv,
  1016.             // ═══ NOUVEAU système (tunnel V2 + IA) ═══
  1017.             "cvData"                    => $cvDataDecoded,
  1018.             "cvSettings"                => $cvSettingsDecoded,
  1019.             "formAiAnalysis"            => $formAiAnalysisDecoded,
  1020.             "formAiAnalysisStatus"      => $query->getFormAiAnalysisStatus(),
  1021.             "formAiAnalysisUpdatedAt"   => $query->getFormAiAnalysisUpdatedAt()
  1022.                 ? $query->getFormAiAnalysisUpdatedAt()->format('c')
  1023.                 : null,
  1024.             "haveGeneratedCv"           => $haveGeneratedCv,
  1025.             "generatedCvUrl"            => $generatedCvUrl,
  1026.             // ═══ ANCIEN système (legacy multilingue) ═══
  1027.             "defaultLocale"             => $query->getDefaultLocale(),
  1028.             "frLanguage"                => (bool)$query->isFrench(),
  1029.             "enLanguage"                => (bool)$query->isEnglish(),
  1030.             "defaultLanguage"           => (bool)$query->isDefault(),
  1031.             "resumeRecruiterFr"         => (string)$query->getResumeRecruiterFr(),
  1032.             "resumeRecruiterEn"         => (string)$query->getResumeRecruiterEn(),
  1033.             "resumeRecruiterDefault"    => (string)$query->getResumeRecruiterDefault(),
  1034.             "presentationFr"            => (string)$query->getPresentationFr(),
  1035.             "presentationEn"            => (string)$query->getPresentationEn(),
  1036.             "presentationDefault"       => (string)$query->getPresentationDefault(),
  1037.             "resumeExpFr"               => (string)$query->getResumeExpFr(),
  1038.             "resumeExpEn"               => (string)$query->getResumeExpEn(),
  1039.             "resumeExpDefault"          => (string)$query->getResumeExpDefault(),
  1040.             "resumeProjectsFr"          => (string)$query->getResumeProjectsFr(),
  1041.             "resumeProjectsEn"          => (string)$query->getResumeProjectsEn(),
  1042.             "resumeProjectsDefault"     => (string)$query->getResumeProjectsDefault(),
  1043.             "hardSkillsFr"              => json_decode($query->getHardSkillsFr()),
  1044.             "hardSkillsEn"              => json_decode($query->getHardSkillsEn()),
  1045.             "hardSkillsDefault"         => json_decode($query->getHardSkillsDefault()),
  1046.             "softSkillsFr"              => json_decode($query->getSoftSkillsFr()),
  1047.             "softSkillsEn"              => json_decode($query->getSoftSkillsEn()),
  1048.             "softSkillsDefault"         => json_decode($query->getSoftSkillsDefault()),
  1049.             "sectorFr"                  => (string)$query->getSectorFr(),
  1050.             "sectorEn"                  => (string)$query->getSectorEn(),
  1051.             "sectorDefault"             => (string)$query->getSectorDefault(),
  1052.             "jobTitleFr"                => (string)$query->getTitlejobFr(),
  1053.             "jobTitleEn"                => (string)$query->getTitlejobEn(),
  1054.             "jobTitleDefault"           => (string)$query->getTitlejobDefault(),
  1055.             "futurTitlejobFr"           => (string)$query->getFuturTitlejobFr(),
  1056.             "futurTitlejobEn"           => (string)$query->getFuturTitlejobEn(),
  1057.             "futurTitlejobDefault"      => (string)$query->getFuturTitlejobDefault(),
  1058.             "videoFrUrl"                => (string)$query->getVideoFrUrl(),
  1059.             "videoEnUrl"                => (string)$query->getVideoEnUrl(),
  1060.             "videoDefaultUrl"           => (string)$query->getVideoDefaultUrl(),
  1061.             // ═══ Champs communs ═══
  1062.             "availability"              => $query->getAvailability(),
  1063.             "city"                      => (string)$query->getCity(),
  1064.             "country"                   => $query->getCountry(),
  1065.             "salary"                    => (bool)$query->isSalary(),
  1066.             "freelance"                 => (bool)$query->isFreelance(),
  1067.             "stage"                     => (bool)$query->isStage(),
  1068.             "alternance"                => (bool)$query->isAlternance(),
  1069.             "websiteUrl"                => $query->getWebsiteUrl(),
  1070.             "linkedinUrl"               => $query->getLinkedinUrl(),
  1071.             "experience"                => $query->getExpYears() ?: 0,
  1072.         ];
  1073.     }
  1074.     /**
  1075.      * Supprime récursivement les champs PII (email, téléphone, adresse)
  1076.      * d'un tableau JSON décodé. Utilisé pour nettoyer cvData / formAiAnalysis
  1077.      * avant envoi au recruteur.
  1078.      */
  1079.     private function stripSensitiveFields($data)
  1080.     {
  1081.         if (!is_array($data)) {
  1082.             return $data;
  1083.         }
  1084.         // Clés PII (insensible à la casse, match partiel)
  1085.         $sensitivePatterns = [
  1086.             'email''mail',
  1087.             'phone''telephone''tel''mobile''portable',
  1088.             'address''adresse''street''rue',
  1089.             'zipcode''zip_code''postal_code''postalcode''code_postal',
  1090.         ];
  1091.         $filtered = [];
  1092.         foreach ($data as $key => $value) {
  1093.             $isSensitive false;
  1094.             if (is_string($key)) {
  1095.                 $keyLower strtolower($key);
  1096.                 foreach ($sensitivePatterns as $pattern) {
  1097.                     if (strpos($keyLower$pattern) !== false) {
  1098.                         $isSensitive true;
  1099.                         break;
  1100.                     }
  1101.                 }
  1102.             }
  1103.             if ($isSensitive) {
  1104.                 continue;
  1105.             }
  1106.             if (is_array($value)) {
  1107.                 $filtered[$key] = $this->stripSensitiveFields($value);
  1108.             } else {
  1109.                 $filtered[$key] = $value;
  1110.             }
  1111.         }
  1112.         return $filtered;
  1113.     }
  1114. }