src/Controller/ThemesWebsite/Whileresume/Website/PublicGeneratorController.php line 141

Open in your IDE?
  1. <?php
  2. namespace App\Controller\ThemesWebsite\Whileresume\Website;
  3. use App\Entity\Core\Users;
  4. use App\Entity\Cvs\Candidates;
  5. use App\Entity\Cvs\PublicCvDraft;
  6. use App\Repository\Cvs\PublicCvDraftRepository;
  7. use App\Security\LoginFormAuthenticator;
  8. use App\Services\Mails;
  9. use App\Services\N8nWebhook;
  10. use Doctrine\ORM\EntityManagerInterface;
  11. use Lexik\Bundle\JWTAuthenticationBundle\Services\JWTTokenManagerInterface;
  12. use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
  13. use Symfony\Component\HttpFoundation\BinaryFileResponse;
  14. use Symfony\Component\HttpFoundation\File\File;
  15. use Symfony\Component\HttpFoundation\JsonResponse;
  16. use Symfony\Component\HttpFoundation\RedirectResponse;
  17. use Symfony\Component\HttpFoundation\Request;
  18. use Symfony\Component\HttpFoundation\Response;
  19. use Symfony\Component\HttpFoundation\ResponseHeaderBag;
  20. use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
  21. use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
  22. use Symfony\Component\HttpKernel\KernelInterface;
  23. use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;
  24. use Symfony\Component\Security\Http\Authentication\UserAuthenticatorInterface;
  25. use Symfony\Component\Validator\Constraints as Assert;
  26. use Symfony\Component\Validator\Validator\ValidatorInterface;
  27. use Twig\Environment;
  28. use Vich\UploaderBundle\Entity\File as EmbeddedFile;
  29. /**
  30.  * Tunnel CV public — V6 (Python lit la draft, Candidates créé uniquement à register)
  31.  *
  32.  * Routes :
  33.  *   GET  /cv-public/                  → entry()        : crée draft + redirect
  34.  *   GET  /cv-public/{slug}/           → choice()
  35.  *   GET  /cv-public/{slug}/theme      → theme()
  36.  *   GET  /cv-public/{slug}/form       → form()
  37.  *   POST /cv-public/{slug}/generate   → generate()     : Python génère PDF (lit draft via --public-slug)
  38.  *   GET  /cv-public/{slug}/register   → registerPage() : page d'inscription
  39.  *   POST /cv-public/{slug}/register   → register()     : crée Users + Candidates depuis draft
  40.  *
  41.  * Variables .env requises :
  42.  *   PYTHON_SCRIPT_CVPUBLIC_PATH1     chemin absolu vers cvpublic_primary.py (theme simple)
  43.  *   PYTHON_SCRIPT_CVPUBLIC_PATH2     chemin absolu vers cvpublic_second.py  (theme compact)
  44.  *   PYTHON_SCRIPT_CV_UPLOADPATH      dossier des PDFs (chemin absolu, slash final)
  45.  *   CV_PDF_PUBLIC_URL                URL publique des PDFs (slash final)
  46.  *   PYTHON_PATH                      binaire python
  47.  */
  48. class PublicGeneratorController extends AbstractController
  49. {
  50.     /** Durée de vie du draft public (48h) */
  51.     private const SLUG_TTL_HOURS 48;
  52.     private const SLUG_LENGTH    20;
  53.     private string $apiBase;
  54.     // Env Python (NOUVELLES variables dédiées au tunnel public)
  55.     private string $scriptPath1;
  56.     private string $scriptPath2;
  57.     private string $pythonBin;
  58.     private string $uploadPath;
  59.     private string $pdfPublicBase;
  60.     private string $analyzerScriptPath;
  61.     public function __construct(
  62.         private readonly EntityManagerInterface       $em,
  63.         private readonly PublicCvDraftRepository      $draftRepo,
  64.         private readonly JWTTokenManagerInterface     $jwtManager,
  65.         private readonly UserPasswordEncoderInterface $passwordEncoder,
  66.         private readonly UserAuthenticatorInterface   $userAuthenticator,
  67.         private readonly LoginFormAuthenticator       $authenticator,
  68.         private readonly Mails                        $ms,
  69.         private readonly Environment                  $twig,
  70.         private readonly ValidatorInterface           $validator,
  71.         private readonly KernelInterface              $kernel,
  72.         private readonly N8nWebhook                   $n8n,
  73.     ) {
  74.         $this->apiBase       $_ENV['CV_PUBLIC_API_BASE']           ?? '/api/cv-public';
  75.         $this->scriptPath1   $_ENV['PYTHON_SCRIPT_CVPUBLIC_PATH1'] ?? '';
  76.         $this->scriptPath2   $_ENV['PYTHON_SCRIPT_CVPUBLIC_PATH2'] ?? '';
  77.         $this->pythonBin     $_ENV['PYTHON_PATH']                  ?? '/usr/bin/python3';
  78.         $this->uploadPath    rtrim($_ENV['PYTHON_SCRIPT_CV_UPLOADPATH'] ?? '''/') . '/';
  79.         $this->pdfPublicBase rtrim($_ENV['CV_PDF_PUBLIC_URL']      ?? '''/') . '/';
  80.         // Script d'analyse IA : même variable .env que CvGeneratorController
  81.         // (PYTHON_SCRIPT_ANALYSE_CV_IA en priorité, PYTHON_SCRIPT_PATH3 en legacy)
  82.         $this->analyzerScriptPath $_ENV['PYTHON_SCRIPT_ANALYSE_CV_IA']
  83.             ?? $_ENV['PYTHON_SCRIPT_PATH3']
  84.             ?? '';
  85.     }
  86.     // =========================================================================
  87.     // Entry + pages
  88.     // =========================================================================
  89.     public function entry(Request $request): RedirectResponse
  90.     {
  91.         $now = new \DateTime();
  92.         $defaultLocale       $_ENV['APP_DEFAULT_LOCALE'] ?? 'fr';
  93.         $localeRequirements  $_ENV['APP_LOCALE_REQUIREMENTS'] ?? 'fr|de|es|nl';
  94.         $supportedLocales    explode('|'$localeRequirements);
  95.         $requestLocale       $request->getLocale();
  96.         $effectiveLocale     in_array($requestLocale$supportedLocalestrue)
  97.             ? $requestLocale
  98.             $defaultLocale;
  99.         $draft = new PublicCvDraft();
  100.         $draft->setPublicSlug($this->generateUniquePublicSlug());
  101.         $draft->setExpiresAt(
  102.             (clone $now)->modify('+' self::SLUG_TTL_HOURS ' hours')
  103.         );
  104.         $draft->setCreatedAt($now);
  105.         $draft->setUpdatedAt($now);
  106.         $draft->setLocale($effectiveLocale);
  107.         $draft->setMode('scratch');
  108.         $this->em->persist($draft);
  109.         $this->em->flush();
  110.         // Préserver le préfixe locale si l'URL d'entrée en avait un
  111.         $hasLocalePrefix = (bool) preg_match(
  112.             '#^/(' $localeRequirements ')/#',
  113.             $request->getPathInfo()
  114.         );
  115.         $route $hasLocalePrefix
  116.             'locale_cv_public_choice'
  117.             'cv_public_choice';
  118.         $params = ['slug' => $draft->getPublicSlug()];
  119.         if ($hasLocalePrefix) {
  120.             $params['_locale'] = $effectiveLocale;
  121.         }
  122.         return $this->redirectToRoute($route$params);
  123.     }
  124.     public function choice(Request $requeststring $slug): Response
  125.     {
  126.         $draft $this->resolveDraftStrict($slug);
  127.         return $this->render(
  128.             'application/whileresume/website/cv-public/choice.html.twig',
  129.             $this->buildContext($draftcurrentStep1)
  130.         );
  131.     }
  132.     public function theme(Request $requeststring $slug): Response
  133.     {
  134.         $draft $this->resolveDraftStrict($slug);
  135.         return $this->render(
  136.             'application/whileresume/website/cv-public/theme.html.twig',
  137.             $this->buildContext($draftcurrentStep2)
  138.         );
  139.     }
  140.     public function form(Request $requeststring $slug): Response
  141.     {
  142.         $draft $this->resolveDraftStrict($slug);
  143.         $mode $request->query->get('mode''scratch');
  144.         if (!in_array($mode, ['analyze''scratch'], true)) {
  145.             $mode 'scratch';
  146.         }
  147.         if ($draft->getMode() !== $mode) {
  148.             $draft->setMode($mode);
  149.             $draft->setUpdatedAt(new \DateTime());
  150.             $this->em->persist($draft);
  151.             $this->em->flush();
  152.         }
  153.         $ctx $this->buildContext($draftcurrentStep3);
  154.         $ctx['mode']      = $mode;
  155.         $ctx['is_public'] = true;
  156.         return $this->render(
  157.             'application/whileresume/website/cv-public/form.html.twig',
  158.             $ctx
  159.         );
  160.     }
  161.     // =========================================================================
  162.     // POST /cv-public/{slug}/generate
  163.     //
  164.     // Python lit cv_data depuis cvs_candidates_drafts via --public-slug.
  165.     // PAS DE CRÉATION DE CANDIDATES ICI — c'est /register qui s'en chargera.
  166.     // PAS D'ANALYSE IA.
  167.     //
  168.     // Body JSON (optionnel — si absent, Python relit la draft en BDD) :
  169.     //   { data: {...}, theme, primary, secondary, cv_language }
  170.     //
  171.     // Réponse :
  172.     //   { success: true, pdf_url: "...", slug: "..." }
  173.     // =========================================================================
  174.     public function generate(Request $requeststring $slug): JsonResponse
  175.     {
  176.         $draft $this->resolveDraftStrict($slug);
  177.         if (empty($this->scriptPath1) || empty($this->scriptPath2)) {
  178.             return new JsonResponse([
  179.                 'success' => false,
  180.                 'error'   => 'Scripts Python non configures dans .env (PYTHON_SCRIPT_CVPUBLIC_PATH1/2)',
  181.             ], 500);
  182.         }
  183.         // Lecture body : si le front envoie cv_data + settings, on les flush
  184.         // dans la draft AVANT exec Python (pour que Python lise les dernières données).
  185.         $body $request->toArray();
  186.         $cvData     $body['data']        ?? null;
  187.         $theme      $body['theme']       ?? null;
  188.         $primary    $body['primary']     ?? null;
  189.         $secondary  $body['secondary']   ?? null;
  190.         $cvLanguage $body['cv_language'] ?? null;
  191.         $now = new \DateTime();
  192.         try {
  193.             // ── 1. Si le front a envoyé cv_data, on flush dans la draft ──
  194.             //    (utile car save-draft est debounced — on garantit la fraîcheur)
  195.             if (is_array($cvData) && !empty($cvData)) {
  196.                 // Photo strippée
  197.                 unset($cvData['_cv_language'], $cvData['photo']);
  198.                 $existingSettings $draft->getCvSettingsArray();
  199.                 $newSettings = [
  200.                     'theme'       => $theme       ?? ($existingSettings['theme']       ?? 'simple'),
  201.                     'primary'     => $primary     ?? ($existingSettings['primary']     ?? '#1A8A7D'),
  202.                     'secondary'   => $secondary   ?? ($existingSettings['secondary']   ?? '#F59E0B'),
  203.                     'cv_language' => $cvLanguage  ?? ($existingSettings['cv_language'] ?? ($draft->getLocale() ?: 'fr')),
  204.                 ];
  205.                 $draft->setCvData(json_encode($cvDataJSON_UNESCAPED_UNICODE JSON_UNESCAPED_SLASHES));
  206.                 $draft->setCvSettings(json_encode($newSettingsJSON_UNESCAPED_UNICODE));
  207.                 // Sync colonnes typées sur la draft (cohérent avec
  208.                 // PublicCvDraftController::saveDraft). Inclut zipcode + address
  209.                 // de façon défensive via method_exists().
  210.                 $this->syncDraftTypedColumns($draft$cvData);
  211.                 $draft->setUpdatedAt($now);
  212.                 $this->em->persist($draft);
  213.                 $this->em->flush();
  214.             }
  215.             // ── 2. Vérifier qu'on a bien des données pour Python ──
  216.             $settings $draft->getCvSettingsArray();
  217.             $effTheme       $settings['theme']       ?? 'simple';
  218.             $effPrimary     $settings['primary']     ?? '#1A8A7D';
  219.             $effSecondary   $settings['secondary']   ?? '#F59E0B';
  220.             $effCvLanguage  $settings['cv_language'] ?? ($draft->getLocale() ?: 'fr');
  221.             $draftCvDataArray $draft->getCvDataArray();
  222.             if (empty($draftCvDataArray)) {
  223.                 return new JsonResponse([
  224.                     'success' => false,
  225.                     'error'   => 'Aucune donnée CV à générer. Veuillez remplir le formulaire d\'abord.',
  226.                 ], 400);
  227.             }
  228.             // ── 3. Préparer le PDF ──
  229.             if (!is_dir($this->uploadPath)) {
  230.                 mkdir($this->uploadPath0755true);
  231.             }
  232.             $ts          time();
  233.             $pdfFileName 'cvpublic_' $draft->getId() . '_' $ts '.pdf';
  234.             $pdfPath     $this->uploadPath $pdfFileName;
  235.             $oldPdfPath  $draft->getCvPdfPath();
  236.             $scriptPath = ($effTheme === 'compact') ? $this->scriptPath2 $this->scriptPath1;
  237.             // Logo (même dossier que le script)
  238.             $logoPath dirname($scriptPath) . '/favicon.png';
  239.             $logoArg  file_exists($logoPath) ? ' --logo ' escapeshellarg($logoPath) : '';
  240.             // Pas de photo en mode public (l'avatar User n'existe pas encore)
  241.             $photoArg '';
  242.             // ── 4. Exec Python avec --public-slug (lit cvs_candidates_drafts) ──
  243.             $command sprintf(
  244.                 '%s %s --public-slug %s%s%s --primary %s --secondary %s --lang %s --output %s 2>&1',
  245.                 escapeshellcmd($this->pythonBin),
  246.                 escapeshellarg($scriptPath),
  247.                 escapeshellarg($draft->getPublicSlug()),
  248.                 $photoArg,
  249.                 $logoArg,
  250.                 escapeshellarg($effPrimary),
  251.                 escapeshellarg($effSecondary),
  252.                 escapeshellarg($effCvLanguage),
  253.                 escapeshellarg($pdfPath)
  254.             );
  255.             $output = [];
  256.             $returnCode 0;
  257.             exec($command$output$returnCode);
  258.             if ($returnCode !== || !file_exists($pdfPath)) {
  259.                 return new JsonResponse([
  260.                     'success' => false,
  261.                     'error'   => 'Erreur lors de la génération du CV. Veuillez réessayer.',
  262.                     'debug'   => ($_ENV['APP_ENV'] ?? 'prod') === 'dev'
  263.                         ? ['returnCode' => $returnCode'output' => $output'command' => $command]
  264.                         : null,
  265.                 ], 500);
  266.             }
  267.             // ── 5. Supprimer l'ancien PDF s'il existe ──
  268.             if ($oldPdfPath && file_exists($oldPdfPath) && $oldPdfPath !== $pdfPath) {
  269.                 @unlink($oldPdfPath);
  270.             }
  271.             // ── 6. Persister cv_pdf_path sur la draft ──
  272.             $draft->setCvPdfPath($pdfPath);
  273.             $draft->setUpdatedAt(new \DateTime());
  274.             $this->em->persist($draft);
  275.             $this->em->flush();
  276.             // pdf_url pointe vers notre route Symfony /pdf qui sert le fichier
  277.             // en streaming après vérification du slug. Pas besoin que le dossier
  278.             // documents/cv soit accessible publiquement.
  279.             // On préserve le préfixe locale si l'URL d'entrée en avait un.
  280.             $localeRequirements $_ENV['APP_LOCALE_REQUIREMENTS'] ?? 'fr|de|es|nl';
  281.             $hasLocalePrefix    = (bool) preg_match(
  282.                 '#^/(' $localeRequirements ')/#',
  283.                 $request->getPathInfo()
  284.             );
  285.             $pdfUrl $hasLocalePrefix
  286.                 $this->generateUrl('locale_cv_public_pdf', [
  287.                     'slug'    => $draft->getPublicSlug(),
  288.                     '_locale' => $request->getLocale(),
  289.                 ])
  290.                 : $this->generateUrl('cv_public_pdf', [
  291.                     'slug' => $draft->getPublicSlug(),
  292.                 ]);
  293.             // Cache busting pour forcer le navigateur à recharger le PDF
  294.             // après une régénération (sinon l'iframe garde l'ancien)
  295.             $pdfUrl .= '?t=' time();
  296.             return new JsonResponse([
  297.                 'success' => true,
  298.                 'pdf_url' => $pdfUrl,
  299.                 'slug'    => $draft->getPublicSlug(),
  300.             ]);
  301.         } catch (\Throwable $e) {
  302.             return new JsonResponse([
  303.                 'success' => false,
  304.                 'error'   => 'Erreur générale : ' $e->getMessage(),
  305.             ], 500);
  306.         }
  307.     }
  308.     // =========================================================================
  309.     // GET /cv-public/{slug}/pdf
  310.     //
  311.     // Sert le PDF en streaming via BinaryFileResponse, après vérification
  312.     // que la draft existe et n'a pas expiré.
  313.     //
  314.     // Le query param ?dl=1 force le téléchargement (Content-Disposition: attachment).
  315.     // Sans ce param, le PDF est servi inline (utilisé par l'iframe de preview).
  316.     // =========================================================================
  317.     public function pdf(Request $requeststring $slug): Response
  318.     {
  319.         $draft $this->resolveDraftStrict($slug);
  320.         $pdfPath $draft->getCvPdfPath();
  321.         if ($pdfPath === null || !file_exists($pdfPath)) {
  322.             throw new NotFoundHttpException('PDF introuvable. Veuillez régénérer votre CV.');
  323.         }
  324.         $disposition $request->query->get('dl') === '1'
  325.             ResponseHeaderBag::DISPOSITION_ATTACHMENT
  326.             ResponseHeaderBag::DISPOSITION_INLINE;
  327.         $response = new BinaryFileResponse($pdfPath);
  328.         $response->setContentDisposition($disposition'cv.pdf');
  329.         $response->headers->set('Content-Type''application/pdf');
  330.         // Pas de cache : le PDF peut être régénéré
  331.         $response->headers->set('Cache-Control''no-cache, no-store, must-revalidate');
  332.         $response->headers->set('Pragma''no-cache');
  333.         $response->headers->set('Expires''0');
  334.         return $response;
  335.     }
  336.     // =========================================================================
  337.     // GET /cv-public/{slug}/register
  338.     // =========================================================================
  339.     public function registerPage(Request $requeststring $slug): Response
  340.     {
  341.         $draft $this->resolveDraftStrict($slug);
  342.         // Si pas encore généré, on redirige vers le form
  343.         if ($draft->getCvPdfPath() === null) {
  344.             $defaultLocale      $_ENV['APP_DEFAULT_LOCALE'] ?? 'fr';
  345.             $localeRequirements $_ENV['APP_LOCALE_REQUIREMENTS'] ?? 'fr|de|es|nl';
  346.             $supportedLocales   explode('|'$localeRequirements);
  347.             $requestLocale      $request->getLocale();
  348.             $effectiveLocale    in_array($requestLocale$supportedLocalestrue)
  349.                 ? $requestLocale
  350.                 $defaultLocale;
  351.             $hasLocalePrefix = (bool) preg_match(
  352.                 '#^/(' $localeRequirements ')/#',
  353.                 $request->getPathInfo()
  354.             );
  355.             $route $hasLocalePrefix 'locale_cv_public_form' 'cv_public_form';
  356.             $params = ['slug' => $slug];
  357.             if ($hasLocalePrefix) {
  358.                 $params['_locale'] = $effectiveLocale;
  359.             }
  360.             return $this->redirectToRoute($route$params);
  361.         }
  362.         $ctx $this->buildContext($draftcurrentStep4);
  363.         $ctx['mode']      = $draft->getMode() ?? 'scratch';
  364.         $ctx['is_public'] = true;
  365.         return $this->render(
  366.             'application/whileresume/website/cv-public/register.html.twig',
  367.             $ctx
  368.         );
  369.     }
  370.     // =========================================================================
  371.     // POST /cv-public/{slug}/register
  372.     //
  373.     // Crée Users + Candidates en transférant tout depuis la draft :
  374.     //   1. cv_data + cv_settings : draft → Candidates
  375.     //   2. PDF : déplacé du dossier "cvpublic_*" vers le pattern "cv_*" attendu
  376.     //   3. Lien Users.candidate
  377.     //   4. DELETE draft
  378.     //   5. Mail bienvenue + login auto
  379.     //
  380.     // PAS D'ANALYSE IA.
  381.     // =========================================================================
  382.     public function register(Request $requeststring $slug): JsonResponse
  383.     {
  384.         $draft $this->resolveDraftStrict($slug);
  385.         if ($draft->getCvPdfPath() === null) {
  386.             return new JsonResponse([
  387.                 'ok'    => false,
  388.                 'error' => 'no_cv_generated',
  389.                 'errors' => [
  390.                     '_global' => 'Vous devez d\'abord générer votre CV avant de créer votre compte.',
  391.                 ],
  392.             ], Response::HTTP_BAD_REQUEST);
  393.         }
  394.         $payload json_decode($request->getContent(), true);
  395.         if (!is_array($payload)) {
  396.             throw new BadRequestHttpException('Invalid JSON payload');
  397.         }
  398.         $email           trim((string) ($payload['email'] ?? ''));
  399.         $password        = (string) ($payload['password'] ?? '');
  400.         $passwordConfirm = (string) ($payload['passwordConfirm'] ?? '');
  401.         $locale          $request->getLocale();
  402.         // --- Validation ------------------------------------------------
  403.         $violations $this->validator->validate($email, [
  404.             new Assert\NotBlank(message'L\'email est obligatoire.'),
  405.             new Assert\Email(message'Format d\'email invalide.'),
  406.             new Assert\Length(max180),
  407.         ]);
  408.         if (count($violations) > 0) {
  409.             return new JsonResponse([
  410.                 'ok'     => false,
  411.                 'errors' => ['email' => $violations[0]->getMessage()],
  412.             ], Response::HTTP_BAD_REQUEST);
  413.         }
  414.         if (strlen($password) < 8) {
  415.             return new JsonResponse([
  416.                 'ok'     => false,
  417.                 'errors' => ['password' => 'Le mot de passe doit faire au moins 8 caractères.'],
  418.             ], Response::HTTP_BAD_REQUEST);
  419.         }
  420.         if ($password !== $passwordConfirm) {
  421.             return new JsonResponse([
  422.                 'ok'     => false,
  423.                 'errors' => ['passwordConfirm' => 'Les mots de passe ne correspondent pas.'],
  424.             ], Response::HTTP_BAD_REQUEST);
  425.         }
  426.         $existing $this->em->getRepository(Users::class)->findOneBy(['email' => $email]);
  427.         if ($existing !== null) {
  428.             return new JsonResponse([
  429.                 'ok'     => false,
  430.                 'error'  => 'email_exists',
  431.                 'errors' => [
  432.                     'email' => 'Cette adresse email est déjà utilisée. Merci d\'en choisir une autre.',
  433.                 ],
  434.             ], Response::HTTP_CONFLICT);
  435.         }
  436.         $cvDataArray $draft->getCvDataArray();
  437.         $oldPdfPath  $draft->getCvPdfPath();
  438.         // --- Transaction : création Users + Candidates + transfert PDF ---
  439.         $this->em->beginTransaction();
  440.         try {
  441.             $now = new \DateTime('now');
  442.             // ── 1. Créer le Candidates ──
  443.             $candidate = new Candidates();
  444.             $candidate->setEmail($email);
  445.             $candidate->setCreatedAt($now);
  446.             $candidate->setUpdatedAt($now);
  447.             $candidate->setOnline(false);
  448.             $candidate->setFirst(false); // l'user a déjà un CV → plus "first"
  449.             if (method_exists($candidate'setAvailability')) {
  450.                 $candidate->setAvailability($cvDataArray['availability'] ?? 'offline');
  451.             }
  452.             // Transfert cv_data + cv_settings depuis la draft
  453.             $candidate->setCvData($draft->getCvData());
  454.             $candidate->setCvSettings($draft->getCvSettings());
  455.             // Sync legacy fields
  456.             $this->syncToLegacyFields($candidate$cvDataArray);
  457.             $this->em->persist($candidate);
  458.             $this->em->flush(); // pour avoir l'ID
  459.             // Slugs internes (pattern existant)
  460.             $candidate->setSlug($this->generateInternalSlug((int) $candidate->getId(), 10));
  461.             if (method_exists($candidate'setSlugAnonyme')) {
  462.                 $candidate->setSlugAnonyme($this->generateInternalSlug((int) $candidate->getId(), 10));
  463.             }
  464.             // ── 2. Déplacer le PDF du nom "cvpublic_X_TS.pdf" → "cv_<candId>_TS.pdf" ──
  465.             $newPdfPath $oldPdfPath;
  466.             if (file_exists($oldPdfPath)) {
  467.                 $ts time();
  468.                 $newPdfFileName 'cv_' $candidate->getId() . '_' $ts '.pdf';
  469.                 $newPdfPath $this->uploadPath $newPdfFileName;
  470.                 if (@rename($oldPdfPath$newPdfPath)) {
  471.                     $candidate->setCvPdfPath($newPdfPath);
  472.                 } else {
  473.                     // Si le rename échoue, on garde l'ancien chemin (qui marche)
  474.                     $candidate->setCvPdfPath($oldPdfPath);
  475.                     $newPdfPath $oldPdfPath;
  476.                 }
  477.             } else {
  478.                 // PDF disparu (rare) : on laisse cv_pdf_path null, l'user pourra régénérer
  479.                 $candidate->setCvPdfPath(null);
  480.                 $newPdfPath null;
  481.             }
  482.             // ── 2bis. Copier le PDF dans /files/cvs comme "CV original uploadé" ──
  483.             //
  484.             // Vich Uploader (mapping cv_files) est configuré pour stocker les
  485.             // CV originaux dans <project_dir>/files/cvs avec un nom uniqid.
  486.             // L'approche setImageFile() ne déclenche pas toujours la lifecycle
  487.             // hook Vich (selon l'ordre flush/persist), donc on fait ça
  488.             // MANUELLEMENT :
  489.             //   1. Copier le PDF généré vers /files/cvs/<uniqid>.pdf
  490.             //   2. Remplir l'EmbeddedFile (image.name, .size, .mimeType,
  491.             //      .originalName, .dimensions) directement
  492.             //
  493.             // Du coup, dans l'espace candidat, le CV apparaîtra comme s'il
  494.             // avait été uploadé via le formulaire d'analyse de /generate2/*.
  495.             if ($newPdfPath !== null && file_exists($newPdfPath)) {
  496.                 try {
  497.                     $vichDir $this->kernel->getProjectDir() . '/files/cvs';
  498.                     if (!is_dir($vichDir)) {
  499.                         @mkdir($vichDir0755true);
  500.                     }
  501.                     // Génère un nom uniqid (cohérent avec namer Vich vich_uploader.namer_uniqid)
  502.                     $vichFilename uniqid() . '.pdf';
  503.                     $vichFullPath $vichDir '/' $vichFilename;
  504.                     if (@copy($newPdfPath$vichFullPath)) {
  505.                         // Remplit l'EmbeddedFile que Vich attend dans Candidates.image
  506.                         $embedded = new EmbeddedFile();
  507.                         $embedded->setName($vichFilename);
  508.                         $embedded->setOriginalName('cv-' $candidate->getId() . '.pdf');
  509.                         $embedded->setMimeType('application/pdf');
  510.                         $embedded->setSize(filesize($vichFullPath) ?: 0);
  511.                         // dimensions : null pour un PDF (Vich gère le cas null)
  512.                         if (method_exists($embedded'setDimensions')) {
  513.                             $embedded->setDimensions(null);
  514.                         }
  515.                         $candidate->setImage($embedded);
  516.                     }
  517.                 } catch (\Throwable $e) {
  518.                     // Best effort : si la copie échoue, on garde quand même
  519.                     // cv_pdf_path. Pas bloquant pour l'inscription.
  520.                 }
  521.             }
  522.             // ── 3. Créer le Users ──
  523.             $newUser = new Users();
  524.             $newUser->setEmail($email);
  525.             $newUser->setUsername('');
  526.             $newUser->setVerification(false);
  527.             $newUser->setNotificationsMessages(true);
  528.             $newUser->setNotificationsSuivis(true);
  529.             $newUser->setPremium(false);
  530.             $newUser->setFirst(false);
  531.             $newUser->setEnabled(true);
  532.             $newUser->setPassword($this->passwordEncoder->encodePassword($newUser$password));
  533.             $newUser->setRoles(['ROLE_USER']);
  534.             $newUser->setTypeAccount('candidate');
  535.             $newUser->setCreatedAt($now);
  536.             $newUser->setUpdatedAt($now);
  537.             if ($draft->getFirstName() && method_exists($newUser'setName')) {
  538.                 $newUser->setName($draft->getFirstName());
  539.             }
  540.             if ($draft->getLastName() && method_exists($newUser'setLastname')) {
  541.                 $newUser->setLastname($draft->getLastName());
  542.             }
  543.             $newUser->setCandidate($candidate);
  544.             $this->em->persist($candidate);
  545.             $this->em->persist($newUser);
  546.             // ── 4. DELETE draft ──
  547.             $this->em->remove($draft);
  548.             $this->em->flush();
  549.             $this->em->commit();
  550.         } catch (\Throwable $e) {
  551.             $this->em->rollback();
  552.             return new JsonResponse([
  553.                 'ok'     => false,
  554.                 'error'  => 'register_failed',
  555.                 'errors' => ['_global' => 'Une erreur est survenue lors de la création du compte.'],
  556.                 'debug'  => ($_ENV['APP_ENV'] ?? 'prod') === 'dev' $e->getMessage() : null,
  557.             ], Response::HTTP_INTERNAL_SERVER_ERROR);
  558.         }
  559.         // --- ANALYSE IA DU CV (non-bloquant, arrière-plan) -------------
  560.         //
  561.         // Lancée APRÈS le commit (sinon le script Python ne verrait pas
  562.         // le Candidates en BDD). Le script analyse_cv_ia.py :
  563.         //   1. Lit cv_data depuis cvs_candidates via selectOneCv()
  564.         //   2. Appelle l'IA (OpenAI) pour générer recruiter_summary,
  565.         //      hard_skills, soft_skills, recruiter_exp, etc.
  566.         //   3. Écrit form_ai_analysis(_status|_updated_at) via updateCvV2()
  567.         //
  568.         // L'utilisateur arrive sur son dashboard immédiatement, et le front
  569.         // poll /api/candidate/info pour récupérer l'analyse quand elle est prête.
  570.         //
  571.         // Identique au flow /generate2/save-cv pour rester cohérent.
  572.         try {
  573.             $candId = (int) $candidate->getId();
  574.             $cvSettings $candidate->getCvSettings();
  575.             $settingsArr $cvSettings ? (json_decode($cvSettingstrue) ?: []) : [];
  576.             $cvLanguage  $settingsArr['cv_language'] ?? $locale ?? 'fr';
  577.             if (!empty($this->analyzerScriptPath) && is_file($this->analyzerScriptPath)) {
  578.                 // Reset des champs form_ai_analysis pour que le front affiche
  579.                 // le spinner "analyse en cours" et non une ancienne analyse.
  580.                 $candidate->setFormAiAnalysis(null);
  581.                 $candidate->setFormAiAnalysisUpdatedAt(null);
  582.                 $candidate->setFormAiAnalysisStatus('pending');
  583.                 $this->em->persist($candidate);
  584.                 $this->em->flush();
  585.                 $ts time();
  586.                 $logPath $this->uploadPath 'ai_' $candId '_' $ts '.log';
  587.                 $targetLang in_array(strtolower((string) $cvLanguage), ['fr''en'], true)
  588.                     ? strtolower((string) $cvLanguage)
  589.                     : 'fr';
  590.                 // IMPORTANT : `cd` dans le dossier du script car analyse_cv_ia.py
  591.                 // fait `from model import ...` (model.py est dans le même dossier).
  592.                 // nohup + & : le process Python survit à la fin du request PHP
  593.                 // → l'API /register répond immédiatement, l'analyse continue derrière.
  594.                 $scriptDir dirname($this->analyzerScriptPath);
  595.                 $aiCommand sprintf(
  596.                     'cd %s && nohup %s %s %s %s > %s 2>&1 &',
  597.                     escapeshellarg($scriptDir),
  598.                     escapeshellcmd($this->pythonBin),
  599.                     escapeshellarg($this->analyzerScriptPath),
  600.                     escapeshellarg((string) $candId),
  601.                     escapeshellarg($targetLang),
  602.                     escapeshellarg($logPath)
  603.                 );
  604.                 exec($aiCommand);
  605.             } else {
  606.                 // Script non configuré : on marque failed pour pas que le front
  607.                 // poll indéfiniment.
  608.                 error_log(sprintf(
  609.                     '[CvPublicAnalyzer] Script d\'analyse non configuré ou introuvable (analyzerScriptPath="%s")',
  610.                     $this->analyzerScriptPath
  611.                 ));
  612.                 try {
  613.                     $candidate->setFormAiAnalysisStatus('failed');
  614.                     $this->em->persist($candidate);
  615.                     $this->em->flush();
  616.                 } catch (\Throwable $inner) {
  617.                     // best effort
  618.                 }
  619.             }
  620.         } catch (\Throwable $e) {
  621.             error_log('[CvPublicAnalyzer] ' $e->getMessage());
  622.             // Ne pas bloquer l'inscription si l'analyse échoue à se lancer
  623.         }
  624.         // --- Mail bienvenue (best-effort) ------------------------------
  625.         try {
  626.             $title $locale === 'fr'
  627.                 '🎉 Bienvenue ! Votre profil peut déjà attirer des recruteurs !'
  628.                 'Welcome! Your profile is ready to attract recruiters!';
  629.             $tplLocale       in_array($locale, ['fr''en'], true) ? $locale 'fr';
  630.             $descriptionHTML $this->twig->render(
  631.                 'application/whileresume/gestion/emails/' $tplLocale '/register_candidate.html.twig',
  632.                 ['title' => $title'email' => $email]
  633.             );
  634.             $this->ms->webhook($title$descriptionHTMLnull$emailnullnull);
  635.         } catch (\Throwable $e) {
  636.             // best effort
  637.         }
  638.         // --- Notification n8n (non bloquante) --------------------------
  639.         $this->n8n->send($_ENV['N8N_WEBHOOK_NOTIF_REGISTER_CANDIDATE'] ?? null, [
  640.             'name'         => $newUser->getName(),
  641.             'lastname'     => $newUser->getLastname(),
  642.             'city'         => $candidate->getCity(),
  643.             'mobile'       => true,
  644.             'email'        => $newUser->getEmail(),
  645.             'id_candidate' => $candidate->getId(),
  646.             'id_user'      => $newUser->getId(),
  647.         ]);
  648.         // --- Login auto ------------------------------------------------
  649.         $this->userAuthenticator->authenticateUser($newUser$this->authenticator$request);
  650.         $jwt $this->jwtManager->create($newUser);
  651.         // Redirect dashboard avec préservation du préfixe locale
  652.         $defaultLocale      $_ENV['APP_DEFAULT_LOCALE'] ?? 'fr';
  653.         $localeRequirements $_ENV['APP_LOCALE_REQUIREMENTS'] ?? 'fr|de|es|nl';
  654.         $supportedLocales   explode('|'$localeRequirements);
  655.         $effectiveLocale    in_array($locale$supportedLocalestrue)
  656.             ? $locale
  657.             $defaultLocale;
  658.         $hasLocalePrefix = (bool) preg_match(
  659.             '#^/(' $localeRequirements ')/#',
  660.             $request->getPathInfo()
  661.         );
  662.         $redirect $hasLocalePrefix
  663.             $this->generateUrl('locale_customer_homepage', ['_locale' => $effectiveLocale])
  664.             : $this->generateUrl('customer_homepage');
  665.         return new JsonResponse([
  666.             'ok'       => true,
  667.             'jwt'      => $jwt,
  668.             'redirect' => $redirect,
  669.             'message'  => 'Votre compte a été créé.',
  670.         ]);
  671.     }
  672.     // =========================================================================
  673.     // Helpers privés
  674.     // =========================================================================
  675.     private function resolveDraftStrict(string $slug): PublicCvDraft
  676.     {
  677.         if (!preg_match('/^[a-f0-9]{20}$/'$slug)) {
  678.             throw new NotFoundHttpException('Lien de brouillon invalide.');
  679.         }
  680.         $draft $this->draftRepo->findValidBySlug($slug);
  681.         if ($draft === null) {
  682.             throw new NotFoundHttpException(
  683.                 'Ce brouillon de CV est introuvable ou a expiré (48h). Veuillez recommencer un nouveau CV.'
  684.             );
  685.         }
  686.         return $draft;
  687.     }
  688.     private function generateUniquePublicSlug(): string
  689.     {
  690.         for ($i 0$i 5$i++) {
  691.             $slug substr(bin2hex(random_bytes((int) ceil(self::SLUG_LENGTH 2))), 0self::SLUG_LENGTH);
  692.             if (!$this->draftRepo->slugExists($slug)) {
  693.                 return $slug;
  694.             }
  695.         }
  696.         throw new \RuntimeException('Unable to generate unique public slug after 5 attempts.');
  697.     }
  698.     private function generateInternalSlug(int $idint $length): string
  699.     {
  700.         $alphabet 'abcdefghijklmnopqrstuvwxyz0123456789';
  701.         $max      strlen($alphabet) - 1;
  702.         $random   '';
  703.         for ($i 0$i $length$i++) {
  704.             $random .= $alphabet[random_int(0$max)];
  705.         }
  706.         return $id '-' $random;
  707.     }
  708.     /**
  709.      * Synchronise les colonnes typées de PublicCvDraft à partir du cv_data.
  710.      *
  711.      * Logique identique à PublicCvDraftController::syncTypedColumns(). Les
  712.      * deux contrôleurs doivent rester en miroir pour que les colonnes
  713.      * typées de la draft soient toujours fraîches au moment où le
  714.      * /register lit la draft pour créer Users + Candidates.
  715.      *
  716.      * Champs « obligatoires » (présents dès la création de l'entité) :
  717.      *   firstName, lastName, email, phone, city, country.
  718.      * Champs « optionnels » (peuvent ne pas exister sur l'entité,
  719.      * protégés par method_exists pour ne pas casser si l'entité n'a
  720.      * pas encore la colonne en BDD) :
  721.      *   zipcode, address.
  722.      */
  723.     private function syncDraftTypedColumns(PublicCvDraft $draft, array $data): void
  724.     {
  725.         if (!empty($data['first_name'])) $draft->setFirstName((string) $data['first_name']);
  726.         if (!empty($data['last_name']))  $draft->setLastName((string) $data['last_name']);
  727.         if (!empty($data['email']))      $draft->setEmail((string) $data['email']);
  728.         if (!empty($data['phone']))      $draft->setPhone((string) $data['phone']);
  729.         if (!empty($data['city']))       $draft->setCity((string) $data['city']);
  730.         if (!empty($data['country']))    $draft->setCountry((string) $data['country']);
  731.         if (!empty($data['zipcode']) && method_exists($draft'setZipcode')) {
  732.             $draft->setZipcode((string) $data['zipcode']);
  733.         }
  734.         if (!empty($data['address']) && method_exists($draft'setAddress')) {
  735.             $draft->setAddress((string) $data['address']);
  736.         }
  737.     }
  738.     private function syncToLegacyFields(Candidates $candidate, array $data): void
  739.     {
  740.         if (!empty($data['city']))         $candidate->setCity($data['city']);
  741.         if (!empty($data['country']))      $candidate->setCountry($data['country']);
  742.         if (!empty($data['address']))      $candidate->setAddress($data['address']);
  743.         if (!empty($data['phone']))        $candidate->setPhone($data['phone']);
  744.         if (!empty($data['zipcode']))      $candidate->setZipcode($data['zipcode']);
  745.         if (!empty($data['linkedin']))     $candidate->setLinkedinUrl($data['linkedin']);
  746.         if (!empty($data['portfolio']))    $candidate->setWebsiteUrl($data['portfolio']);
  747.         if (!empty($data['title'])) {
  748.             $candidate->setTitlejobDefault($data['title']);
  749.             if (!$candidate->getTitlejobFr()) $candidate->setTitlejobFr($data['title']);
  750.         }
  751.         if (!empty($data['summary'])) {
  752.             $candidate->setPresentationDefault($data['summary']);
  753.             if (!$candidate->getPresentationFr()) $candidate->setPresentationFr($data['summary']);
  754.         }
  755.         if (!empty($data['hard_skills'])) {
  756.             $encoded json_encode($data['hard_skills'], JSON_UNESCAPED_UNICODE);
  757.             $candidate->setHardSkillsDefault($encoded);
  758.             if (!$candidate->getHardSkillsFr()) $candidate->setHardSkillsFr($encoded);
  759.         }
  760.         if (!empty($data['soft_skills'])) {
  761.             $encoded json_encode($data['soft_skills'], JSON_UNESCAPED_UNICODE);
  762.             $candidate->setSoftSkillsDefault($encoded);
  763.             if (!$candidate->getSoftSkillsFr()) $candidate->setSoftSkillsFr($encoded);
  764.         }
  765.     }
  766.     private function buildContext(PublicCvDraft $draftint $currentStep): array
  767.     {
  768.         return [
  769.             'jwt'          => null,
  770.             'public_slug'  => $draft->getPublicSlug(),
  771.             'api_base'     => $this->apiBase,
  772.             'is_public'    => true,
  773.             'user'         => null,
  774.             'candidate'    => null,
  775.             'draft'        => $draft,
  776.             'current_step' => $currentStep,
  777.             'user_data'    => [
  778.                 'first_name' => $draft->getFirstName() ?? '',
  779.                 'last_name'  => $draft->getLastName()  ?? '',
  780.                 'email'      => $draft->getEmail()     ?? '',
  781.             ],
  782.             'mode'         => $draft->getMode() ?? 'scratch',
  783.         ];
  784.     }
  785. }