<?phpnamespace App\Controller\ThemesWebsite\Whileresume\Website;use App\Entity\Core\Users;use App\Entity\Cvs\Candidates;use App\Entity\Cvs\PublicCvDraft;use App\Repository\Cvs\PublicCvDraftRepository;use App\Security\LoginFormAuthenticator;use App\Services\Mails;use App\Services\N8nWebhook;use Doctrine\ORM\EntityManagerInterface;use Lexik\Bundle\JWTAuthenticationBundle\Services\JWTTokenManagerInterface;use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;use Symfony\Component\HttpFoundation\BinaryFileResponse;use Symfony\Component\HttpFoundation\File\File;use Symfony\Component\HttpFoundation\JsonResponse;use Symfony\Component\HttpFoundation\RedirectResponse;use Symfony\Component\HttpFoundation\Request;use Symfony\Component\HttpFoundation\Response;use Symfony\Component\HttpFoundation\ResponseHeaderBag;use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;use Symfony\Component\HttpKernel\KernelInterface;use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;use Symfony\Component\Security\Http\Authentication\UserAuthenticatorInterface;use Symfony\Component\Validator\Constraints as Assert;use Symfony\Component\Validator\Validator\ValidatorInterface;use Twig\Environment;use Vich\UploaderBundle\Entity\File as EmbeddedFile;/** * Tunnel CV public — V6 (Python lit la draft, Candidates créé uniquement à register) * * Routes : * GET /cv-public/ → entry() : crée draft + redirect * GET /cv-public/{slug}/ → choice() * GET /cv-public/{slug}/theme → theme() * GET /cv-public/{slug}/form → form() * POST /cv-public/{slug}/generate → generate() : Python génère PDF (lit draft via --public-slug) * GET /cv-public/{slug}/register → registerPage() : page d'inscription * POST /cv-public/{slug}/register → register() : crée Users + Candidates depuis draft * * Variables .env requises : * PYTHON_SCRIPT_CVPUBLIC_PATH1 chemin absolu vers cvpublic_primary.py (theme simple) * PYTHON_SCRIPT_CVPUBLIC_PATH2 chemin absolu vers cvpublic_second.py (theme compact) * PYTHON_SCRIPT_CV_UPLOADPATH dossier des PDFs (chemin absolu, slash final) * CV_PDF_PUBLIC_URL URL publique des PDFs (slash final) * PYTHON_PATH binaire python */class PublicGeneratorController extends AbstractController{ /** Durée de vie du draft public (48h) */ private const SLUG_TTL_HOURS = 48; private const SLUG_LENGTH = 20; private string $apiBase; // Env Python (NOUVELLES variables dédiées au tunnel public) private string $scriptPath1; private string $scriptPath2; private string $pythonBin; private string $uploadPath; private string $pdfPublicBase; private string $analyzerScriptPath; public function __construct( private readonly EntityManagerInterface $em, private readonly PublicCvDraftRepository $draftRepo, private readonly JWTTokenManagerInterface $jwtManager, private readonly UserPasswordEncoderInterface $passwordEncoder, private readonly UserAuthenticatorInterface $userAuthenticator, private readonly LoginFormAuthenticator $authenticator, private readonly Mails $ms, private readonly Environment $twig, private readonly ValidatorInterface $validator, private readonly KernelInterface $kernel, private readonly N8nWebhook $n8n, ) { $this->apiBase = $_ENV['CV_PUBLIC_API_BASE'] ?? '/api/cv-public'; $this->scriptPath1 = $_ENV['PYTHON_SCRIPT_CVPUBLIC_PATH1'] ?? ''; $this->scriptPath2 = $_ENV['PYTHON_SCRIPT_CVPUBLIC_PATH2'] ?? ''; $this->pythonBin = $_ENV['PYTHON_PATH'] ?? '/usr/bin/python3'; $this->uploadPath = rtrim($_ENV['PYTHON_SCRIPT_CV_UPLOADPATH'] ?? '', '/') . '/'; $this->pdfPublicBase = rtrim($_ENV['CV_PDF_PUBLIC_URL'] ?? '', '/') . '/'; // Script d'analyse IA : même variable .env que CvGeneratorController // (PYTHON_SCRIPT_ANALYSE_CV_IA en priorité, PYTHON_SCRIPT_PATH3 en legacy) $this->analyzerScriptPath = $_ENV['PYTHON_SCRIPT_ANALYSE_CV_IA'] ?? $_ENV['PYTHON_SCRIPT_PATH3'] ?? ''; } // ========================================================================= // Entry + pages // ========================================================================= public function entry(Request $request): RedirectResponse { $now = new \DateTime(); $defaultLocale = $_ENV['APP_DEFAULT_LOCALE'] ?? 'fr'; $localeRequirements = $_ENV['APP_LOCALE_REQUIREMENTS'] ?? 'fr|de|es|nl'; $supportedLocales = explode('|', $localeRequirements); $requestLocale = $request->getLocale(); $effectiveLocale = in_array($requestLocale, $supportedLocales, true) ? $requestLocale : $defaultLocale; $draft = new PublicCvDraft(); $draft->setPublicSlug($this->generateUniquePublicSlug()); $draft->setExpiresAt( (clone $now)->modify('+' . self::SLUG_TTL_HOURS . ' hours') ); $draft->setCreatedAt($now); $draft->setUpdatedAt($now); $draft->setLocale($effectiveLocale); $draft->setMode('scratch'); $this->em->persist($draft); $this->em->flush(); // Préserver le préfixe locale si l'URL d'entrée en avait un $hasLocalePrefix = (bool) preg_match( '#^/(' . $localeRequirements . ')/#', $request->getPathInfo() ); $route = $hasLocalePrefix ? 'locale_cv_public_choice' : 'cv_public_choice'; $params = ['slug' => $draft->getPublicSlug()]; if ($hasLocalePrefix) { $params['_locale'] = $effectiveLocale; } return $this->redirectToRoute($route, $params); } public function choice(Request $request, string $slug): Response { $draft = $this->resolveDraftStrict($slug); return $this->render( 'application/whileresume/website/cv-public/choice.html.twig', $this->buildContext($draft, currentStep: 1) ); } public function theme(Request $request, string $slug): Response { $draft = $this->resolveDraftStrict($slug); return $this->render( 'application/whileresume/website/cv-public/theme.html.twig', $this->buildContext($draft, currentStep: 2) ); } public function form(Request $request, string $slug): Response { $draft = $this->resolveDraftStrict($slug); $mode = $request->query->get('mode', 'scratch'); if (!in_array($mode, ['analyze', 'scratch'], true)) { $mode = 'scratch'; } if ($draft->getMode() !== $mode) { $draft->setMode($mode); $draft->setUpdatedAt(new \DateTime()); $this->em->persist($draft); $this->em->flush(); } $ctx = $this->buildContext($draft, currentStep: 3); $ctx['mode'] = $mode; $ctx['is_public'] = true; return $this->render( 'application/whileresume/website/cv-public/form.html.twig', $ctx ); } // ========================================================================= // POST /cv-public/{slug}/generate // // Python lit cv_data depuis cvs_candidates_drafts via --public-slug. // PAS DE CRÉATION DE CANDIDATES ICI — c'est /register qui s'en chargera. // PAS D'ANALYSE IA. // // Body JSON (optionnel — si absent, Python relit la draft en BDD) : // { data: {...}, theme, primary, secondary, cv_language } // // Réponse : // { success: true, pdf_url: "...", slug: "..." } // ========================================================================= public function generate(Request $request, string $slug): JsonResponse { $draft = $this->resolveDraftStrict($slug); if (empty($this->scriptPath1) || empty($this->scriptPath2)) { return new JsonResponse([ 'success' => false, 'error' => 'Scripts Python non configures dans .env (PYTHON_SCRIPT_CVPUBLIC_PATH1/2)', ], 500); } // Lecture body : si le front envoie cv_data + settings, on les flush // dans la draft AVANT exec Python (pour que Python lise les dernières données). $body = $request->toArray(); $cvData = $body['data'] ?? null; $theme = $body['theme'] ?? null; $primary = $body['primary'] ?? null; $secondary = $body['secondary'] ?? null; $cvLanguage = $body['cv_language'] ?? null; $now = new \DateTime(); try { // ── 1. Si le front a envoyé cv_data, on flush dans la draft ── // (utile car save-draft est debounced — on garantit la fraîcheur) if (is_array($cvData) && !empty($cvData)) { // Photo strippée unset($cvData['_cv_language'], $cvData['photo']); $existingSettings = $draft->getCvSettingsArray(); $newSettings = [ 'theme' => $theme ?? ($existingSettings['theme'] ?? 'simple'), 'primary' => $primary ?? ($existingSettings['primary'] ?? '#1A8A7D'), 'secondary' => $secondary ?? ($existingSettings['secondary'] ?? '#F59E0B'), 'cv_language' => $cvLanguage ?? ($existingSettings['cv_language'] ?? ($draft->getLocale() ?: 'fr')), ]; $draft->setCvData(json_encode($cvData, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); $draft->setCvSettings(json_encode($newSettings, JSON_UNESCAPED_UNICODE)); // Sync colonnes typées sur la draft (cohérent avec // PublicCvDraftController::saveDraft). Inclut zipcode + address // de façon défensive via method_exists(). $this->syncDraftTypedColumns($draft, $cvData); $draft->setUpdatedAt($now); $this->em->persist($draft); $this->em->flush(); } // ── 2. Vérifier qu'on a bien des données pour Python ── $settings = $draft->getCvSettingsArray(); $effTheme = $settings['theme'] ?? 'simple'; $effPrimary = $settings['primary'] ?? '#1A8A7D'; $effSecondary = $settings['secondary'] ?? '#F59E0B'; $effCvLanguage = $settings['cv_language'] ?? ($draft->getLocale() ?: 'fr'); $draftCvDataArray = $draft->getCvDataArray(); if (empty($draftCvDataArray)) { return new JsonResponse([ 'success' => false, 'error' => 'Aucune donnée CV à générer. Veuillez remplir le formulaire d\'abord.', ], 400); } // ── 3. Préparer le PDF ── if (!is_dir($this->uploadPath)) { mkdir($this->uploadPath, 0755, true); } $ts = time(); $pdfFileName = 'cvpublic_' . $draft->getId() . '_' . $ts . '.pdf'; $pdfPath = $this->uploadPath . $pdfFileName; $oldPdfPath = $draft->getCvPdfPath(); $scriptPath = ($effTheme === 'compact') ? $this->scriptPath2 : $this->scriptPath1; // Logo (même dossier que le script) $logoPath = dirname($scriptPath) . '/favicon.png'; $logoArg = file_exists($logoPath) ? ' --logo ' . escapeshellarg($logoPath) : ''; // Pas de photo en mode public (l'avatar User n'existe pas encore) $photoArg = ''; // ── 4. Exec Python avec --public-slug (lit cvs_candidates_drafts) ── $command = sprintf( '%s %s --public-slug %s%s%s --primary %s --secondary %s --lang %s --output %s 2>&1', escapeshellcmd($this->pythonBin), escapeshellarg($scriptPath), escapeshellarg($draft->getPublicSlug()), $photoArg, $logoArg, escapeshellarg($effPrimary), escapeshellarg($effSecondary), escapeshellarg($effCvLanguage), escapeshellarg($pdfPath) ); $output = []; $returnCode = 0; exec($command, $output, $returnCode); if ($returnCode !== 0 || !file_exists($pdfPath)) { return new JsonResponse([ 'success' => false, 'error' => 'Erreur lors de la génération du CV. Veuillez réessayer.', 'debug' => ($_ENV['APP_ENV'] ?? 'prod') === 'dev' ? ['returnCode' => $returnCode, 'output' => $output, 'command' => $command] : null, ], 500); } // ── 5. Supprimer l'ancien PDF s'il existe ── if ($oldPdfPath && file_exists($oldPdfPath) && $oldPdfPath !== $pdfPath) { @unlink($oldPdfPath); } // ── 6. Persister cv_pdf_path sur la draft ── $draft->setCvPdfPath($pdfPath); $draft->setUpdatedAt(new \DateTime()); $this->em->persist($draft); $this->em->flush(); // pdf_url pointe vers notre route Symfony /pdf qui sert le fichier // en streaming après vérification du slug. Pas besoin que le dossier // documents/cv soit accessible publiquement. // On préserve le préfixe locale si l'URL d'entrée en avait un. $localeRequirements = $_ENV['APP_LOCALE_REQUIREMENTS'] ?? 'fr|de|es|nl'; $hasLocalePrefix = (bool) preg_match( '#^/(' . $localeRequirements . ')/#', $request->getPathInfo() ); $pdfUrl = $hasLocalePrefix ? $this->generateUrl('locale_cv_public_pdf', [ 'slug' => $draft->getPublicSlug(), '_locale' => $request->getLocale(), ]) : $this->generateUrl('cv_public_pdf', [ 'slug' => $draft->getPublicSlug(), ]); // Cache busting pour forcer le navigateur à recharger le PDF // après une régénération (sinon l'iframe garde l'ancien) $pdfUrl .= '?t=' . time(); return new JsonResponse([ 'success' => true, 'pdf_url' => $pdfUrl, 'slug' => $draft->getPublicSlug(), ]); } catch (\Throwable $e) { return new JsonResponse([ 'success' => false, 'error' => 'Erreur générale : ' . $e->getMessage(), ], 500); } } // ========================================================================= // GET /cv-public/{slug}/pdf // // Sert le PDF en streaming via BinaryFileResponse, après vérification // que la draft existe et n'a pas expiré. // // Le query param ?dl=1 force le téléchargement (Content-Disposition: attachment). // Sans ce param, le PDF est servi inline (utilisé par l'iframe de preview). // ========================================================================= public function pdf(Request $request, string $slug): Response { $draft = $this->resolveDraftStrict($slug); $pdfPath = $draft->getCvPdfPath(); if ($pdfPath === null || !file_exists($pdfPath)) { throw new NotFoundHttpException('PDF introuvable. Veuillez régénérer votre CV.'); } $disposition = $request->query->get('dl') === '1' ? ResponseHeaderBag::DISPOSITION_ATTACHMENT : ResponseHeaderBag::DISPOSITION_INLINE; $response = new BinaryFileResponse($pdfPath); $response->setContentDisposition($disposition, 'cv.pdf'); $response->headers->set('Content-Type', 'application/pdf'); // Pas de cache : le PDF peut être régénéré $response->headers->set('Cache-Control', 'no-cache, no-store, must-revalidate'); $response->headers->set('Pragma', 'no-cache'); $response->headers->set('Expires', '0'); return $response; } // ========================================================================= // GET /cv-public/{slug}/register // ========================================================================= public function registerPage(Request $request, string $slug): Response { $draft = $this->resolveDraftStrict($slug); // Si pas encore généré, on redirige vers le form if ($draft->getCvPdfPath() === null) { $defaultLocale = $_ENV['APP_DEFAULT_LOCALE'] ?? 'fr'; $localeRequirements = $_ENV['APP_LOCALE_REQUIREMENTS'] ?? 'fr|de|es|nl'; $supportedLocales = explode('|', $localeRequirements); $requestLocale = $request->getLocale(); $effectiveLocale = in_array($requestLocale, $supportedLocales, true) ? $requestLocale : $defaultLocale; $hasLocalePrefix = (bool) preg_match( '#^/(' . $localeRequirements . ')/#', $request->getPathInfo() ); $route = $hasLocalePrefix ? 'locale_cv_public_form' : 'cv_public_form'; $params = ['slug' => $slug]; if ($hasLocalePrefix) { $params['_locale'] = $effectiveLocale; } return $this->redirectToRoute($route, $params); } $ctx = $this->buildContext($draft, currentStep: 4); $ctx['mode'] = $draft->getMode() ?? 'scratch'; $ctx['is_public'] = true; return $this->render( 'application/whileresume/website/cv-public/register.html.twig', $ctx ); } // ========================================================================= // POST /cv-public/{slug}/register // // Crée Users + Candidates en transférant tout depuis la draft : // 1. cv_data + cv_settings : draft → Candidates // 2. PDF : déplacé du dossier "cvpublic_*" vers le pattern "cv_*" attendu // 3. Lien Users.candidate // 4. DELETE draft // 5. Mail bienvenue + login auto // // PAS D'ANALYSE IA. // ========================================================================= public function register(Request $request, string $slug): JsonResponse { $draft = $this->resolveDraftStrict($slug); if ($draft->getCvPdfPath() === null) { return new JsonResponse([ 'ok' => false, 'error' => 'no_cv_generated', 'errors' => [ '_global' => 'Vous devez d\'abord générer votre CV avant de créer votre compte.', ], ], Response::HTTP_BAD_REQUEST); } $payload = json_decode($request->getContent(), true); if (!is_array($payload)) { throw new BadRequestHttpException('Invalid JSON payload'); } $email = trim((string) ($payload['email'] ?? '')); $password = (string) ($payload['password'] ?? ''); $passwordConfirm = (string) ($payload['passwordConfirm'] ?? ''); $locale = $request->getLocale(); // --- Validation ------------------------------------------------ $violations = $this->validator->validate($email, [ new Assert\NotBlank(message: 'L\'email est obligatoire.'), new Assert\Email(message: 'Format d\'email invalide.'), new Assert\Length(max: 180), ]); if (count($violations) > 0) { return new JsonResponse([ 'ok' => false, 'errors' => ['email' => $violations[0]->getMessage()], ], Response::HTTP_BAD_REQUEST); } if (strlen($password) < 8) { return new JsonResponse([ 'ok' => false, 'errors' => ['password' => 'Le mot de passe doit faire au moins 8 caractères.'], ], Response::HTTP_BAD_REQUEST); } if ($password !== $passwordConfirm) { return new JsonResponse([ 'ok' => false, 'errors' => ['passwordConfirm' => 'Les mots de passe ne correspondent pas.'], ], Response::HTTP_BAD_REQUEST); } $existing = $this->em->getRepository(Users::class)->findOneBy(['email' => $email]); if ($existing !== null) { return new JsonResponse([ 'ok' => false, 'error' => 'email_exists', 'errors' => [ 'email' => 'Cette adresse email est déjà utilisée. Merci d\'en choisir une autre.', ], ], Response::HTTP_CONFLICT); } $cvDataArray = $draft->getCvDataArray(); $oldPdfPath = $draft->getCvPdfPath(); // --- Transaction : création Users + Candidates + transfert PDF --- $this->em->beginTransaction(); try { $now = new \DateTime('now'); // ── 1. Créer le Candidates ── $candidate = new Candidates(); $candidate->setEmail($email); $candidate->setCreatedAt($now); $candidate->setUpdatedAt($now); $candidate->setOnline(false); $candidate->setFirst(false); // l'user a déjà un CV → plus "first" if (method_exists($candidate, 'setAvailability')) { $candidate->setAvailability($cvDataArray['availability'] ?? 'offline'); } // Transfert cv_data + cv_settings depuis la draft $candidate->setCvData($draft->getCvData()); $candidate->setCvSettings($draft->getCvSettings()); // Sync legacy fields $this->syncToLegacyFields($candidate, $cvDataArray); $this->em->persist($candidate); $this->em->flush(); // pour avoir l'ID // Slugs internes (pattern existant) $candidate->setSlug($this->generateInternalSlug((int) $candidate->getId(), 10)); if (method_exists($candidate, 'setSlugAnonyme')) { $candidate->setSlugAnonyme($this->generateInternalSlug((int) $candidate->getId(), 10)); } // ── 2. Déplacer le PDF du nom "cvpublic_X_TS.pdf" → "cv_<candId>_TS.pdf" ── $newPdfPath = $oldPdfPath; if (file_exists($oldPdfPath)) { $ts = time(); $newPdfFileName = 'cv_' . $candidate->getId() . '_' . $ts . '.pdf'; $newPdfPath = $this->uploadPath . $newPdfFileName; if (@rename($oldPdfPath, $newPdfPath)) { $candidate->setCvPdfPath($newPdfPath); } else { // Si le rename échoue, on garde l'ancien chemin (qui marche) $candidate->setCvPdfPath($oldPdfPath); $newPdfPath = $oldPdfPath; } } else { // PDF disparu (rare) : on laisse cv_pdf_path null, l'user pourra régénérer $candidate->setCvPdfPath(null); $newPdfPath = null; } // ── 2bis. Copier le PDF dans /files/cvs comme "CV original uploadé" ── // // Vich Uploader (mapping cv_files) est configuré pour stocker les // CV originaux dans <project_dir>/files/cvs avec un nom uniqid. // L'approche setImageFile() ne déclenche pas toujours la lifecycle // hook Vich (selon l'ordre flush/persist), donc on fait ça // MANUELLEMENT : // 1. Copier le PDF généré vers /files/cvs/<uniqid>.pdf // 2. Remplir l'EmbeddedFile (image.name, .size, .mimeType, // .originalName, .dimensions) directement // // Du coup, dans l'espace candidat, le CV apparaîtra comme s'il // avait été uploadé via le formulaire d'analyse de /generate2/*. if ($newPdfPath !== null && file_exists($newPdfPath)) { try { $vichDir = $this->kernel->getProjectDir() . '/files/cvs'; if (!is_dir($vichDir)) { @mkdir($vichDir, 0755, true); } // Génère un nom uniqid (cohérent avec namer Vich vich_uploader.namer_uniqid) $vichFilename = uniqid() . '.pdf'; $vichFullPath = $vichDir . '/' . $vichFilename; if (@copy($newPdfPath, $vichFullPath)) { // Remplit l'EmbeddedFile que Vich attend dans Candidates.image $embedded = new EmbeddedFile(); $embedded->setName($vichFilename); $embedded->setOriginalName('cv-' . $candidate->getId() . '.pdf'); $embedded->setMimeType('application/pdf'); $embedded->setSize(filesize($vichFullPath) ?: 0); // dimensions : null pour un PDF (Vich gère le cas null) if (method_exists($embedded, 'setDimensions')) { $embedded->setDimensions(null); } $candidate->setImage($embedded); } } catch (\Throwable $e) { // Best effort : si la copie échoue, on garde quand même // cv_pdf_path. Pas bloquant pour l'inscription. } } // ── 3. Créer le Users ── $newUser = new Users(); $newUser->setEmail($email); $newUser->setUsername(''); $newUser->setVerification(false); $newUser->setNotificationsMessages(true); $newUser->setNotificationsSuivis(true); $newUser->setPremium(false); $newUser->setFirst(false); $newUser->setEnabled(true); $newUser->setPassword($this->passwordEncoder->encodePassword($newUser, $password)); $newUser->setRoles(['ROLE_USER']); $newUser->setTypeAccount('candidate'); $newUser->setCreatedAt($now); $newUser->setUpdatedAt($now); if ($draft->getFirstName() && method_exists($newUser, 'setName')) { $newUser->setName($draft->getFirstName()); } if ($draft->getLastName() && method_exists($newUser, 'setLastname')) { $newUser->setLastname($draft->getLastName()); } $newUser->setCandidate($candidate); $this->em->persist($candidate); $this->em->persist($newUser); // ── 4. DELETE draft ── $this->em->remove($draft); $this->em->flush(); $this->em->commit(); } catch (\Throwable $e) { $this->em->rollback(); return new JsonResponse([ 'ok' => false, 'error' => 'register_failed', 'errors' => ['_global' => 'Une erreur est survenue lors de la création du compte.'], 'debug' => ($_ENV['APP_ENV'] ?? 'prod') === 'dev' ? $e->getMessage() : null, ], Response::HTTP_INTERNAL_SERVER_ERROR); } // --- ANALYSE IA DU CV (non-bloquant, arrière-plan) ------------- // // Lancée APRÈS le commit (sinon le script Python ne verrait pas // le Candidates en BDD). Le script analyse_cv_ia.py : // 1. Lit cv_data depuis cvs_candidates via selectOneCv() // 2. Appelle l'IA (OpenAI) pour générer recruiter_summary, // hard_skills, soft_skills, recruiter_exp, etc. // 3. Écrit form_ai_analysis(_status|_updated_at) via updateCvV2() // // L'utilisateur arrive sur son dashboard immédiatement, et le front // poll /api/candidate/info pour récupérer l'analyse quand elle est prête. // // Identique au flow /generate2/save-cv pour rester cohérent. try { $candId = (int) $candidate->getId(); $cvSettings = $candidate->getCvSettings(); $settingsArr = $cvSettings ? (json_decode($cvSettings, true) ?: []) : []; $cvLanguage = $settingsArr['cv_language'] ?? $locale ?? 'fr'; if (!empty($this->analyzerScriptPath) && is_file($this->analyzerScriptPath)) { // Reset des champs form_ai_analysis pour que le front affiche // le spinner "analyse en cours" et non une ancienne analyse. $candidate->setFormAiAnalysis(null); $candidate->setFormAiAnalysisUpdatedAt(null); $candidate->setFormAiAnalysisStatus('pending'); $this->em->persist($candidate); $this->em->flush(); $ts = time(); $logPath = $this->uploadPath . 'ai_' . $candId . '_' . $ts . '.log'; $targetLang = in_array(strtolower((string) $cvLanguage), ['fr', 'en'], true) ? strtolower((string) $cvLanguage) : 'fr'; // IMPORTANT : `cd` dans le dossier du script car analyse_cv_ia.py // fait `from model import ...` (model.py est dans le même dossier). // nohup + & : le process Python survit à la fin du request PHP // → l'API /register répond immédiatement, l'analyse continue derrière. $scriptDir = dirname($this->analyzerScriptPath); $aiCommand = sprintf( 'cd %s && nohup %s %s %s %s > %s 2>&1 &', escapeshellarg($scriptDir), escapeshellcmd($this->pythonBin), escapeshellarg($this->analyzerScriptPath), escapeshellarg((string) $candId), escapeshellarg($targetLang), escapeshellarg($logPath) ); exec($aiCommand); } else { // Script non configuré : on marque failed pour pas que le front // poll indéfiniment. error_log(sprintf( '[CvPublicAnalyzer] Script d\'analyse non configuré ou introuvable (analyzerScriptPath="%s")', $this->analyzerScriptPath )); try { $candidate->setFormAiAnalysisStatus('failed'); $this->em->persist($candidate); $this->em->flush(); } catch (\Throwable $inner) { // best effort } } } catch (\Throwable $e) { error_log('[CvPublicAnalyzer] ' . $e->getMessage()); // Ne pas bloquer l'inscription si l'analyse échoue à se lancer } // --- Mail bienvenue (best-effort) ------------------------------ try { $title = $locale === 'fr' ? '🎉 Bienvenue ! Votre profil peut déjà attirer des recruteurs !' : 'Welcome! Your profile is ready to attract recruiters!'; $tplLocale = in_array($locale, ['fr', 'en'], true) ? $locale : 'fr'; $descriptionHTML = $this->twig->render( 'application/whileresume/gestion/emails/' . $tplLocale . '/register_candidate.html.twig', ['title' => $title, 'email' => $email] ); $this->ms->webhook($title, $descriptionHTML, null, $email, null, null); } catch (\Throwable $e) { // best effort } // --- Notification n8n (non bloquante) -------------------------- $this->n8n->send($_ENV['N8N_WEBHOOK_NOTIF_REGISTER_CANDIDATE'] ?? null, [ 'name' => $newUser->getName(), 'lastname' => $newUser->getLastname(), 'city' => $candidate->getCity(), 'mobile' => true, 'email' => $newUser->getEmail(), 'id_candidate' => $candidate->getId(), 'id_user' => $newUser->getId(), ]); // --- Login auto ------------------------------------------------ $this->userAuthenticator->authenticateUser($newUser, $this->authenticator, $request); $jwt = $this->jwtManager->create($newUser); // Redirect dashboard avec préservation du préfixe locale $defaultLocale = $_ENV['APP_DEFAULT_LOCALE'] ?? 'fr'; $localeRequirements = $_ENV['APP_LOCALE_REQUIREMENTS'] ?? 'fr|de|es|nl'; $supportedLocales = explode('|', $localeRequirements); $effectiveLocale = in_array($locale, $supportedLocales, true) ? $locale : $defaultLocale; $hasLocalePrefix = (bool) preg_match( '#^/(' . $localeRequirements . ')/#', $request->getPathInfo() ); $redirect = $hasLocalePrefix ? $this->generateUrl('locale_customer_homepage', ['_locale' => $effectiveLocale]) : $this->generateUrl('customer_homepage'); return new JsonResponse([ 'ok' => true, 'jwt' => $jwt, 'redirect' => $redirect, 'message' => 'Votre compte a été créé.', ]); } // ========================================================================= // Helpers privés // ========================================================================= private function resolveDraftStrict(string $slug): PublicCvDraft { if (!preg_match('/^[a-f0-9]{20}$/', $slug)) { throw new NotFoundHttpException('Lien de brouillon invalide.'); } $draft = $this->draftRepo->findValidBySlug($slug); if ($draft === null) { throw new NotFoundHttpException( 'Ce brouillon de CV est introuvable ou a expiré (48h). Veuillez recommencer un nouveau CV.' ); } return $draft; } private function generateUniquePublicSlug(): string { for ($i = 0; $i < 5; $i++) { $slug = substr(bin2hex(random_bytes((int) ceil(self::SLUG_LENGTH / 2))), 0, self::SLUG_LENGTH); if (!$this->draftRepo->slugExists($slug)) { return $slug; } } throw new \RuntimeException('Unable to generate unique public slug after 5 attempts.'); } private function generateInternalSlug(int $id, int $length): string { $alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789'; $max = strlen($alphabet) - 1; $random = ''; for ($i = 0; $i < $length; $i++) { $random .= $alphabet[random_int(0, $max)]; } return $id . '-' . $random; } /** * Synchronise les colonnes typées de PublicCvDraft à partir du cv_data. * * Logique identique à PublicCvDraftController::syncTypedColumns(). Les * deux contrôleurs doivent rester en miroir pour que les colonnes * typées de la draft soient toujours fraîches au moment où le * /register lit la draft pour créer Users + Candidates. * * Champs « obligatoires » (présents dès la création de l'entité) : * firstName, lastName, email, phone, city, country. * Champs « optionnels » (peuvent ne pas exister sur l'entité, * protégés par method_exists pour ne pas casser si l'entité n'a * pas encore la colonne en BDD) : * zipcode, address. */ private function syncDraftTypedColumns(PublicCvDraft $draft, array $data): void { if (!empty($data['first_name'])) $draft->setFirstName((string) $data['first_name']); if (!empty($data['last_name'])) $draft->setLastName((string) $data['last_name']); if (!empty($data['email'])) $draft->setEmail((string) $data['email']); if (!empty($data['phone'])) $draft->setPhone((string) $data['phone']); if (!empty($data['city'])) $draft->setCity((string) $data['city']); if (!empty($data['country'])) $draft->setCountry((string) $data['country']); if (!empty($data['zipcode']) && method_exists($draft, 'setZipcode')) { $draft->setZipcode((string) $data['zipcode']); } if (!empty($data['address']) && method_exists($draft, 'setAddress')) { $draft->setAddress((string) $data['address']); } } private function syncToLegacyFields(Candidates $candidate, array $data): void { if (!empty($data['city'])) $candidate->setCity($data['city']); if (!empty($data['country'])) $candidate->setCountry($data['country']); if (!empty($data['address'])) $candidate->setAddress($data['address']); if (!empty($data['phone'])) $candidate->setPhone($data['phone']); if (!empty($data['zipcode'])) $candidate->setZipcode($data['zipcode']); if (!empty($data['linkedin'])) $candidate->setLinkedinUrl($data['linkedin']); if (!empty($data['portfolio'])) $candidate->setWebsiteUrl($data['portfolio']); if (!empty($data['title'])) { $candidate->setTitlejobDefault($data['title']); if (!$candidate->getTitlejobFr()) $candidate->setTitlejobFr($data['title']); } if (!empty($data['summary'])) { $candidate->setPresentationDefault($data['summary']); if (!$candidate->getPresentationFr()) $candidate->setPresentationFr($data['summary']); } if (!empty($data['hard_skills'])) { $encoded = json_encode($data['hard_skills'], JSON_UNESCAPED_UNICODE); $candidate->setHardSkillsDefault($encoded); if (!$candidate->getHardSkillsFr()) $candidate->setHardSkillsFr($encoded); } if (!empty($data['soft_skills'])) { $encoded = json_encode($data['soft_skills'], JSON_UNESCAPED_UNICODE); $candidate->setSoftSkillsDefault($encoded); if (!$candidate->getSoftSkillsFr()) $candidate->setSoftSkillsFr($encoded); } } private function buildContext(PublicCvDraft $draft, int $currentStep): array { return [ 'jwt' => null, 'public_slug' => $draft->getPublicSlug(), 'api_base' => $this->apiBase, 'is_public' => true, 'user' => null, 'candidate' => null, 'draft' => $draft, 'current_step' => $currentStep, 'user_data' => [ 'first_name' => $draft->getFirstName() ?? '', 'last_name' => $draft->getLastName() ?? '', 'email' => $draft->getEmail() ?? '', ], 'mode' => $draft->getMode() ?? 'scratch', ]; }}