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

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