src/Controller/SuggestionController.php line 73

Open in your IDE?
  1. <?php
  2. namespace App\Controller;
  3. use App\Entity\Job;
  4. use App\Entity\Network;
  5. use App\Entity\Suggestion;
  6. use App\Form\PhoneType;
  7. use Psr\Log\LoggerInterface;
  8. use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
  9. use Symfony\Component\HttpFoundation\JsonResponse;
  10. use Symfony\Component\HttpFoundation\Request;
  11. use Symfony\Component\HttpFoundation\Response;
  12. use Symfony\Component\HttpKernel\KernelInterface;
  13. use Symfony\Component\Routing\Annotation\Route;
  14. use Symfony\Contracts\HttpClient\HttpClientInterface;
  15. use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
  16. use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface;
  17. use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface;
  18. use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface;
  19. use Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface;
  20. use App\Repository\SuggestionRepository;
  21. use Doctrine\ORM\EntityManagerInterface;
  22. use App\Message\UpdateCaptainDataCookie;
  23. use App\Message\ProcessPendingMessagesJob;
  24. use App\Entity\CaptaindataJob;
  25. use Symfony\Component\Messenger\MessageBusInterface;
  26. use DateTimeImmutable;
  27. use Symfony\Component\Security\Http\Util\TargetPathTrait;
  28. use App\Repository\MessageRepository;
  29. use App\Entity\Message;
  30. use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
  31. use App\Service\GeminiApiService;
  32. class SuggestionController extends AbstractController
  33. {
  34.   use TargetPathTrait;
  35.   private $httpClient;
  36.   private $logger;
  37.   private $geminiApiService;
  38.   private $kernel;
  39.   private $entityManager;
  40.   private $bus;
  41.   private $suggestionLoadingLogger;
  42.   private $params;
  43.   private const AI_TEMPERATURE 0.2;
  44.   public function __construct(
  45.     HttpClientInterface $httpClient,
  46.     LoggerInterface $logger,
  47.     LoggerInterface $suggestionLoadingLogger,
  48.     GeminiApiService $geminiApiService,
  49.     KernelInterface $kernel,
  50.     EntityManagerInterface $entityManager,
  51.     MessageBusInterface $bus,
  52.     ParameterBagInterface $params
  53.   ) {
  54.     $this->httpClient $httpClient;
  55.     $this->logger $logger;
  56.     $this->suggestionLoadingLogger $suggestionLoadingLogger;
  57.     $this->geminiApiService $geminiApiService;
  58.     $this->kernel $kernel;
  59.     $this->entityManager $entityManager;
  60.     $this->bus $bus;
  61.     $this->params $params;
  62.   }
  63.   /**
  64.    * @Route("/suggestions-ia", name="suggestion.index")
  65.    */
  66.   public function index(
  67.     Request $request,
  68.     SuggestionRepository $suggestionRepository,
  69.     MessageRepository $messageRepository
  70.   ): Response {
  71.     /** @var \App\Entity\User|null $user */
  72.     $user $this->getUser();
  73.     // Important : le User stocké dans le token Symfony peut être obsolète (session),
  74.     // alors que la DB est à jour (identity_uid, flags LinkedIn, etc.).
  75.     // On force ici un refresh Doctrine pour garantir que le rendu Twig reflète la DB.
  76.     try {
  77.       if ($user instanceof \App\Entity\User) {
  78.         $this->entityManager->refresh($user);
  79.       }
  80.     } catch (\Exception $e) {
  81.       // Best-effort : ne pas bloquer la page si refresh impossible
  82.     }
  83.     // Sauvegarder le token en session si présent dans l'URL (pour préserver après déconnexion)
  84.     $token $request->query->get('token''');
  85.     if (!empty($token)) {
  86.       $request->getSession()->set('notification_token'$token);
  87.     }
  88.     if (!$user) {
  89.       // Récupérer le token depuis les paramètres de l'URL ou de la session
  90.       if (empty($token)) {
  91.         $token $request->getSession()->get('notification_token''');
  92.       }
  93.       if (!empty($token)) {
  94.         return $this->redirectToRoute('app_login', ['token' => $token]);
  95.       }
  96.       return $this->redirectToRoute('app_login');
  97.     }
  98.     // =========================================================================
  99.     // LOGIQUE DE MISE À JOUR DU COOKIE A ÉTÉ SUPPRIMÉE DE CETTE MÉTHODE
  100.     // Elle est maintenant gérée par ExtensionController::saveCookies
  101.     // =========================================================================
  102.     // --- Préparation des données pour le template ---
  103.     // 1. On vérifie s'il faut afficher l'alerte mobile
  104.     $failedMessage $messageRepository->findOneBy([
  105.       'user' => $user,
  106.       'status' => Message::STATUS_SENT_FAILED
  107.     ]);
  108.     $showMobileReconnectAlert = ($failedMessage !== null);
  109.     // 2. Vérifier si l'utilisateur a une intégration LinkedIn valide
  110.     // L'identity_uid est requis pour avoir une intégration LinkedIn fonctionnelle
  111.     $hasLinkedInIntegration $user->getHasLinkedinIntegration() === true
  112.       && $user->getLinkedinIntegrationStatus() === 'VALID'
  113.       && !empty($user->getIdentityUid());
  114.     // 2.1. Déclencher le traitement des messages en attente si l'intégration est valide
  115.     // OU si l'utilisateur a un identity_uid (l'intégration peut être mise à jour via synchronisation cookie)
  116.     if ($hasLinkedInIntegration || (!empty($user->getIdentityUid()) && $user->getHasLinkedinIntegration())) {
  117.       $this->triggerPendingMessagesIfNeeded($user);
  118.     }
  119.     // 3. On génère l'URL de connexion LinkedIn pour le template
  120.     $linkedinUrl 'https://www.linkedin.com/oauth/v2/authorization?response_type=code&client_id='
  121.       $this->getParameter('app.linkedin_client_id')
  122.       . '&redirect_uri=' $this->getParameter('app.root_url') . '/connect/linkedin&state='
  123.       bin2hex(random_bytes(10))
  124.       . '&scope=openid%20profile%20email';
  125.     // 4. On sauvegarde la destination pour la redirection post-login
  126.     $this->saveTargetPath($request->getSession(), 'main'$this->generateUrl('suggestion.index'));
  127.     // --- Récupération des données des suggestions ---
  128.     if (!method_exists($user'getClient') || !($client $user->getClient())) {
  129.       $this->addFlash('error''Aucun client associé à votre compte.');
  130.       return $this->render('suggestion/index.html.twig', [
  131.         'hasSuggestions' => 0,
  132.         'totalSuggestionsCount' => 0,
  133.         'suggestionsByJob' => [],
  134.         'linkedinUrl' => $linkedinUrl,
  135.         'showMobileReconnectAlert' => $showMobileReconnectAlert,
  136.         'chrome_extension_id' => $_ENV['CHROME_EXTENSION_ID'],
  137.       ]);
  138.     }
  139.     $companyName $client->getName() ?? '[NOM ENTREPRISE]';
  140.     $userId $user->getId();
  141.     $clientId $client->getId();
  142.     $this->suggestionLoadingLogger->info("Index page: User ID {$userId}, Client ID {$clientId}");
  143.     $userSuggestionsCheck $suggestionRepository->hasSuggestions($userId);
  144.     $hasSuggestions count($userSuggestionsCheck) > 0;
  145.     $this->suggestionLoadingLogger->info("Index page: hasSuggestions check returned " . ($hasSuggestions 'true' 'false'));
  146.     $allFilteredSuggestions  $suggestionRepository->findByClientIdExcludingMessagedNetworks($clientId$userId);
  147.     $totalSuggestionsCount count($allFilteredSuggestions);
  148.     $this->suggestionLoadingLogger->info("Index page: findByClientIdExcludingMessagedNetworks found {$totalSuggestionsCount} suggestions.");
  149.     $allPendingSuggestionsCount $suggestionRepository->countAllPendingSuggestions($userId$clientId);
  150.     $this->suggestionLoadingLogger->info("Index page: countAllPendingSuggestions found {$allPendingSuggestionsCount} total pending suggestions.");
  151.     // A-t-il déjà traité au moins une suggestion ? (pour l'état vide)
  152.     $hasEverProcessed $suggestionRepository->hasUserProcessedSuggestions($userId);
  153.     $suggestionsByJob = [];
  154.     foreach ($allFilteredSuggestions as $oneSuggestion) {
  155.       $job $oneSuggestion->getJob();
  156.       if ($job) {
  157.         $jobId $job->getId();
  158.         if (!isset($suggestionsByJob[$jobId])) {
  159.           $suggestionsByJob[$jobId] = ['job' => $job'suggestions' => []];
  160.         }
  161.         $suggestionsByJob[$jobId]['suggestions'][] = $oneSuggestion;
  162.       }
  163.     }
  164.     uasort($suggestionsByJob, function ($a$b) {
  165.       return count($b['suggestions']) <=> count($a['suggestions']);
  166.     });
  167.     $this->suggestionLoadingLogger->info("Index page: suggestionsByJob constructed with " count($suggestionsByJob) . " job(s).", [
  168.       'job_ids' => array_keys($suggestionsByJob),
  169.       'total_suggestions_in_array' => array_sum(array_map(function ($item) {
  170.         return count($item['suggestions']);
  171.       }, $suggestionsByJob))
  172.     ]);
  173.     // --- Données "premier profil" (desktop) ---
  174.     // Objectif : éviter toute dépendance à des variables Twig implicites (strict_variables)
  175.     $firstJob null;
  176.     $firstSuggestion null;
  177.     $firstSuggestionCount 0;
  178.     if (!empty($suggestionsByJob)) {
  179.       $firstJobData reset($suggestionsByJob);
  180.       if (is_array($firstJobData)) {
  181.         $firstJob $firstJobData['job'] ?? null;
  182.         $firstSuggestions $firstJobData['suggestions'] ?? [];
  183.         if (is_array($firstSuggestions) && !empty($firstSuggestions)) {
  184.           $firstSuggestion $firstSuggestions[0];
  185.           $firstSuggestionCount count($firstSuggestions);
  186.         }
  187.       }
  188.     }
  189.     $phoneForm null;
  190.     if (method_exists($user'getPhone') && !$user->getPhone()) {
  191.       $phoneForm $this->createForm(PhoneType::class, $user, [
  192.         'action' => $this->generateUrl('ajax_user_update_phone'),
  193.         'method' => 'POST',
  194.       ]);
  195.     }
  196.     $userHasSeenPreviewModal false;
  197.     if ($user instanceof \App\Entity\User && method_exists($user'isHasSeenLinkedinPreviewModal')) {
  198.       $userHasSeenPreviewModal $user->isHasSeenLinkedinPreviewModal();
  199.     }
  200.     return $this->render('suggestion/index.html.twig', [
  201.       'hasSuggestions' => $hasSuggestions,
  202.       'totalSuggestionsCount' => $totalSuggestionsCount,
  203.       'allPendingSuggestionsCount' => $allPendingSuggestionsCount,
  204.       'hasEverProcessed' => $hasEverProcessed,
  205.       'suggestionsByJob' => $suggestionsByJob,
  206.       'firstJob' => $firstJob,
  207.       'firstSuggestion' => $firstSuggestion,
  208.       'firstSuggestionCount' => $firstSuggestionCount,
  209.       'phoneForm' => $phoneForm $phoneForm->createView() : null,
  210.       'userHasSeenPreviewModal' => $userHasSeenPreviewModal,
  211.       'user_template_formal' => $user->getMessageTemplateFormal(),
  212.       'user_template_informal' => $user->getMessageTemplateInformal(),
  213.       'user_greeting_formal' => $user->getGreetingFormal(),
  214.       'user_greeting_informal' => $user->getGreetingInformal(),
  215.       'company_name' => $companyName,
  216.       'is_tour_hidden' => $user->isIsSuggestionTourHidden(),
  217.       'linkedinUrl' => $linkedinUrl,
  218.       'showMobileReconnectAlert' => $showMobileReconnectAlert// Utilise la variable calculée au début
  219.       'chrome_extension_id' => $_ENV['CHROME_EXTENSION_ID'],
  220.       'hasLinkedInIntegration' => $hasLinkedInIntegration// Nouveau
  221.       'hasExtension' => $user->getHasExtension() === 1// Pour afficher le lien d'installation
  222.       'hasCredentials' => $user->hasValidLinkedinCredentials(), // Pour distinguer le parcours credentials
  223.     ]);
  224.   }
  225.   /**
  226.    * Déclenche le traitement des messages en attente si l'intégration LinkedIn est VALID
  227.    * 
  228.    * @param \App\Entity\User $user Utilisateur
  229.    * @return void
  230.    */
  231.   private function triggerPendingMessagesIfNeeded(\App\Entity\User $user): void
  232.   {
  233.     // Vérifier s'il y a des messages en attente
  234.     $pendingMessages $this->entityManager->getRepository(Message::class)->findBy([
  235.       'user' => $user,
  236.       'status' => Message::STATUS_PENDING,
  237.     ]);
  238.     if (empty($pendingMessages)) {
  239.       $this->logger->debug('SUGGESTION: Aucun message en attente pour cet utilisateur', [
  240.         'user_id' => $user->getId(),
  241.       ]);
  242.       return; // Pas de messages en attente
  243.     }
  244.     // Vérifier que l'intégration est VALID avant de déclencher
  245.     // Si elle est EXPIRED, on attend la synchronisation du cookie
  246.     if ($user->getLinkedinIntegrationStatus() !== 'VALID') {
  247.       $this->logger->info('SUGGESTION: Messages en attente détectés mais intégration non VALID, attente synchronisation cookie', [
  248.         'user_id' => $user->getId(),
  249.         'pending_count' => count($pendingMessages),
  250.         'integration_status' => $user->getLinkedinIntegrationStatus(),
  251.       ]);
  252.       return;
  253.     }
  254.     $this->logger->info('SUGGESTION: Messages en attente détectés, déclenchement du job', [
  255.       'user_id' => $user->getId(),
  256.       'pending_count' => count($pendingMessages),
  257.     ]);
  258.     // Vérifier s'il existe déjà un job en attente
  259.     $existingJob $this->entityManager->getRepository(CaptaindataJob::class)->findOneBy([
  260.       'user' => $user,
  261.       'workflowType' => CaptaindataJob::WORKFLOW_TYPE_SEND_MESSAGES,
  262.       'status' => CaptaindataJob::STATUS_SEND_MESSAGES_PENDING,
  263.     ]);
  264.     if ($existingJob) {
  265.       $this->logger->info('SUGGESTION: Job existant trouvé, re-dispatch', [
  266.         'user_id' => $user->getId(),
  267.         'job_id' => $existingJob->getId(),
  268.       ]);
  269.       $this->bus->dispatch(new ProcessPendingMessagesJob($existingJob->getId()));
  270.       return;
  271.     }
  272.     // Créer un nouveau job
  273.     $workflowUid $this->params->get('app.captaindata_workflow_send_message_uid');
  274.     $captainDataJob = new CaptaindataJob(
  275.       $user,
  276.       CaptaindataJob::WORKFLOW_TYPE_SEND_MESSAGES,
  277.       $workflowUid,
  278.       CaptaindataJob::STATUS_SEND_MESSAGES_PENDING
  279.     );
  280.     $this->entityManager->persist($captainDataJob);
  281.     $this->entityManager->flush();
  282.     $this->bus->dispatch(new ProcessPendingMessagesJob($captainDataJob->getId()));
  283.     $this->logger->info('SUGGESTION: Nouveau job créé et dispatché pour messages en attente', [
  284.       'user_id' => $user->getId(),
  285.       'job_id' => $captainDataJob->getId(),
  286.       'pending_count' => count($pendingMessages),
  287.     ]);
  288.   }
  289.   /**
  290.    * @Route("/ajax/suggestion/update-refusal", name="ajax_suggestion_update_refusal", methods={"POST"})
  291.    */
  292.   public function updateRefusalReason(
  293.     Request $request,
  294.     SuggestionRepository $suggestionRepository,
  295.     EntityManagerInterface $entityManager
  296.   ): JsonResponse {
  297.     if (!$request->isXmlHttpRequest()) {
  298.       return new JsonResponse(['status' => 'error''message' => 'Requête non autorisée.'], Response::HTTP_FORBIDDEN);
  299.     }
  300.     if (!$this->getUser()) {
  301.       return new JsonResponse(['status' => 'error''message' => 'Utilisateur non authentifié.'], Response::HTTP_UNAUTHORIZED);
  302.     }
  303.     $data $request->request->all();
  304.     $suggestionId $data['suggestionId'] ?? null;
  305.     $refusalReason $data['refusalReason'] ?? null;
  306.     if (!$suggestionId || $refusalReason === null || trim($refusalReason) === '') {
  307.       return new JsonResponse(['status' => 'error''message' => 'Données manquantes ou raison vide.'], Response::HTTP_BAD_REQUEST);
  308.     }
  309.     $suggestion $suggestionRepository->find($suggestionId);
  310.     if (!$suggestion) {
  311.       return new JsonResponse(['status' => 'error''message' => 'Suggestion non trouvée.'], Response::HTTP_NOT_FOUND);
  312.     }
  313.     try {
  314.       $suggestion->setRefusal($refusalReason);
  315.       $suggestion->setSendStatus(0);
  316.       $suggestion->setProcessedAt(new \DateTimeImmutable());
  317.       $entityManager->flush();
  318.       return new JsonResponse(['status' => 'success''message' => 'Raison du refus enregistrée.']);
  319.     } catch (\Exception $e) {
  320.       $this->logger->error("Erreur MAJ refus suggestion ID {$suggestionId}: " $e->getMessage());
  321.       return new JsonResponse(['status' => 'error''message' => 'Erreur lors de la mise à jour de la suggestion.'], Response::HTTP_INTERNAL_SERVER_ERROR);
  322.     }
  323.   }
  324.   /**
  325.    * @Route("/api/suggestion/{id}/analyze", name="api_suggestion_analyze", methods={"POST"})
  326.    */
  327.   public function api_suggestion_analyze(Suggestion $suggestion): JsonResponse
  328.   {
  329.     if (!$this->getUser()) {
  330.       return new JsonResponse(['success' => false'message' => 'Utilisateur non authentifié.'], Response::HTTP_UNAUTHORIZED);
  331.     }
  332.     $network $suggestion->getNetwork();
  333.     $job $suggestion->getJob();
  334.     if (!$network || !$job) {
  335.       $this->logger->error("Données Network ou Job manquantes pour l'analyse IA de la suggestion ID: " $suggestion->getId());
  336.       return new JsonResponse(['success' => false'message' => 'Données de la suggestion incomplètes pour l\'analyse.'], Response::HTTP_BAD_REQUEST);
  337.     }
  338.     if ($suggestion->getExtendedComment() !== null) {
  339.       return new JsonResponse([
  340.         'success' => true,
  341.         'analysis' => $suggestion->getExtendedComment(),
  342.         'from_cache' => true
  343.       ]);
  344.     }
  345.     // 1. Lire le template du prompt depuis le fichier
  346.     $promptTemplatePath $this->kernel->getProjectDir() . '/config/prompts/gemini_2.5_flash.txt'// Assurez-vous que ce chemin/nom est correct
  347.     if (!file_exists($promptTemplatePath)) {
  348.       $this->logger->error("Template de prompt non trouvé à: " $promptTemplatePath);
  349.       return new JsonResponse(['success' => false'message' => 'Erreur de configuration interne (template de prompt manquant).'], Response::HTTP_INTERNAL_SERVER_ERROR);
  350.     }
  351.     $promptTemplate file_get_contents($promptTemplatePath);
  352.     // 2. Préparer les valeurs pour les variables
  353.     $candidateFirstName $network->getFirstname() ?? '[Prénom Candidat Manquant]';
  354.     // Utilisation directe du contenu de linkedin_data comme CV
  355.     $fullCVText $this->extractCVText($network); // Cette méthode retourne maintenant le JSON brut ou un placeholder
  356.     $fullJobOfferText $this->extractJobOfferText($job); // Assurez-vous que cette méthode est bien implémentée
  357.     $jobTitleFromOffer $job->getTitle() ?? '[Titre Poste Manquant]';
  358.     // 3. Remplacer les variables dans le template
  359.     $replacements = [
  360.       '$Candidate_FirstName' => $candidateFirstName,
  361.       '$Full_CV_Text_Here' => $fullCVText,
  362.       '$Full_Job_Offer_Text_Here' => $fullJobOfferText,
  363.       '$Job_Title_From_Offer' => $jobTitleFromOffer,
  364.     ];
  365.     $prompt str_replace(array_keys($replacements), array_values($replacements), $promptTemplate);
  366.     $this->logger->info(
  367.       "Prompt préparé pour Gemini pour suggestion ID {$suggestion->getId()} (longueur: " strlen($prompt) . " caractères)."
  368.       // Décommentez la ligne suivante pour logger le prompt complet dans le contexte si besoin pour le débogage
  369.       ,
  370.       ['full_prompt_for_debug' => $prompt]
  371.     );
  372.     // 4. Construire le payload pour Gemini (Google AI Studio)
  373.     $payload = [
  374.       "contents" => [
  375.         ["role" => "user""parts" => [["text" => $prompt]]]
  376.       ],
  377.       "generationConfig" => [
  378.         "temperature"     => self::AI_TEMPERATURE,
  379.         "topK"            => 20,
  380.         "topP"            => 0.95,
  381.         "maxOutputTokens" => 1000,
  382.       ]
  383.     ];
  384.     $this->logger->info("Appel API Gemini (Google AI Studio) pour suggestion ID {$suggestion->getId()}");
  385.     $decodedResponse $this->geminiApiService->generateContent('gemini-2.5-pro'$payload);
  386.     if ($decodedResponse === null) {
  387.       $this->logger->error("Échec de l'appel Gemini API pour suggestion ID {$suggestion->getId()}");
  388.       return new JsonResponse([
  389.         'success' => false,
  390.         'isFriendlyError' => true,
  391.         'friendlyError' => [
  392.           'title' => 'Petite mise au point en cours !',
  393.           'message' => 'Nous affûtons nos algorithmes pour rendre nos suggestions encore plus pertinentes. Le service sera de retour dans quelques instants, plus performant que jamais.'
  394.         ]
  395.       ], Response::HTTP_SERVICE_UNAVAILABLE);
  396.     }
  397.     $this->logger->debug("Réponse brute Gemini (Google AI) pour suggestion ID {$suggestion->getId()}: "$decodedResponse);
  398.     $analysisHtml null;
  399.     if (isset($decodedResponse['candidates'][0]['content']['parts'][0]['text'])) {
  400.       $analysisText $decodedResponse['candidates'][0]['content']['parts'][0]['text'];
  401.       // --- DÉBUT DE LA MODIFICATION ---
  402.       // Nettoyage initial du texte reçu de l'IA
  403.       $analysisText preg_replace('/^```(html)?\s*?\n?/im'''$analysisText);
  404.       $analysisText preg_replace('/\n?```\s*?$/im'''$analysisText);
  405.       $analysisText trim($analysisText);
  406.       // Transformation en HTML avec le bon ordre de priorité
  407.       $lines preg_split('/(\r\n|\n|\r)+/'$analysisText);
  408.       $htmlOutput '';
  409.       $inList false// Pour savoir si nous sommes en train de construire une liste <ul>
  410.       $regexTitle '/^\s*(?:\*\s*)?\*\*(.*?)\*\*\s*(.*)$/';
  411.       foreach ($lines as $line) {
  412.         $trimmedLine trim($line);
  413.         if (empty($trimmedLine)) {
  414.           continue;
  415.         }
  416.         // CAS 1 (LE PLUS SPÉCIFIQUE) : La ligne est un "Titre : Paragraphe"
  417.         if (preg_match($regexTitle$trimmedLine$matches)) {
  418.           // Un titre signale la fin de toute liste précédente.
  419.           if ($inList) {
  420.             $htmlOutput .= '</ul>';
  421.             $inList false;
  422.           }
  423.           $titleText trim($matches[1]);
  424.           $paragraphText trim($matches[2]);
  425.           $combinedHtml '<strong>' htmlspecialchars($titleText) . '</strong>';
  426.           if (!empty($paragraphText)) {
  427.             $combinedHtml .= '<br>' htmlspecialchars($paragraphText);
  428.           }
  429.           $htmlOutput .= '<p>' $combinedHtml '</p>';
  430.           // CAS 2 : La ligne est un élément de liste simple
  431.         } elseif (strpos($trimmedLine'* ') === 0) {
  432.           if (!$inList) {
  433.             // Si ce n'est pas déjà fait, on ouvre la balise <ul>
  434.             $htmlOutput .= '<ul>';
  435.             $inList true;
  436.           }
  437.           // On retire le "* " du début et on crée le <li>
  438.           $listItemText substr($trimmedLine2);
  439.           $htmlOutput .= '<li>' htmlspecialchars(trim($listItemText)) . '</li>';
  440.           // CAS 3 : La ligne est un paragraphe simple
  441.         } else {
  442.           // Un paragraphe simple signale aussi la fin de toute liste.
  443.           if ($inList) {
  444.             $htmlOutput .= '</ul>';
  445.             $inList false;
  446.           }
  447.           $htmlOutput .= '<p>' htmlspecialchars($trimmedLine) . '</p>';
  448.         }
  449.       }
  450.       // Après la boucle, on s'assure de fermer la liste si le texte se terminait par un <li>
  451.       if ($inList) {
  452.         $htmlOutput .= '</ul>';
  453.       }
  454.       $analysisHtml $htmlOutput;
  455.     } else {
  456.       $this->logger->warning("Réponse de Gemini inattendue pour suggestion ID {$suggestion->getId()}: "$decodedResponse);
  457.       return new JsonResponse(['success' => false'message' => 'Réponse de l\'IA inattendue ou vide.'], Response::HTTP_OK);
  458.     }
  459.     if (empty(trim(strip_tags((string)$analysisHtml)))) { // Vérifier si l'analyse n'est pas vide après suppression des tags HTML
  460.       $this->logger->info("Analyse IA vide après nettoyage pour suggestion ID {$suggestion->getId()}");
  461.       return new JsonResponse(['success' => true'analysis' => '<p class="text-muted"><em>L\'IA n\'a pas fourni d\'analyse pour ce profil.</em></p>'], Response::HTTP_OK);
  462.     }
  463.     // Enregistrer l'analyse directement sur l'entité Suggestion
  464.     $suggestion->setExtendedComment($analysisHtml);
  465.     $suggestion->setExtendedCommentCreatedAt(new \DateTimeImmutable());
  466.     $this->entityManager->flush();
  467.     return new JsonResponse(['success' => true'analysis' => $analysisHtml]);
  468.   }
  469.   /**
  470.    * Méthode pour extraire le contenu du CV pour le prompt.
  471.    * Retourne directement le contenu de linkedin_data.
  472.    */
  473.   private function extractCVText(Network $network): string
  474.   {
  475.     $linkedinDataJson $network->getLinkedinData();
  476.     if (empty($linkedinDataJson)) {
  477.       $this->logger->warning("Le champ linkedin_data est vide pour Network ID: " $network->getId() . ". Envoi d'un placeholder pour le CV.");
  478.       return "[Aucune donnée détaillée de CV disponible pour ce profil.]";
  479.     }
  480.     return $linkedinDataJson;
  481.   }
  482.   /**
  483.    * Méthode pour extraire/formater le texte de l'offre d'emploi pour le prompt.
  484.    */
  485.   private function extractJobOfferText(Job $job): string
  486.   {
  487.     $description $job->getClient()->getDescription();
  488.     $offerText "Titre du poste: {$job->getTitle()}. ";
  489.     if ($description) {
  490.       $offerText .= "Description de l'entreprise: " strip_tags($description) . ". ";
  491.     }
  492.     if (method_exists($job'getProfile') && ($profileText $job->getProfile())) {
  493.       $offerText .= "Profil recherché: " strip_tags($profileText) . ". ";
  494.     }
  495.     if (method_exists($job'getMission') && ($missionText $job->getMission())) {
  496.       $offerText .= "Missions: " strip_tags($missionText) . ". ";
  497.     }
  498.     // Ajoutez d'autres champs pertinents ici si nécessaire
  499.     return empty(trim($offerText)) ? "[Description de l'offre non disponible ou vide]" $offerText;
  500.   }
  501.   /**
  502.    * @Route("/ajax/user/update-phone", name="ajax_user_update_phone", methods={"POST"})
  503.    */
  504.   public function updateUserPhone(Request $requestEntityManagerInterface $entityManagerSuggestionRepository $suggestionRepository): Response
  505.   {
  506.     $isAjax $request->isXmlHttpRequest();
  507.     /** @var \App\Entity\User|null $user */
  508.     $user $this->getUser();
  509.     if (!$user) {
  510.       return new JsonResponse(['success' => false'message' => 'Utilisateur non authentifié.'], Response::HTTP_UNAUTHORIZED);
  511.     }
  512.     $form $this->createForm(PhoneType::class, $user);
  513.     $form->handleRequest($request);
  514.     if ($form->isSubmitted() && $form->isValid()) {
  515.       try {
  516.         // Mettre à jour l'étape d'onboarding
  517.         $user->setOnboardingStep('completed');
  518.         $entityManager->flush(); // User a été mis à jour par le form->handleRequest
  519.         // Après enregistrement, vérifier si des suggestions sont déjà disponibles pour cet utilisateur
  520.         $userId $user->getId();
  521.         $userSuggestionsCheck $suggestionRepository->hasSuggestions($userId);
  522.         $hasSuggestionsNow count($userSuggestionsCheck) > 0;
  523.         // Préparer la réponse selon le type de requête (AJAX vs form classique)
  524.         if ($isAjax) {
  525.           $response = [
  526.             'success' => true,
  527.             'message' => 'Numéro de téléphone enregistré.',
  528.             'hasSuggestions' => $hasSuggestionsNow,
  529.             'redirect_url' => $this->generateUrl('suggestion.index')
  530.           ];
  531.           return new JsonResponse($response);
  532.         }
  533.         // Requête non-AJAX (submit classique) : rediriger vers la page des suggestions
  534.         $this->addFlash('success''Numéro de téléphone enregistré.');
  535.         return $this->redirectToRoute('suggestion.index');
  536.       } catch (\Exception $e) {
  537.         $this->logger->error("Erreur MAJ téléphone utilisateur ID {$user->getId()}: " $e->getMessage());
  538.         if ($isAjax) {
  539.           return new JsonResponse(['success' => false'message' => 'Erreur lors de l\'enregistrement du numéro.'], Response::HTTP_INTERNAL_SERVER_ERROR);
  540.         }
  541.         $this->addFlash('error''Erreur lors de l\'enregistrement du numéro.');
  542.         return $this->redirectToRoute('suggestion.index');
  543.       }
  544.     }
  545.     // Récupérer les erreurs du formulaire pour les renvoyer si nécessaire
  546.     $errors = [];
  547.     foreach ($form->getErrors(true) as $error) {
  548.       $errors[] = $error->getMessage();
  549.     }
  550.     // Pour les erreurs sur des champs spécifiques
  551.     foreach ($form as $child) {
  552.       if (!$child->isValid()) {
  553.         foreach ($child->getErrors(true) as $error) {
  554.           $errors[$child->getName()][] = $error->getMessage();
  555.         }
  556.       }
  557.     }
  558.     if ($isAjax) {
  559.       return new JsonResponse([
  560.         'success' => false,
  561.         'message' => 'Données invalides.',
  562.         'errors' => $errors
  563.       ], Response::HTTP_BAD_REQUEST);
  564.     }
  565.     // Requête non-AJAX : afficher un message et rediriger
  566.     $this->addFlash('error'implode(', 'array_values($errors) ?: ['Données invalides.']));
  567.     return $this->redirectToRoute('suggestion.index');
  568.   }
  569.   /**
  570.    * @Route("/ajax/user/mark-linkedin-preview-modal-shown", name="ajax_user_mark_linkedin_preview_modal_shown", methods={"POST"})
  571.    */
  572.   public function markLinkedInPreviewModalAsShown(Request $requestEntityManagerInterface $entityManager): JsonResponse
  573.   {
  574.     if (!$request->isXmlHttpRequest()) {
  575.       return new JsonResponse(['status' => 'error''message' => 'Requête non autorisée.'], Response::HTTP_FORBIDDEN);
  576.     }
  577.     /** @var \App\Entity\User|null $user */
  578.     $user $this->getUser();
  579.     // Assurez-vous que $user est bien une instance de votre entité User
  580.     if (!$user instanceof \App\Entity\User) {
  581.       return new JsonResponse(['status' => 'error''message' => 'Utilisateur non authentifié ou type incorrect.'], Response::HTTP_UNAUTHORIZED);
  582.     }
  583.     // Vérifiez que la méthode existe pour éviter les erreurs si l'entité n'est pas à jour
  584.     if (method_exists($user'setHasSeenLinkedinPreviewModal') && method_exists($user'isHasSeenLinkedinPreviewModal')) {
  585.       if (!$user->isHasSeenLinkedinPreviewModal()) {
  586.         $user->setHasSeenLinkedinPreviewModal(true);
  587.         try {
  588.           $entityManager->flush();
  589.           return new JsonResponse(['status' => 'success''message' => 'Préférence enregistrée.']);
  590.         } catch (\Exception $e) {
  591.           $this->logger->error("Erreur MAJ isHasSeenLinkedinPreviewModal pour User ID {$user->getId()}: " $e->getMessage(), ['exception' => $e]);
  592.           return new JsonResponse(['status' => 'error''message' => 'Erreur serveur lors de la sauvegarde.'], Response::HTTP_INTERNAL_SERVER_ERROR);
  593.         }
  594.       }
  595.       return new JsonResponse(['status' => 'info''message' => 'Préférence déjà enregistrée.']);
  596.     } else {
  597.       $this->logger->error("Méthode setHasSeenLinkedinPreviewModal ou isHasSeenLinkedinPreviewModal non trouvée pour User ID {$user->getId()}");
  598.       return new JsonResponse(['status' => 'error''message' => 'Erreur de configuration du modèle utilisateur.'], Response::HTTP_INTERNAL_SERVER_ERROR);
  599.     }
  600.   }
  601.   /**
  602.    * @Route("/ajax/user/hide-suggestion-tour", name="ajax_user_hide_suggestion_tour", methods={"POST"})
  603.    */
  604.   public function hideSuggestionTour(Request $requestEntityManagerInterface $entityManager): JsonResponse
  605.   {
  606.     if (!$request->isXmlHttpRequest()) {
  607.       return new JsonResponse(['status' => 'error''message' => 'Requête non autorisée.'], Response::HTTP_FORBIDDEN);
  608.     }
  609.     /** @var \App\Entity\User|null $user */
  610.     $user $this->getUser();
  611.     if (!$user) {
  612.       return new JsonResponse(['status' => 'error''message' => 'Utilisateur non authentifié.'], Response::HTTP_UNAUTHORIZED);
  613.     }
  614.     try {
  615.       $user->setIsSuggestionTourHidden(true);
  616.       $entityManager->flush();
  617.       return new JsonResponse(['status' => 'success']);
  618.     } catch (\Exception $e) {
  619.       $this->logger->error("Erreur MAJ isSuggestionTourHidden pour User ID {$user->getId()}: " $e->getMessage());
  620.       return new JsonResponse(['status' => 'error''message' => 'Erreur serveur.'], Response::HTTP_INTERNAL_SERVER_ERROR);
  621.     }
  622.   }
  623.   /**
  624.    * @Route("/ajax/suggestion/load-next", name="ajax_suggestion_load_next", methods={"POST"})
  625.    */
  626.   public function loadNextSuggestion(SuggestionRepository $suggestionRepositoryEntityManagerInterface $entityManager): JsonResponse
  627.   {
  628.     /** @var \App\Entity\User|null $user */
  629.     $user $this->getUser();
  630.     if (!$user || !$user->getClient()) {
  631.       $this->suggestionLoadingLogger->warning('Tentative de chargement de suggestion par un utilisateur non authentifié ou sans client.');
  632.       return new JsonResponse(['status' => 'error''message' => 'Utilisateur non autorisé.'], Response::HTTP_UNAUTHORIZED);
  633.     }
  634.     $userId $user->getId();
  635.     $clientId $user->getClient()->getId();
  636.     $this->suggestionLoadingLogger->info("Recherche des prochaines suggestions pour l'utilisateur ID: {$userId}, Client ID: {$clientId}");
  637.     $nextSuggestions $suggestionRepository->findNextHiddenSuggestions($userId$clientId);
  638.     if (empty($nextSuggestions)) {
  639.       $this->suggestionLoadingLogger->info("Aucune suggestion cachée trouvée pour l'utilisateur ID: {$userId}");
  640.       return new JsonResponse(['status' => 'done''message' => 'Aucune autre suggestion disponible.']);
  641.     }
  642.     $suggestionsData = [];
  643.     foreach ($nextSuggestions as $nextSuggestion) {
  644.       $suggestionId $nextSuggestion->getId();
  645.       $this->suggestionLoadingLogger->info("Suggestion ID: {$suggestionId} trouvée. Passage de is_hidden à false.");
  646.       $nextSuggestion->setIsHidden(false);
  647.       $job $nextSuggestion->getJob();
  648.       $network $nextSuggestion->getNetwork();
  649.       // Préparer les données pour cette suggestion
  650.       $suggestionsData[] = [
  651.         'id' => $nextSuggestion->getId(),
  652.         'comment' => $nextSuggestion->getComment(),
  653.         'network' => [
  654.           'id' => $network->getId(),
  655.           'firstname' => $network->getFirstname(),
  656.           'lastname' => $network->getLastname(),
  657.           'photo' => $network->getPhoto(),
  658.           'linkedin' => $network->getLinkedin(),
  659.           'job' => $network->getJob(),
  660.         ],
  661.         'job' => [
  662.           'id' => $job->getId(),
  663.           'title' => $job->getTitle(),
  664.         ]
  665.       ];
  666.     }
  667.     $entityManager->flush();
  668.     $this->suggestionLoadingLogger->info(count($suggestionsData) . " suggestion(s) mise(s) à jour avec succès. Préparation de la réponse JSON.");
  669.     return new JsonResponse(['status' => 'success''suggestions' => $suggestionsData]);
  670.   }
  671. }