<?phpnamespace App\Controller\Api\Cvs;use App\Entity\Core\Users;use App\Entity\Core\UsersHasFiches;use App\Entity\Cvs\Candidates;use App\Entity\Cvs\CandidatesHasLikes;use App\Entity\Cvs\CandidatesHasStatistics;use App\Entity\Dossiers\Fiches;use App\Entity\Dossiers\FichesCategories;use App\Entity\Dossiers\FichesHasDetails;use App\Entity\Dossiers\FichesHasGroups;use App\Entity\Dossiers\TemplatesHasDetails;use App\Entity\Dossiers\TemplatesHasGroups;use App\Entity\Messenger\Groups;use App\Entity\Messenger\Messages;use App\Entity\Pages\Templates;use App\Services\Dossiers;use Sensio\Bundle\FrameworkExtraBundle\Configuration\Cache;use Sensio\Bundle\FrameworkExtraBundle\Configuration\IsGranted;use Sensio\Bundle\FrameworkExtraBundle\Configuration\ParamConverter;use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;use Symfony\Component\EventDispatcher\EventDispatcherInterface;use Symfony\Component\HttpFoundation\Request;use Symfony\Component\HttpFoundation\Response;use Symfony\Component\Routing\Annotation\Route;use Symfony\Component\HttpFoundation\JsonResponse;use Doctrine\ORM\EntityManagerInterface;use Knp\Component\Pager\PaginatorInterface;use Symfony\Component\Form\Extension\Core\Type\CheckboxType;use Symfony\Component\Form\Extension\Core\Type\ChoiceType;use Symfony\Component\Form\Extension\Core\Type\DateTimeType;use Symfony\Component\Form\Extension\Core\Type\EmailType;use Symfony\Component\Form\Extension\Core\Type\HiddenType;use Symfony\Component\Form\Extension\Core\Type\NumberType;use Symfony\Component\Form\Extension\Core\Type\SubmitType;use Symfony\Component\Form\Extension\Core\Type\TextareaType;use Symfony\Component\Form\Extension\Core\Type\TextType;use Symfony\Component\HttpFoundation\Session\Session;use Symfony\Component\HttpFoundation\File\UploadedFile;class CandidatesController extends AbstractController{ private $em; private $paginator; private $dossier; public function __construct(EntityManagerInterface $em, PaginatorInterface $paginator, Dossiers $dossier) { $this->em = $em; $this->paginator = $paginator; $this->dossier = $dossier; } /** * Liste des candidats. * @param Request $request * @param $start * @param $limit * @return JsonResponse */ public function recents(Request $request, $start, $limit) { $user = $this->getUser(); $queries = $this->em->getRepository(Candidates::class)->recents($start, $limit); if ($queries === null) { return new JsonResponse(null); } $array = []; foreach ($queries as $query) { $array[] = $this->serializeCandidateForRecruiter($query, $user); } $chs = new CandidatesHasStatistics(); $chs->setCandidate(null); $chs->setType("list_candidates"); $chs->setUser($user); $this->em->persist($chs); $this->em->flush(); return new JsonResponse($array); } /** * Liste des likes. * @param Request $request * @param $start * @param $limit * @return JsonResponse */ public function likes(Request $request, $start, $limit) { $user = $this->getUser(); $queries = $this->em->getRepository(Candidates::class)->searchLikes($user, null, $start, $limit); if ($queries === null) { return new JsonResponse(null); } $array = []; foreach ($queries as $query) { $array[] = $this->serializeCandidateForRecruiter($query, $user); } $chs = new CandidatesHasStatistics(); $chs->setCandidate(null); $chs->setType("list_favorites"); $chs->setUser($user); $this->em->persist($chs); $this->em->flush(); return new JsonResponse($array); } /** * Rechercher un candidat favoris * @param Request $request * @param $start * @param $limit * @return JsonResponse */ public function searchLikes(Request $request, $start, $limit) { $user = $this->getUser(); $data = json_decode($request->getContent(), true); $term = $data['term'] ?? ''; if (empty($term)) { return new JsonResponse([ 'error' => 'Le paramètre "term" est requis' ], 400); } $queries = $this->em->getRepository(Candidates::class)->searchLikes($user, $term, $start, $limit); if ($queries === null) { return new JsonResponse(null); } $array = []; foreach ($queries as $query) { $array[] = $this->serializeCandidateForRecruiter($query, $user); } $chs = new CandidatesHasStatistics(); $chs->setCandidate(null); $chs->setType("search_favorites"); $chs->setUser($user); $this->em->persist($chs); $this->em->flush(); return new JsonResponse($array); } /** * Recherche un candidat * @param Request $request * @param $start * @param $limit * @return JsonResponse */ public function searchRecents(Request $request, $start, $limit) { $user = $this->getUser(); $data = json_decode($request->getContent(), true); $term = $data['term'] ?? ''; if (empty($term)) { return new JsonResponse([ 'error' => 'Le paramètre "term" est requis' ], 400); } $queries = $this->em->getRepository(Candidates::class)->searchRecents($term, $start, $limit); if ($queries === null) { return new JsonResponse(null); } $array = []; foreach ($queries as $query) { $array[] = $this->serializeCandidateForRecruiter($query, $user); } $chs = new CandidatesHasStatistics(); $chs->setCandidate(null); $chs->setType("search_candidates"); $chs->setUser($user); $this->em->persist($chs); $this->em->flush(); return new JsonResponse($array); } /** * Lire un CV * @param Request $request * @param Candidates $candidate * @return JsonResponse */ public function readCV(Request $request, Candidates $candidate) { $user = $this->getUser(); $cv64 = null; $source = null; // 'generated' | 'uploaded' | null // ─── Priorité 1 : CV généré (tunnel V2, PDF sur disque) ─── $pdfPath = $candidate->getCvPdfPath(); if ($pdfPath && file_exists($pdfPath)) { try { $pdfContent = @file_get_contents($pdfPath); if ($pdfContent !== false && strlen($pdfContent) > 0) { $cv64 = base64_encode($pdfContent); $source = 'generated'; } } catch (\Exception $e) { // Fallback sur CV uploadé si lecture échoue $cv64 = null; } } // ─── Priorité 2 : CV uploadé par le candidat ─── if ($cv64 === null && $candidate->getImage() !== null && $candidate->getImage()->getName() !== null) { $cv64 = $candidate->getImageBase64($this->getParameter('kernel.project_dir')); if ($cv64) { $source = 'uploaded'; } } // ─── Statistique de consultation ─── $chs = new CandidatesHasStatistics(); $chs->setCandidate($candidate); $chs->setType("readcv"); $chs->setUser($user); $this->em->persist($chs); $this->em->flush(); return new JsonResponse([ "file" => $cv64, "source" => $source, // informatif : 'generated', 'uploaded' ou null ]); } /** * Nouvelle conversation. * @param Request $request * @param Candidates $candidate * @return JsonResponse */ public function newConversation(Request $request, Candidates $candidate) { $user = $this->getUser(); $userCandidate = $this->em->getRepository(Users::class)->findOneBy(['candidate' => $candidate->getId()]); $groups = $this->em->getRepository(Groups::class)->findOrCreateConversation($user,$userCandidate); if($groups == null) { $hash = Groups::generateConversationHash($user, $userCandidate); $groups = new Groups(); $groups->setUserOne($user); $groups->setUserTwo($user); $groups->setConversationHash($hash); $groups->setTitle(""); $this->em->persist($groups); $this->em->flush(); } $chs = new CandidatesHasStatistics(); $chs->setCreatedAt(new \DateTime("now")); $chs->setUpdatedAt(new \DateTime("now")); $chs->setCandidate($userCandidate->getCandidate()); $chs->setType("new_conversation"); $chs->setUser($user); $this->em->persist($chs); $this->em->flush(); return new JsonResponse(["group" => $groups->getId()]); } /** * Like un candidat. * @param Request $request * @param Candidates $candidate * @return JsonResponse */ public function like(Request $request, Candidates $candidate) { $user = $this->getUser(); $like = $this->em->getRepository(CandidatesHasLikes::class)->findOneBy(['candidate' =>$candidate]); if($like !== null) { $this->em->remove($like); $this->em->flush(); return new JsonResponse(["success" => true, 'like' => false]); } $like = new CandidatesHasLikes(); $like->setCandidate($candidate); $like->setUser($user); $this->em->persist($like); $this->em->flush(); $chs = new CandidatesHasStatistics(); $chs->setCandidate($candidate); $chs->setType("like"); $chs->setUser($user); $this->em->persist($chs); $this->em->flush(); return new JsonResponse(["success" => true, 'like' => true]); } /** * Visionner une conversation ? * @param Request $request * @param Groups $group * @return JsonResponse */ public function show(Request $request,Groups $group) { $user = $this->getUser(); $noWay = false; if($group->getUserOne() === $user) { $noWay = true; } if($group->getUserTwo() === $user) { $noWay = true; } if($noWay === false) { return new JsonResponse(null); } $queries = $this->em->getRepository(Messages::class)->findBy(['group' => $group],['createdAt'=>'ASC']); $array = []; foreach ($queries as $query) { $createdAt = ""; if($query->getCreatedAt() !== null) { $createdAt = $query->getCreatedAt()->format('c'); } $array[] = [ "id" => (string)$query->getId(), "message" => (string)$query->getMessage(), "createdAt" => $createdAt ]; } return new JsonResponse($array); } /** * Fiche d'un candidat * @param Request $request * @return JsonResponse */ public function info(Request $request): JsonResponse { $user = $this->getUser(); $candidate = $user->getCandidate(); $userCandidate = $this->em->getRepository(Users::class)->findoneBy(['candidate' => $candidate->getId()]); $createdAt = ""; if($candidate->getCreatedAt() !== null) { $createdAt = $candidate->getCreatedAt()->format('c'); } $updatedAt = ""; if($candidate->getUpdatedAt() !== null) { $updatedAt = $candidate->getUpdatedAt()->format('c'); } $cv64 = ""; $haveCV = false; if($candidate->getImage() !== null && $candidate->getImage()->getName() !== null){ $cv64 = $candidate->getImageBase64($this->getParameter('kernel.project_dir')); $haveCV = true; } $avatar64 = ""; if($userCandidate->getImage() !== null && $userCandidate->getImage()->getName() !== null){ $avatar64 = $userCandidate->getImageBase64($this->getParameter('kernel.project_dir')); } $array = [ "id" => (string)$candidate->getId(), "createdAt" => $createdAt, "updatedAt" => $updatedAt, "fullname" => ucfirst(strtolower($userCandidate->getName()))." ".strtoupper($userCandidate->getLastname()), "avatar" => $avatar64, "name" => (string)$userCandidate->getName(), "lastname" => (string)$userCandidate->getLastname(), "defaultLocale" => $candidate->getDefaultLocale(), "frLanguage" => (bool)$candidate->isFrench(), "enLanguage" => (bool)$candidate->isEnglish(), "defaultLanguage" => (bool)$candidate->isDefault(), "resumeRecruiterFr" => (string)$candidate->getResumeRecruiterFr(), "resumeRecruiterEn" => (string)$candidate->getResumeRecruiterEn(), "resumeRecruiterDefault" => (string)$candidate->getResumeRecruiterDefault(), "resumeExpFr" => (string)$candidate->getResumeExpFr(), "resumeExpEn" => (string)$candidate->getResumeExpEn(), "resumeExpDefault" => (string)$candidate->getResumeExpDefault(), "resumeProjectsFr" => (string)$candidate->getResumeProjectsFr(), "resumeProjectsEn" => (string)$candidate->getResumeProjectsEn(), "resumeProjectsDefault" => (string)$candidate->getResumeProjectsDefault(), "presentationFr" => (string)$candidate->getPresentationFr(), "presentationEn" => (string)$candidate->getPresentationEn(), "presentationDefault" => (string)$candidate->getPresentationDefault(), "haveCV" => $haveCV, "hardSkillsFr" => json_decode($candidate->getHardSkillsFr()), "hardSkillsEn" => json_decode($candidate->getHardSkillsEn()), "hardSkillsDefault" => json_decode($candidate->getHardSkillsDefault()), "softSkillsFr" => json_decode($candidate->getSoftSkillsFr()), "softSkillsEn" => json_decode($candidate->getSoftSkillsEn()), "softSkillsDefault" => json_decode($candidate->getSoftSkillsDefault()), "sectorFr" => (string)$candidate->getSectorFr(), "sectorEn" => (string)$candidate->getSectorEn(), "sectorDefault" => (string)$candidate->getSectorDefault(), "jobTitleFr" => (string)$candidate->getTitlejobFr(), "jobTitleEn" => (string)$candidate->getTitlejobEn(), "jobTitleDefault" => (string)$candidate->getTitlejobDefault(), "futurTitlejobFr" => (string)$candidate->getFuturTitlejobFr(), "futurTitlejobEn" => (string)$candidate->getFuturTitlejobEn(), "futurTitlejobDefault" => (string)$candidate->getFuturTitlejobDefault(), "availability" => $candidate->getAvailability(), "cv" => "", // Chargé à la demande via GET /info/uploaded-cv-base64 "address" => $candidate->getAddress(), "zipcode" => $candidate->getZipcode(), "country" => $candidate->getCountry(), 'city' => $candidate->getCity(), 'salary' => $candidate->isSalary(), 'freelance' => $candidate->isFreelance(), 'stage' => $candidate->isStage(), 'alternance' => $candidate->isAlternance(), "websiteUrl" => $candidate->getWebsiteUrl(), "linkedinUrl" => $candidate->getLinkedinUrl(), "videoFrUrl" => (string)$candidate->getVideoFrUrl(), "videoEnUrl" => (string)$candidate->getVideoEnUrl(), "videoDefaultUrl" => (string)$candidate->getVideoDefaultUrl(), "experience" => $candidate->getExpYears() ?: 0, "formAiAnalysis" => $candidate->getFormAiAnalysis() ? json_decode($candidate->getFormAiAnalysis(), true) : null, "formAiAnalysisStatus" => $candidate->getFormAiAnalysisStatus(), "formAiAnalysisUpdatedAt" => $candidate->getFormAiAnalysisUpdatedAt() ? $candidate->getFormAiAnalysisUpdatedAt()->format('c') : null, // ── CV généré via le tunnel V2 (distinct du CV uploadé dans "cv") ── "haveGeneratedCv" => ($candidate->getCvPdfPath() && file_exists($candidate->getCvPdfPath())), "generatedCvUrl" => ($candidate->getCvPdfPath() && file_exists($candidate->getCvPdfPath())) ? rtrim($_ENV['CV_PDF_PUBLIC_URL'] ?? '', '/') . '/' . basename($candidate->getCvPdfPath()) : null, ]; $chs = new CandidatesHasStatistics(); $chs->setCandidate($candidate); $chs->setType("view_candidate"); $chs->setUser($user); $this->em->persist($chs); $this->em->flush(); return new JsonResponse($array); } /** /** * Retourne le CV uploadé encodé en base64. * Endpoint dédié pour ne pas alourdir /info avec un gros payload. * GET /api/cvs/candidates/info/uploaded-cv-base64 */ public function getUploadedCvBase64(Request $request): JsonResponse { $user = $this->getUser(); if (!$user) return new JsonResponse(['error' => 'Unauthorized'], 401); $candidate = $user->getCandidate(); if (!$candidate) return new JsonResponse(['error' => 'Candidat introuvable'], 404); if ($candidate->getImage() === null || $candidate->getImage()->getName() === null) { return new JsonResponse(['success' => false, 'error' => 'Aucun CV uploadé'], 404); } try { $cv64 = $candidate->getImageBase64($this->getParameter('kernel.project_dir')); if (empty($cv64)) { return new JsonResponse(['success' => false, 'error' => 'Fichier CV introuvable sur le disque'], 404); } return new JsonResponse([ 'success' => true, 'base64' => $cv64, 'filename' => $candidate->getImage()->getName(), ]); } catch (\Throwable $e) { return new JsonResponse(['success' => false, 'error' => $e->getMessage()], 500); } } /** * Enregistrer le CV * @param Request $request * @return JsonResponse */ public function uploadCV(Request $request): JsonResponse { if (!$request->isMethod('POST')) { return new JsonResponse(['error' => 'Method not allowed'], 405); } $user = $this->getUser(); if (!$user) { return new JsonResponse(['error' => 'Unauthorized'], 401); } $candidate = $user->getCandidate(); if (!$candidate) { return new JsonResponse(['error' => 'Candidate not found'], 404); } $data = $request->toArray(); if (!empty($data['cv'])) { try { $pdfInfo = $this->detectPdfTypeFromBase64($data['cv']); // Décoder le base64 $decodedPdf = base64_decode($pdfInfo['data']); if ($decodedPdf === false) { return new JsonResponse(['error' => 'Invalid base64 PDF'], 400); } // Créer un fichier temporaire avec un nom unique $tmpFilePath = sys_get_temp_dir() . '/' . uniqid('cv_', true) . '.pdf'; // Écrire le PDF décodé dans le fichier temporaire $bytesWritten = file_put_contents($tmpFilePath, $decodedPdf); if ($bytesWritten === false) { return new JsonResponse(['error' => 'Failed to write temporary file'], 500); } // ✅ IMPORTANT : Créer un objet UploadedFile pour VichUploader $uploadedFile = new UploadedFile( $tmpFilePath, // Chemin du fichier temporaire 'cv.pdf', // Nom original du fichier 'application/pdf', // Type MIME null, // Erreur (null = pas d'erreur) true // test mode = true (fichier temporaire) ); // Assigner le fichier à l'entité via VichUploader $candidate->setImageFile($uploadedFile); // ⚠️ Adapter selon votre nom de méthode (setCvFile?) // ✅ IMPORTANT : Mettre à jour le timestamp pour forcer VichUploader à détecter le changement $candidate->setUpdatedAt(new \DateTimeImmutable()); } catch (\Exception $e) { // Nettoyer le fichier temporaire en cas d'erreur if (isset($tmpFilePath) && file_exists($tmpFilePath)) { @unlink($tmpFilePath); } return new JsonResponse(['error' => 'Upload failed: ' . $e->getMessage()], 500); } } $this->em->persist($candidate); $this->em->flush(); // Nettoyer le fichier temporaire après le flush if (isset($tmpFilePath) && file_exists($tmpFilePath)) { @unlink($tmpFilePath); } return new JsonResponse(['success' => true]); } /** * Analyse du CV si c'est finis ou en cours. * @param Request $request * @return JsonResponse */ public function analyseCV(Request $request): JsonResponse { $user = $this->getUser(); $candidate = $user->getCandidate(); return new JsonResponse([ "analyse" => $candidate->getAnalyse(), ]); } /** * Exécution de l'analyse par Python. * @param Request $request * @return JsonResponse */ public function executionCV(Request $request): JsonResponse { $user = $this->getUser(); $candidate = $user->getCandidate(); $pathPython = $_ENV["PYTHON_PATH"]; //if ($candidate->getAnalyse() === true) { $pathPythonScript = $_ENV["PYTHON_SCRIPT_PATH"]; $cvID = $candidate->getId(); $command = escapeshellcmd($pathPython . " " . $pathPythonScript . " " . $cvID); shell_exec($command . " > /dev/null 2>/dev/null &"); $candidate->setAnalyse(false); $this->em->persist($candidate); $this->em->flush(); /*} if ($candidate->getAnalyse() !== true) { $pathPythonScript = $_ENV["PYTHON_SCRIPT_PATH"]; $cvID = $candidate->getId(); $command = escapeshellcmd($pathPython . " " . $pathPythonScript . " " . $cvID); shell_exec($command . " > /dev/null 2>/dev/null &"); $candidate->setAnalyse(false); $this->em->persist($candidate); $this->em->flush(); }*/ return new JsonResponse(["success" => true]); } /** * Mettre à jour la localisation * @param Request $request * @return JsonResponse */ public function updateLocalisation(Request $request) { if (!$request->isMethod('POST')) { return new JsonResponse(['error' => 'Method not allowed'], 405); } $user = $this->getUser(); if (!$user) { return new JsonResponse(['error' => 'Unauthorized'], 401); } $candidate = $user->getCandidate(); $data = $request->toArray(); $candidate->setAddress($data['address']); $candidate->setCity($data['city']); $candidate->setZipcode($data['zipcode']); $candidate->setCountry($data['country']); $candidate->setPointX(null); $candidate->setPointY(null); $this->em->persist($candidate); $this->em->flush(); /* $uhf = $this->em->getRepository(UsersHasFiches::class)->findOneBy(['user' => $user]); if($uhf == null) { $tag = "users"; $category = $this->em->getRepository(FichesCategories::class)->findOneBy(['identifiant' => $tag]); $template = $this->em->getRepository(Templates::class)->findOneBy(['tag' => $tag]); $yearNumber = $this->dossier->getYearNumber($category); $fiche = new Fiches(); $fiche->setTitle('Nouveau'); $fiche->setCategory($category); $fiche->setIdentifiant($yearNumber); $this->em->persist($fiche); $groups = $this->em->getRepository(TemplatesHasGroups::class)->findBy(['template' => $template]); if($groups != null) { foreach ($groups as $group) { $newGroup = new FichesHasGroups(); $newGroup->setTitle($group->getTitle()); $newGroup->setIdentifiant($group->getIdentifiant()); $newGroup->setSequence($group->getSequence()); $newGroup->setFiche($fiche); $this->em->persist($newGroup); $items = $this->em->getRepository(TemplatesHasDetails::class)->findBy(['group' => $group]); if($items != null) { foreach ($items as $item) { $newItem = new FichesHasDetails(); $newItem->setTitle($item->getTitle()); $newItem->setCreatedAt(new \DateTime('now')); $newItem->setUpdatedAt(new \DateTime('now')); $newItem->setSequence($item->getSequence()); $newItem->setIdentifiant($item->getIdentifiant()); $newItem->setAction($item->getAction()); $newItem->setSize($item->getSize()); $newItem->setTypeValues($item->getTypeValues()); $newItem->setValues($item->getValues()); $newItem->setFiche($fiche); $newItem->setGroup($newGroup); $this->em->persist($newItem); } } } } $uhf = new UsersHasFiches(); $uhf->setUser($user); $uhf->setFiche($fiche); $uhf->setCreatedAt(new \DateTime('now')); $uhf->setUpdatedAt(new \DateTime('now')); $this->em->persist($uhf); $this->em->flush(); } $fiche = $uhf->getFiche(); $this->dossier->updateItem($fiche,"address",(string)$data['address']); $this->dossier->updateItem($fiche,"zipcode",(string)$data['zipcode']); $this->dossier->updateItem($fiche,"city",(string)$data['city']); $this->dossier->updateItem($fiche,"country",(string)$data['country']); */ return new JsonResponse(['candidate' => $candidate->getId(), 'success' => true]); } /** * Mise à jour du job. * @param Request $request * @return JsonResponse */ public function updateJob(Request $request): JsonResponse { if (!$request->isMethod('POST')) { return new JsonResponse(['error' => 'Method not allowed'], 405); } $user = $this->getUser(); if (!$user) { return new JsonResponse(['error' => 'Unauthorized'], 401); } $candidate = $user->getCandidate(); $data = $request->toArray(); $candidate->setTitlejobFr($data['titlejobFr']); $candidate->setTitlejobEn($data['titlejobEn']); $candidate->setTitlejobDefault($data['titlejobDefault']); $candidate->setFuturTitlejobFr($data['futurTitlejobFr']); $candidate->setFuturTitlejobEn($data['futurTitlejobEn']); $candidate->setFuturTitlejobDefault($data['futurTitlejobDefault']); $candidate->setSectorFr($data['sectorFr']); $candidate->setSectorEn($data['sectorEn']); $candidate->setSectorDefault($data['sectorDefault']); $this->em->persist($candidate); $this->em->flush(); return new JsonResponse([ 'candidate' => $candidate->getId(), 'success' => true ]); } /** * Mis à jour des liens. * @param Request $request * @return JsonResponse */ public function updateWebsite(Request $request): JsonResponse { if (!$request->isMethod('POST')) { return new JsonResponse(['error' => 'Method not allowed'], 405); } $user = $this->getUser(); if (!$user) { return new JsonResponse(['error' => 'Unauthorized'], 401); } $candidate = $user->getCandidate(); $data = $request->toArray(); $candidate->setWebsiteUrl($data['websiteUrl']); $candidate->setLinkedinUrl($data['linkedinUrl']); $this->em->persist($candidate); $this->em->flush(); return new JsonResponse([ 'candidate' => $candidate->getId(), 'success' => true ]); } /** * Mise à jour de l'expérience * @param Request $request * @return JsonResponse */ public function updateExperience(Request $request): JsonResponse { if (!$request->isMethod('POST')) { return new JsonResponse(['error' => 'Method not allowed'], 405); } $user = $this->getUser(); if (!$user) { return new JsonResponse(['error' => 'Unauthorized'], 401); } $candidate = $user->getCandidate(); $data = $request->toArray(); $candidate->setExpYears($data['exp']); $this->em->persist($candidate); $this->em->flush(); return new JsonResponse(['candidate' => $candidate->getId(), 'success' => true]); } /** * Mise à jour de la présentation * @param Request $request * @return JsonResponse */ public function updatePresentation(Request $request): JsonResponse { if (!$request->isMethod('POST')) { return new JsonResponse(['error' => 'Method not allowed'], 405); } $user = $this->getUser(); if (!$user) { return new JsonResponse(['error' => 'Unauthorized'], 401); } $candidate = $user->getCandidate(); $data = $request->toArray(); $candidate->setPresentationDefault($data['presentationDefault']); $candidate->setPresentationFr($data['presentationFr']); $candidate->setPresentationEn($data['presentationEn']); $this->em->persist($candidate); $this->em->flush(); return new JsonResponse(['candidate' => $candidate->getId(), 'success' => true]); } /** * Statut du candidat * @param Request $request * @return JsonResponse */ public function getStatus(Request $request): JsonResponse { $user = $this->getUser(); if (!$user) { return new JsonResponse(['error' => 'Unauthorized'], 401); } $candidate = $user->getCandidate(); return new JsonResponse([ 'status' => [ 'salary' => $candidate->isSalary(), 'freelance' => $candidate->isFreelance(), 'stage' => $candidate->isStage(), 'alternance' => $candidate->isAlternance(), ] ]); } /** * Mise à jour du status du candidat * @param Request $request * @return JsonResponse */ public function updateStatus(Request $request): JsonResponse { if (!$request->isMethod('POST')) { return new JsonResponse(['error' => 'Method not allowed'], 405); } $user = $this->getUser(); if (!$user) { return new JsonResponse(['error' => 'Unauthorized'], 401); } $candidate = $user->getCandidate(); $data = $request->toArray(); if (isset($data['status']) && is_array($data['status'])) { $statuses = $data['status']; $candidate->setSalary(false); $candidate->setFreelance(false); $candidate->setStage(false); $candidate->setAlternance(false); foreach ($statuses as $status) { switch ($status) { case 'salaried': $candidate->setSalary(true); break; case 'freelance': $candidate->setFreelance(true); break; case 'internship': $candidate->setStage(true); break; case 'apprenticeship': $candidate->setAlternance(true); break; } } } $this->em->persist($candidate); $this->em->flush(); return new JsonResponse([ 'candidate' => $candidate->getId(), 'success' => true, 'status' => [ 'salary' => $candidate->isSalary(), 'freelance' => $candidate->isFreelance(), 'stage' => $candidate->isStage(), 'alternance' => $candidate->isAlternance(), ] ]); } /** * MIse à jour des disponibbilités * @param Request $request * @return JsonResponse */ public function updateAvailability(Request $request): JsonResponse { if (!$request->isMethod('POST')) { return new JsonResponse(['error' => 'Method not allowed'], 405); } $user = $this->getUser(); if (!$user) { return new JsonResponse(['error' => 'Unauthorized'], 401); } $candidate = $user->getCandidate(); $data = $request->toArray(); $candidate->setAvailability($data['availability']); if($candidate->getAvailability() === "offline") { $candidate->setOnline(false); } else { $candidate->setOnline(true); } $this->em->persist($candidate); $this->em->flush(); return new JsonResponse(['candidate' => $candidate->getId(), 'success' => true]); } /** * Mise à jour CV vidéo * @param Request $request * @return JsonResponse */ public function updateVideo(Request $request): JsonResponse { if (!$request->isMethod('POST')) { return new JsonResponse(['error' => 'Method not allowed'], 405); } $user = $this->getUser(); if (!$user) { return new JsonResponse(['error' => 'Unauthorized'], 401); } $candidate = $user->getCandidate(); $data = $request->toArray(); $candidate->setVideoEnUrl($data['videoEnUrl']); $candidate->setVideoFrUrl($data['videoFrUrl']); $candidate->setVideoDefaultUrl($data['videoDefaultUrl']); $this->em->persist($candidate); $this->em->flush(); return new JsonResponse(['candidate' => $candidate->getId(), 'success' => true]); } /** * Mise à jour d'une seule vidéo CV par langue. * locale = 'fr' | 'en' | 'default' * Body : { videoUrl: string } * * Différence avec updateVideo() : ne touche qu'une seule URL, * pratique pour le dashboard candidat avec 3 onglets de langue. * * @param Request $request * @param string $locale * @return JsonResponse */ public function updateVideoByLocale(Request $request, string $locale): JsonResponse { if (!$request->isMethod('POST')) { return new JsonResponse(['error' => 'Method not allowed'], 405); } $user = $this->getUser(); if (!$user) { return new JsonResponse(['error' => 'Unauthorized'], 401); } $candidate = $user->getCandidate(); if (!$candidate) { return new JsonResponse(['error' => 'Candidate not found'], 404); } $data = $request->toArray(); $videoUrl = isset($data['videoUrl']) ? trim((string)$data['videoUrl']) : ''; // Validation souple : URL valide OU chaîne vide (= effacer la vidéo) if ($videoUrl !== '' && !filter_var($videoUrl, FILTER_VALIDATE_URL)) { return new JsonResponse(['error' => 'Invalid URL'], 400); } switch ($locale) { case 'fr': $candidate->setVideoFrUrl($videoUrl); break; case 'en': $candidate->setVideoEnUrl($videoUrl); break; case 'default': $candidate->setVideoDefaultUrl($videoUrl); break; default: return new JsonResponse(['error' => 'Invalid locale'], 400); } $this->em->persist($candidate); $this->em->flush(); return new JsonResponse([ 'candidate' => $candidate->getId(), 'success' => true, 'locale' => $locale, 'videoUrl' => $videoUrl, ]); } /** * Publication du CV * @param Request $request * @return JsonResponse */ public function publish(Request $request): JsonResponse { $user = $this->getUser(); if (!$user) { return new JsonResponse(['error' => 'Unauthorized'], 401); } $user->setFirst(false); $this->em->persist($user); $this->em->flush(); $candidate = $user->getCandidate(); // Statut "open" par défaut uniquement si aucun statut n'est défini (null/vide). // Si le candidat a déjà choisi un statut ("open", "ecoute" ou "offline"), // on respecte son choix existant. if (empty($candidate->getAvailability())) { $candidate->setAvailability('open'); } $candidate->setOnline(true); $this->em->persist($candidate); $this->em->flush(); return new JsonResponse(['candidate' => $candidate->getId(), 'success' => true]); } /** * Dépublication du CV * @param Request $request * @return JsonResponse */ public function unpublish(Request $request): JsonResponse { $user = $this->getUser(); if (!$user) { return new JsonResponse(['error' => 'Unauthorized'], 401); } $user->setFirst(false); $this->em->persist($user); $this->em->flush(); $candidate = $user->getCandidate(); $candidate->setAvailability("offline"); $candidate->setOnline(false); $this->em->persist($candidate); $this->em->flush(); return new JsonResponse(['candidate' => $candidate->getId(), 'success' => true]); } /** * CV PDF * @param string $base64String * @return array|string[] */ private function detectPdfTypeFromBase64(string $base64String): array { // Supprimer le préfixe data:application/pdf;base64, si présent if (preg_match('/^data:application\/pdf;base64,(.+)$/', $base64String, $matches)) { return [ 'data' => $matches[1] ]; } return ['data' => $base64String]; } /** * Sérialise un candidat pour le dashboard recruteur. * Renvoie ANCIENS champs multilingues + NOUVEAUX champs (cvData, * cvSettings, formAiAnalysis + PDF généré) en parallèle. */ private function serializeCandidateForRecruiter($query, $recruiter = null): array { $createdAt = $query->getCreatedAt() ? $query->getCreatedAt()->format('c') : ''; $updatedAt = $query->getUpdatedAt() ? $query->getUpdatedAt()->format('c') : ''; $like = false; $likeEntity = $this->em->getRepository(CandidatesHasLikes::class) ->findOneBy(['candidate' => $query->getId()]); if ($likeEntity !== null) { $like = true; } $haveCV = false; if ($query->getImage() !== null && $query->getImage()->getName() !== null) { $haveCV = true; } $userCandidate = $this->em->getRepository(Users::class) ->findOneBy(['candidate' => $query->getId()]); $avatar64 = ""; if ($userCandidate && $userCandidate->getImage() !== null && $userCandidate->getImage()->getName() !== null) { $avatar64 = $userCandidate->getImageBase64($this->getParameter('kernel.project_dir')); } $fullname = $userCandidate ? ucfirst(strtolower($userCandidate->getName()))." ".strtoupper($userCandidate->getLastname()) : ''; // ─── Nouveaux champs (tunnel V2 + IA) ─── $decode = function(?string $raw) { if (!$raw) return null; $v = json_decode($raw, true); return (json_last_error() === JSON_ERROR_NONE) ? $v : null; }; // ⚠️ Filtrage PII : le recruteur ne doit PAS voir email/tel/adresse $cvDataDecoded = $this->stripSensitiveFields($decode($query->getCvData())); $cvSettingsDecoded = $this->stripSensitiveFields($decode($query->getCvSettings())); $formAiAnalysisDecoded = $this->stripSensitiveFields($decode($query->getFormAiAnalysis())); $pdfPath = $query->getCvPdfPath(); $haveGeneratedCv = $pdfPath && file_exists($pdfPath); $generatedCvUrl = $haveGeneratedCv ? rtrim($_ENV['CV_PDF_PUBLIC_URL'] ?? '', '/') . '/' . basename($pdfPath) : null; return [ // ═══ Base ═══ "id" => (string)$query->getId(), "createdAt" => $createdAt, "updatedAt" => $updatedAt, "fullname" => $fullname, "avatar" => $avatar64, "name" => $userCandidate ? (string)$userCandidate->getName() : '', "lastname" => $userCandidate ? (string)$userCandidate->getLastname() : '', "like" => $like, "haveCV" => $haveCV || $haveGeneratedCv, // ═══ NOUVEAU système (tunnel V2 + IA) ═══ "cvData" => $cvDataDecoded, "cvSettings" => $cvSettingsDecoded, "formAiAnalysis" => $formAiAnalysisDecoded, "formAiAnalysisStatus" => $query->getFormAiAnalysisStatus(), "formAiAnalysisUpdatedAt" => $query->getFormAiAnalysisUpdatedAt() ? $query->getFormAiAnalysisUpdatedAt()->format('c') : null, "haveGeneratedCv" => $haveGeneratedCv, "generatedCvUrl" => $generatedCvUrl, // ═══ ANCIEN système (legacy multilingue) ═══ "defaultLocale" => $query->getDefaultLocale(), "frLanguage" => (bool)$query->isFrench(), "enLanguage" => (bool)$query->isEnglish(), "defaultLanguage" => (bool)$query->isDefault(), "resumeRecruiterFr" => (string)$query->getResumeRecruiterFr(), "resumeRecruiterEn" => (string)$query->getResumeRecruiterEn(), "resumeRecruiterDefault" => (string)$query->getResumeRecruiterDefault(), "presentationFr" => (string)$query->getPresentationFr(), "presentationEn" => (string)$query->getPresentationEn(), "presentationDefault" => (string)$query->getPresentationDefault(), "resumeExpFr" => (string)$query->getResumeExpFr(), "resumeExpEn" => (string)$query->getResumeExpEn(), "resumeExpDefault" => (string)$query->getResumeExpDefault(), "resumeProjectsFr" => (string)$query->getResumeProjectsFr(), "resumeProjectsEn" => (string)$query->getResumeProjectsEn(), "resumeProjectsDefault" => (string)$query->getResumeProjectsDefault(), "hardSkillsFr" => json_decode($query->getHardSkillsFr()), "hardSkillsEn" => json_decode($query->getHardSkillsEn()), "hardSkillsDefault" => json_decode($query->getHardSkillsDefault()), "softSkillsFr" => json_decode($query->getSoftSkillsFr()), "softSkillsEn" => json_decode($query->getSoftSkillsEn()), "softSkillsDefault" => json_decode($query->getSoftSkillsDefault()), "sectorFr" => (string)$query->getSectorFr(), "sectorEn" => (string)$query->getSectorEn(), "sectorDefault" => (string)$query->getSectorDefault(), "jobTitleFr" => (string)$query->getTitlejobFr(), "jobTitleEn" => (string)$query->getTitlejobEn(), "jobTitleDefault" => (string)$query->getTitlejobDefault(), "futurTitlejobFr" => (string)$query->getFuturTitlejobFr(), "futurTitlejobEn" => (string)$query->getFuturTitlejobEn(), "futurTitlejobDefault" => (string)$query->getFuturTitlejobDefault(), "videoFrUrl" => (string)$query->getVideoFrUrl(), "videoEnUrl" => (string)$query->getVideoEnUrl(), "videoDefaultUrl" => (string)$query->getVideoDefaultUrl(), // ═══ Champs communs ═══ "availability" => $query->getAvailability(), "city" => (string)$query->getCity(), "country" => $query->getCountry(), "salary" => (bool)$query->isSalary(), "freelance" => (bool)$query->isFreelance(), "stage" => (bool)$query->isStage(), "alternance" => (bool)$query->isAlternance(), "websiteUrl" => $query->getWebsiteUrl(), "linkedinUrl" => $query->getLinkedinUrl(), "experience" => $query->getExpYears() ?: 0, ]; } /** * Supprime récursivement les champs PII (email, téléphone, adresse) * d'un tableau JSON décodé. Utilisé pour nettoyer cvData / formAiAnalysis * avant envoi au recruteur. */ private function stripSensitiveFields($data) { if (!is_array($data)) { return $data; } // Clés PII (insensible à la casse, match partiel) $sensitivePatterns = [ 'email', 'mail', 'phone', 'telephone', 'tel', 'mobile', 'portable', 'address', 'adresse', 'street', 'rue', 'zipcode', 'zip_code', 'postal_code', 'postalcode', 'code_postal', ]; $filtered = []; foreach ($data as $key => $value) { $isSensitive = false; if (is_string($key)) { $keyLower = strtolower($key); foreach ($sensitivePatterns as $pattern) { if (strpos($keyLower, $pattern) !== false) { $isSensitive = true; break; } } } if ($isSensitive) { continue; } if (is_array($value)) { $filtered[$key] = $this->stripSensitiveFields($value); } else { $filtered[$key] = $value; } } return $filtered; }}