<?php
declare(strict_types=1);
namespace App\Controller;
use App\Entity\CaptaindataJob;
use App\Entity\Client;
use App\Entity\User;
use App\Exception\CaptainDataApiException;
use App\Form\PhoneType;
use App\Repository\ClientRepository;
use App\Repository\UserRepository;
use App\Service\AdminAlertService;
use App\Service\CaptainDataV4ApiService;
use App\Service\EncryptionService;
use App\Service\ReconnectionService;
use App\Service\UserLoggerFactory;
use App\Message\ProcessPendingMessagesJob;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Messenger\MessageBusInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
/**
* Contrôleur pour gérer l'onboarding avec CaptainData v4
*/
class OnboardingController extends AbstractController
{
/**
* @var CaptainDataV4ApiService
*/
private $captainDataV4Service;
/**
* @var UserRepository
*/
private $userRepository;
/**
* @var EntityManagerInterface
*/
private $entityManager;
/**
* @var LoggerInterface
*/
private $logger;
/**
* @var TokenStorageInterface
*/
private $tokenStorage;
/**
* @var ClientRepository
*/
private $clientRepository;
/**
* @var ParameterBagInterface
*/
private $params;
/**
* @var UserPasswordHasherInterface
*/
private $userPasswordHasher;
/**
* @var UserLoggerFactory
*/
private $userLoggerFactory;
/**
* @var EncryptionService
*/
private $encryptionService;
/**
* @var ReconnectionService
*/
private $reconnectionService;
/**
* @var MessageBusInterface
*/
private $bus;
/**
* @var AdminAlertService
*/
private $adminAlertService;
/**
* @param CaptainDataV4ApiService $captainDataV4Service Service CaptainData v4
* @param UserRepository $userRepository Repository des utilisateurs
* @param EntityManagerInterface $entityManager Entity Manager Doctrine
* @param LoggerInterface $logger Logger dédié
* @param TokenStorageInterface $tokenStorage Token storage pour authentification
* @param ClientRepository $clientRepository Repository des clients
* @param ParameterBagInterface $params Paramètres de configuration
* @param UserPasswordHasherInterface $userPasswordHasher Hasher de mot de passe Symfony
* @param UserLoggerFactory $userLoggerFactory Factory pour logger dans user_activity
* @param EncryptionService $encryptionService Service de chiffrement pour les identifiants LinkedIn
* @param ReconnectionService $reconnectionService Service de reconnexion silencieuse
* @param MessageBusInterface $bus Message bus pour déclencher les jobs
* @param AdminAlertService $adminAlertService Service d'alerte admin (erreurs CaptainData)
*/
public function __construct(
CaptainDataV4ApiService $captainDataV4Service,
UserRepository $userRepository,
EntityManagerInterface $entityManager,
LoggerInterface $logger,
TokenStorageInterface $tokenStorage,
ClientRepository $clientRepository,
ParameterBagInterface $params,
UserPasswordHasherInterface $userPasswordHasher,
UserLoggerFactory $userLoggerFactory,
EncryptionService $encryptionService,
ReconnectionService $reconnectionService,
MessageBusInterface $bus,
AdminAlertService $adminAlertService
) {
$this->captainDataV4Service = $captainDataV4Service;
$this->userRepository = $userRepository;
$this->entityManager = $entityManager;
$this->logger = $logger;
$this->tokenStorage = $tokenStorage;
$this->clientRepository = $clientRepository;
$this->params = $params;
$this->userPasswordHasher = $userPasswordHasher;
$this->userLoggerFactory = $userLoggerFactory;
$this->encryptionService = $encryptionService;
$this->reconnectionService = $reconnectionService;
$this->bus = $bus;
$this->adminAlertService = $adminAlertService;
}
/**
* Sauvegarde le timezone de l'utilisateur en session
*
* @Route("/api/onboarding/save-timezone", name="api.onboarding.save_timezone", methods={"POST"})
*
* @param Request $request Requête HTTP
* @param SessionInterface $session Session Symfony
* @return JsonResponse Réponse JSON
*/
public function saveTimezone(Request $request, SessionInterface $session): JsonResponse
{
try {
$data = json_decode($request->getContent(), true);
if (!isset($data['timezone']) || empty($data['timezone'])) {
return new JsonResponse([
'success' => false,
'error' => 'Timezone requis',
], 400);
}
$timezone = trim($data['timezone']);
// Valider que le timezone est valide (format IANA)
try {
new \DateTimeZone($timezone);
} catch (\Exception $e) {
return new JsonResponse([
'success' => false,
'error' => 'Format de timezone invalide',
], 400);
}
// Sauvegarder en session
$session->set('user_timezone', $timezone);
$this->logger->info('ONBOARDING_V4: Timezone sauvegardé en session', [
'action' => 'save_timezone',
'timezone' => $timezone,
]);
return new JsonResponse([
'success' => true,
'timezone' => $timezone,
]);
} catch (\Exception $e) {
$this->logger->error('ONBOARDING_V4: Erreur lors de la sauvegarde du timezone', [
'action' => 'save_timezone',
'error' => $e->getMessage(),
]);
return new JsonResponse([
'success' => false,
'error' => 'Erreur lors de la sauvegarde du timezone',
], 500);
}
}
/**
* Connecter un compte LinkedIn en utilisant email/password
* Flux complet : Création Identity + Integration + Extraction profil + Création/MAJ utilisateur Symfony + Authentification
*
* @Route("/api/onboarding/connect-linkedin", name="api.onboarding.connect_linkedin", methods={"POST"})
*
* @param Request $request Requête HTTP
* @param SessionInterface $session Session Symfony
* @return JsonResponse Réponse JSON
*/
public function connectLinkedIn(Request $request, SessionInterface $session): JsonResponse
{
$this->logger->info('ONBOARDING_V4: Début de la connexion LinkedIn', [
'action' => 'connect_linkedin',
]);
$createdIdentityUid = null;
try {
// 1. Récupérer les données du formulaire
$data = json_decode($request->getContent(), true);
$data = is_array($data) ? $data : [];
$isResubmit = isset($data['resubmit']) && $data['resubmit'] === true;
if ($isResubmit) {
// Re-soumission avec identifiants en session (pop-in "J'ai ouvert l'application" / "J'ai installé l'application")
$identityUid = $session->get('checkpoint_identity_uid');
$linkedinEmail = $session->get('checkpoint_linkedin_email');
$linkedinPassword = $session->get('checkpoint_linkedin_password_resubmit');
$saveCredentials = (bool) $session->get('checkpoint_save_credentials', false);
if (empty($identityUid) || empty($linkedinEmail) || empty($linkedinPassword)) {
$this->logger->warning('ONBOARDING_V4: Resubmit sans credentials en session', [
'action' => 'connect_linkedin',
'has_identity_uid' => !empty($identityUid),
'has_email' => !empty($linkedinEmail),
'has_password_resubmit' => !empty($linkedinPassword),
]);
return new JsonResponse([
'error' => 'Session expirée ou identifiants indisponibles. Veuillez vous reconnecter avec vos identifiants LinkedIn.',
], 400);
}
$linkedinEmail = trim($linkedinEmail);
$this->logger->info('ONBOARDING_V4: Re-soumission connexion LinkedIn avec credentials session', [
'action' => 'connect_linkedin',
'identity_uid' => $identityUid,
]);
} else {
if (!isset($data['linkedin_email']) || !isset($data['linkedin_password'])) {
return new JsonResponse([
'error' => 'Email et mot de passe requis',
], 400);
}
$linkedinEmail = trim($data['linkedin_email']);
$linkedinPassword = $data['linkedin_password'];
$saveCredentials = isset($data['save_credentials']) && $data['save_credentials'] === true;
$identityUid = null;
}
// Log de debug pour tracer la réception des données
$this->logger->debug('ONBOARDING_V4: Données reçues pour connexion LinkedIn', [
'action' => 'connect_linkedin',
'has_linkedin_email' => !empty($linkedinEmail),
'has_linkedin_password' => !empty($linkedinPassword),
'save_credentials_raw' => $data['save_credentials'] ?? null,
'save_credentials_bool' => $saveCredentials,
'data_keys' => array_keys($data),
]);
// Récupérer le timezone depuis la session ou utiliser la valeur par défaut
$timezone = $session->get('user_timezone', 'Europe/Paris');
// Valider le timezone
try {
new \DateTimeZone($timezone);
} catch (\Exception $e) {
$timezone = 'Europe/Paris'; // Fallback si invalide
}
// Validation basique
if (empty($linkedinEmail) || empty($linkedinPassword)) {
return new JsonResponse([
'error' => 'Email et mot de passe ne peuvent pas être vides',
], 400);
}
if (!filter_var($linkedinEmail, FILTER_VALIDATE_EMAIL)) {
return new JsonResponse([
'error' => 'Format d\'email invalide',
], 400);
}
// 2. Récupérer le client_id et le rôle depuis la session ou depuis l'utilisateur connecté
$clientId = $session->get('client_id');
$role = $session->get('role', ['ROLE_COOPTOR']);
$origin = $session->get('origin');
// Si pas de client_id en session mais utilisateur connecté, récupérer depuis l'utilisateur
if (!$clientId && $this->getUser()) {
$currentUser = $this->getUser();
if ($currentUser->getClient()) {
$clientId = $currentUser->getClient()->getId();
$role = $currentUser->getRoles();
$this->logger->info('ONBOARDING_V4: client_id récupéré depuis l\'utilisateur connecté', [
'action' => 'connect_linkedin',
'user_id' => $currentUser->getId(),
'client_id' => $clientId,
]);
}
}
if (!$clientId) {
$this->logger->error('ONBOARDING_V4: Pas de client_id en session et utilisateur non connecté', [
'action' => 'connect_linkedin',
]);
return new JsonResponse([
'error_code' => 'SESSION_EXPIRED',
'error' => 'Votre session a expiré. Veuillez vous reconnecter.',
], 401);
}
/** @var Client|null $client */
$client = $this->clientRepository->find($clientId);
if (!$client) {
return new JsonResponse([
'error' => 'Client introuvable',
], 404);
}
// 2.1. Vérifier si un utilisateur existe déjà avec cet email
// MODIFICATION : Ne plus vérifier le mot de passe Bambboo, on crée directement l'intégration LinkedIn
$existingUser = $this->userRepository->findOneBy(['email' => $linkedinEmail]);
if ($existingUser) {
$this->logger->info('ONBOARDING_V4: Utilisateur existant trouvé, connexion LinkedIn directe', [
'action' => 'connect_linkedin',
'user_id' => $existingUser->getId(),
'email' => $linkedinEmail,
]);
// Vérifier si l'utilisateur a déjà une intégration LinkedIn valide
if (
$existingUser->getHasLinkedinIntegration() &&
$existingUser->getLinkedinIntegrationStatus() === 'VALID' &&
$existingUser->getIdentityUid()
) {
$this->logger->info('ONBOARDING_V4: Utilisateur a déjà une intégration LinkedIn valide', [
'action' => 'connect_linkedin',
'user_id' => $existingUser->getId(),
'identity_uid' => $existingUser->getIdentityUid(),
'integration_status' => $existingUser->getLinkedinIntegrationStatus(),
]);
// Retourner le succès sans redirection
$session->remove('checkpoint_linkedin_password_resubmit');
return new JsonResponse([
'success' => true,
'existing_user' => true,
'message' => 'Connexion LinkedIn réussie !',
'redirect_url' => null,
'user_id' => $existingUser->getId(),
], 200);
}
// L'utilisateur existe mais n'a pas d'intégration LinkedIn valide
// On continue pour créer l'intégration LinkedIn
$this->logger->info('ONBOARDING_V4: Utilisateur existant sans intégration LinkedIn, création en cours', [
'action' => 'connect_linkedin',
'user_id' => $existingUser->getId(),
]);
}
// 3. Déterminer l'Identity à utiliser (réutiliser si possible) — ignoré en resubmit (identity_uid en session)
if (!$isResubmit) {
$currentUser = $this->getUser();
$identityUid = null;
if ($currentUser instanceof User && $currentUser->getIdentityUid()) {
$identityUid = $currentUser->getIdentityUid();
$this->logger->info('ONBOARDING_V4: Réutilisation identity_uid utilisateur connecté', [
'action' => 'connect_linkedin',
'user_id' => $currentUser->getId(),
'identity_uid' => $identityUid,
]);
} elseif ($existingUser instanceof User && $existingUser->getIdentityUid()) {
$identityUid = $existingUser->getIdentityUid();
$this->logger->info('ONBOARDING_V4: Réutilisation identity_uid utilisateur existant', [
'action' => 'connect_linkedin',
'user_id' => $existingUser->getId(),
'identity_uid' => $identityUid,
]);
} else {
// Pré-check : éviter de recréer une Identity si une Identity "pré-onboarding" existe déjà
// (cas classique : erreur d'intégration LinkedIn / fermeture navigateur -> identity_uid non récupéré côté UI)
try {
$existingIdentityUid = $this->captainDataV4Service->findIdentityUidByExactName($linkedinEmail);
if ($existingIdentityUid) {
$identityUid = $existingIdentityUid;
$this->logger->info('ONBOARDING_V4: Réutilisation d\'une Identity existante trouvée par email', [
'action' => 'connect_linkedin',
'linkedin_email' => $linkedinEmail,
'identity_uid' => $identityUid,
]);
}
} catch (\Exception $e) {
// Fail-open : en cas d'erreur de recherche, conserver le comportement actuel
$this->logger->warning('ONBOARDING_V4: Impossible de rechercher une Identity existante par email, création d\'une nouvelle Identity', [
'action' => 'connect_linkedin',
'linkedin_email' => $linkedinEmail,
'error' => $e->getMessage(),
]);
}
// Si aucune Identity n'a été trouvée, on conserve le comportement actuel : création d'une nouvelle Identity
if (!$identityUid) {
$this->logger->info('ONBOARDING_V4: Création d\'une nouvelle Identity (aucune identity_uid existante)', [
'action' => 'connect_linkedin',
'linkedin_email' => $linkedinEmail,
'timezone' => $timezone,
]);
$tempExternalId = 'temp_' . uniqid();
$identityData = $this->captainDataV4Service->createIdentity(
$tempExternalId,
$linkedinEmail,
$timezone
);
$identityUid = $identityData['uid'] ?? null;
if (!$identityUid) {
throw new \RuntimeException('Identity créée mais identity_uid non reçu dans la réponse');
}
$createdIdentityUid = $identityUid;
$this->logger->info('ONBOARDING_V4: Identity créée avec succès', [
'action' => 'connect_linkedin',
'identity_uid' => $identityUid,
]);
}
}
}
// 4. Créer l'intégration LinkedIn avec les identifiants
// Petit délai pour s'assurer que l'Identity est bien propagée dans l'API CaptainData
sleep(1);
$this->logger->info('ONBOARDING_V4: Création de l\'intégration LinkedIn', [
'action' => 'connect_linkedin',
'identity_uid' => $identityUid,
]);
try {
$integrationData = $this->captainDataV4Service->createLinkedInIntegrationWithCredentials(
$identityUid,
$linkedinEmail,
$linkedinPassword,
null
);
} catch (\Exception $e) {
// Cas observé en local : timeout "Idle timeout reached" alors que l'intégration peut avoir été créée côté Edges.
// Dans ce cas, on tente un "read-after-write" : récupérer le statut de l'intégration et renvoyer le checkpoint
// au lieu de renvoyer un 500.
if (stripos($e->getMessage(), 'Idle timeout reached') !== false) {
$createIntegrationException = $e;
$this->logger->warning('ONBOARDING_V4: Timeout lors de la création de l\'intégration LinkedIn, tentative de récupération du statut', [
'action' => 'connect_linkedin',
'identity_uid' => $identityUid,
'error' => $e->getMessage(),
]);
// Petit délai pour laisser Edges finaliser la création (best-effort)
usleep(500000);
try {
$integrationData = $this->captainDataV4Service->getLinkedInIntegrationStatus($identityUid);
$this->logger->info('ONBOARDING_V4: Statut intégration LinkedIn récupéré après timeout', [
'action' => 'connect_linkedin',
'identity_uid' => $identityUid,
'status' => $integrationData['status'] ?? null,
'integration_uid' => $integrationData['uid'] ?? null,
'checkpoint' => $integrationData['checkpoint'] ?? null,
]);
// Si Edges répond "404 intégration inexistante", notre service renvoie exists=false.
// Dans ce cas, on ne doit pas masquer le timeout par un faux "INVALID_CREDENTIALS".
if (isset($integrationData['exists']) && $integrationData['exists'] === false) {
throw $createIntegrationException;
}
} catch (\Exception $statusException) {
// Si la récupération échoue, on remonte l'exception initiale (comportement actuel)
throw $createIntegrationException;
}
} else {
throw $e;
}
}
// 5. Analyser la réponse de l'API
$status = $integrationData['status'] ?? 'UNKNOWN';
$checkpoint = $integrationData['checkpoint'] ?? null;
$this->logger->info('ONBOARDING_V4: Intégration LinkedIn créée', [
'action' => 'connect_linkedin',
'identity_uid' => $identityUid,
'integration_uid' => $integrationData['uid'] ?? null,
'status' => $status,
'checkpoint' => $checkpoint,
]);
// 6. Gérer les statuts non-VALID
if ($status === 'PENDING' && $checkpoint !== null) {
// Checkpoint requis (2FA, Email, Phone, etc.)
$checkpointType = $checkpoint['type'] ?? 'UNKNOWN';
$integrationUid = $integrationData['uid'] ?? null;
$this->logger->warning('ONBOARDING_V4: Checkpoint LinkedIn détecté', [
'action' => 'connect_linkedin',
'checkpoint_type' => $checkpointType,
'identity_uid' => $identityUid,
]);
// Logger aussi dans user_activity (si on a un user identifié) pour faciliter le diagnostic par user
// Important : ne jamais logger le mot de passe / le code, uniquement des métadonnées de checkpoint.
$checkpointPhone = null;
if ($checkpointType === 'PHONE_REGISTER') {
$checkpointPhone = $checkpoint['phone'] ?? $checkpoint['phone_number'] ?? $checkpoint['masked_phone'] ?? $checkpoint['destination'] ?? null;
}
$userIdToLog = null;
$currentUserForLog = $this->getUser();
if ($currentUserForLog instanceof User) {
$userIdToLog = $currentUserForLog->getId();
} elseif ($existingUser instanceof User) {
$userIdToLog = $existingUser->getId();
}
if (is_int($userIdToLog)) {
// Stocker le user_id en session pour pouvoir logger aussi lors de resolve-checkpoint
// sans dépendre de la base de données (important en test / en cas de DB indisponible).
$session->set('checkpoint_user_id', $userIdToLog);
try {
$userLogger = $this->userLoggerFactory->createLogger($userIdToLog);
$userLogger->warning('ONBOARDING_V4: Checkpoint LinkedIn détecté', [
'action' => 'connect_linkedin',
'step' => 'checkpoint_detected',
'user_id' => $userIdToLog,
'identity_uid' => $identityUid,
'integration_uid' => $integrationUid,
'checkpoint_type' => $checkpointType,
'checkpoint_phone' => $checkpointPhone,
]);
} catch (\Exception $logException) {
$this->logger->warning('ONBOARDING_V4: Impossible de logger le checkpoint dans user_activity', [
'action' => 'connect_linkedin',
'user_id' => $userIdToLog,
'identity_uid' => $identityUid,
'checkpoint_type' => $checkpointType,
'log_error' => $logException->getMessage(),
]);
}
}
// Stocker les infos nécessaires en session pour la résolution du checkpoint
$session->set('checkpoint_identity_uid', $identityUid);
$session->set('checkpoint_linkedin_email', $linkedinEmail);
$session->set('checkpoint_type', $checkpointType);
$session->set('checkpoint_save_credentials', $saveCredentials);
// Pour la re-soumission (pop-in "J'ai ouvert l'application" / "J'ai installé l'application")
$session->set('checkpoint_linkedin_password_resubmit', $linkedinPassword);
// Stocker le password hashé temporairement en session (sécurisé)
// On ne stocke JAMAIS le password en clair
// Utiliser UserPasswordHasherInterface pour cohérence avec l'inscription
$tempUser = new User();
$passwordHash = $this->userPasswordHasher->hashPassword($tempUser, $linkedinPassword);
$session->set('checkpoint_password_hash', $passwordHash);
// Si la sauvegarde est demandée, stocker le mot de passe chiffré pour pouvoir le sauvegarder après le checkpoint
if ($saveCredentials) {
$encryptedPassword = $this->encryptionService->encrypt($linkedinPassword);
if ($encryptedPassword) {
$session->set('checkpoint_password_encrypted', $encryptedPassword);
$this->logger->debug('ONBOARDING_V4: Mot de passe chiffré stocké en session pour sauvegarde après checkpoint', [
'action' => 'connect_linkedin',
'identity_uid' => $identityUid,
'checkpoint_type' => $checkpointType,
]);
}
}
$this->logger->info('ONBOARDING_V4: Password hashé stocké en session pour checkpoint', [
'action' => 'connect_linkedin',
'identity_uid' => $identityUid,
'checkpoint_type' => $checkpointType,
'save_credentials' => $saveCredentials,
]);
$responseData = [
'success' => false,
'checkpoint' => $checkpoint,
'identity_uid' => $identityUid,
'message' => 'Un checkpoint LinkedIn est requis. Veuillez suivre les instructions.',
'integration_uid' => $integrationUid,
];
if ($checkpointType === 'PHONE_REGISTER') {
if ($checkpointPhone !== null && $checkpointPhone !== '') {
$responseData['checkpoint_phone'] = $checkpointPhone;
}
}
return new JsonResponse($responseData, 202); // 202 Accepted
}
if ($status === 'INVALID') {
// Identifiants invalides
return new JsonResponse([
'error_code' => 'LINKEDIN_INVALID_CREDENTIALS',
'error' => 'Identifiants LinkedIn incorrects. Vérifiez votre email et votre mot de passe LinkedIn.',
], 401);
}
if ($status !== 'VALID') {
// Statut inconnu
return new JsonResponse([
'error_code' => 'LINKEDIN_UNEXPECTED_STATUS',
'error' => 'Connexion LinkedIn impossible pour le moment. Veuillez réessayer plus tard.',
], 500);
}
// 7. Récupérer l'URL du profil LinkedIn depuis les métadonnées
$meta = $integrationData['meta'] ?? [];
$linkedinProfileUrl = $meta['url'] ?? null;
if (!$linkedinProfileUrl) {
$this->logger->error('ONBOARDING_V4: URL du profil LinkedIn introuvable', [
'action' => 'connect_linkedin',
'identity_uid' => $identityUid,
'meta' => $meta,
]);
return new JsonResponse([
'error' => 'URL du profil LinkedIn introuvable',
], 500);
}
// 8. Extraire le profil LinkedIn
$this->logger->info('ONBOARDING_V4: Extraction du profil LinkedIn', [
'action' => 'connect_linkedin',
'identity_uid' => $identityUid,
'linkedin_profile_url' => $linkedinProfileUrl,
]);
$linkedinProfile = $this->captainDataV4Service->getAuthenticatedLinkedInProfile($identityUid, $linkedinProfileUrl);
$this->logger->info('ONBOARDING_V4: Profil LinkedIn extrait', [
'action' => 'connect_linkedin',
'identity_uid' => $identityUid,
'profile_url' => $linkedinProfile['linkedin_profile_url'] ?? null,
]);
// 9. Vérifier si l'utilisateur existe déjà dans Bambboo
// Priorité 1 : Utilisateur actuellement connecté
$currentUser = $this->getUser();
$existingUser = null;
if ($currentUser) {
$existingUser = $currentUser;
$this->logger->info('ONBOARDING_V4: Utilisateur connecté trouvé, mise à jour avec données LinkedIn', [
'action' => 'connect_linkedin',
'user_id' => $existingUser->getId(),
'email' => $existingUser->getEmail(),
'linkedin_email' => $linkedinEmail,
]);
} else {
// Priorité 2 : Recherche par email LinkedIn
$existingUser = $this->userRepository->findOneBy(['email' => $linkedinEmail]);
}
if ($existingUser) {
// Utilisateur existant → Mettre à jour avec les données LinkedIn
$this->logger->info('ONBOARDING_V4: Utilisateur existant trouvé, mise à jour avec données LinkedIn', [
'action' => 'connect_linkedin',
'user_id' => $existingUser->getId(),
'email' => $existingUser->getEmail(),
'linkedin_email' => $linkedinEmail,
]);
// Déterminer la locale à partir du timezone
$locale = $this->getLocaleFromTimezone($timezone);
// Mettre à jour la locale de l'utilisateur si elle n'est pas déjà définie
if (empty($existingUser->getLocale())) {
$existingUser->setLocale($locale);
$this->entityManager->flush();
}
// Créer le logger user_activity pour logger le timezone et la locale
$userLogger = $this->userLoggerFactory->createLogger($existingUser->getId());
$userLogger->info('ONBOARDING_V4: Timezone et locale utilisés pour la création de l\'Identity', [
'action' => 'connect_linkedin',
'step' => 'identity_created_with_timezone',
'timezone' => $timezone,
'locale' => $locale,
'identity_uid' => $identityUid,
]);
// Mettre à jour les données LinkedIn
$existingUser->setIdentityUid($identityUid);
$existingUser->setNumberConnections($linkedinProfile['number_connections'] ?? null);
// Mettre à jour la photo et le linkedin_url si disponibles
if (isset($linkedinProfile['profile_image_url']) || isset($linkedinProfile['profile_picture'])) {
$existingUser->setPhoto($linkedinProfile['profile_image_url'] ?? $linkedinProfile['profile_picture'] ?? null);
}
if (isset($linkedinProfile['linkedin_profile_url'])) {
$existingUser->setLinkedinUrl($linkedinProfile['linkedin_profile_url']);
}
// Mettre à jour le prénom et nom depuis LinkedIn
if (isset($linkedinProfile['first_name']) || isset($linkedinProfile['firstname'])) {
$firstName = $this->ensureUtf8($linkedinProfile['first_name'] ?? $linkedinProfile['firstname']);
$existingUser->setFirstName($firstName);
}
if (isset($linkedinProfile['last_name']) || isset($linkedinProfile['lastname'])) {
$lastName = $this->ensureUtf8($linkedinProfile['last_name'] ?? $linkedinProfile['lastname']);
$existingUser->setLastName($lastName);
}
// Ne pas modifier le mot de passe lors de la connexion LinkedIn pour un utilisateur existant
// Le mot de passe n'est défini que lors de la création d'un nouvel utilisateur
// Tracking intégration LinkedIn
$existingUser->setHasLinkedinIntegration(true);
$existingUser->setLinkedinIntegrationCreatedAt(new \DateTimeImmutable());
$existingUser->setLinkedinIntegrationLastVerifiedAt(new \DateTimeImmutable());
$existingUser->setLinkedinIntegrationStatus('VALID');
$existingUser->setCheckpointType(null); // Pas de checkpoint
$existingUser->setOnboardingStep('completed');
$existingUser->setModifiedAt(new \DateTimeImmutable());
$this->entityManager->flush();
// Mettre à jour l'Identity avec le pattern "[LOCALE] [ID] Prénom Nom"
$firstName = mb_convert_encoding($existingUser->getFirstName(), 'UTF-8', 'UTF-8');
$lastName = mb_convert_encoding($existingUser->getLastName(), 'UTF-8', 'UTF-8');
// Récupérer la locale de l'utilisateur ou déterminer depuis le timezone
$userLocale = $existingUser->getLocale();
if (empty($userLocale)) {
$userLocale = $this->getLocaleFromTimezone($timezone);
$existingUser->setLocale($userLocale);
$this->entityManager->flush();
}
$identityName = sprintf('[%s] [%d] %s %s', strtoupper($userLocale), $existingUser->getId(), $firstName, $lastName);
$this->captainDataV4Service->updateIdentity($identityUid, $identityName);
$this->logger->info('ONBOARDING_V4: Utilisateur mis à jour avec données LinkedIn', [
'action' => 'connect_linkedin',
'user_id' => $existingUser->getId(),
'has_linkedin_integration' => true,
'has_photo' => !empty($existingUser->getPhoto()),
'has_linkedin_url' => !empty($existingUser->getLinkedinUrl()),
'identity_name' => $identityName,
]);
// Sauvegarder les identifiants si la checkbox est cochée
$this->logger->debug('ONBOARDING_V4: Vérification sauvegarde identifiants (utilisateur existant mis à jour)', [
'action' => 'connect_linkedin',
'user_id' => $existingUser->getId(),
'save_credentials' => $saveCredentials,
'has_existing_encrypted_email' => $existingUser->getLinkedinEmailEncrypted() !== null,
'has_existing_encrypted_password' => $existingUser->getLinkedinPasswordEncrypted() !== null,
]);
if ($saveCredentials) {
$this->logger->info('ONBOARDING_V4: Début sauvegarde identifiants (utilisateur existant mis à jour)', [
'action' => 'connect_linkedin',
'user_id' => $existingUser->getId(),
]);
$this->saveLinkedinCredentials($existingUser, $linkedinEmail, $linkedinPassword);
} else {
$this->logger->debug('ONBOARDING_V4: Sauvegarde identifiants non demandée (checkbox non cochée)', [
'action' => 'connect_linkedin',
'user_id' => $existingUser->getId(),
]);
}
$user = $existingUser;
} else {
// Nouvel utilisateur → Créer dans Bambboo
$this->logger->info('ONBOARDING_V4: Création d\'un nouvel utilisateur', [
'action' => 'connect_linkedin',
'email' => $linkedinEmail,
]);
$user = new User();
$firstName = $linkedinProfile['first_name'] ?? $linkedinProfile['firstname'] ?? 'Prénom';
$lastName = $linkedinProfile['last_name'] ?? $linkedinProfile['lastname'] ?? 'Nom';
$user->setFirstName($this->ensureUtf8($firstName));
$user->setLastName($this->ensureUtf8($lastName));
$user->setEmail($linkedinEmail);
$user->setPhoto($linkedinProfile['profile_image_url'] ?? $linkedinProfile['profile_picture'] ?? null);
$user->setLinkedinUrl($linkedinProfile['linkedin_profile_url'] ?? null);
$user->setNumberConnections($linkedinProfile['number_connections'] ?? null);
$user->setRoles($role);
$user->setClient($client);
$user->setOrigin($origin);
$user->setIdentityUid($identityUid);
$user->setCreatedAt(new \DateTimeImmutable());
$user->setModifiedAt(new \DateTimeImmutable());
// Champs booléens et entiers obligatoires
$user->setIsVerified(false);
$user->setIsActive(true);
$user->setHasExtension(0);
$user->setHasSeenLinkedinPreviewModal(false);
$user->setIsSuggestionTourHidden(false);
// Génération du token
$bytes = random_bytes(24);
$token = rtrim(strtr(base64_encode($bytes), '+/', '-_'), '=');
$user->setToken($token);
// Génération du rememberMeKey
$rememberMeKey = bin2hex(random_bytes(32));
$user->setRememberMeKey($rememberMeKey);
// Sauvegarder le password hashé (connexion LinkedIn réussie sans checkpoint)
// Utiliser UserPasswordHasherInterface pour cohérence avec l'inscription
$hashedPassword = $this->userPasswordHasher->hashPassword($user, $linkedinPassword);
$user->setPassword($hashedPassword);
// Tracking intégration LinkedIn
$user->setHasLinkedinIntegration(true);
$user->setLinkedinIntegrationCreatedAt(new \DateTimeImmutable());
$user->setLinkedinIntegrationLastVerifiedAt(new \DateTimeImmutable());
$user->setLinkedinIntegrationStatus('VALID');
$user->setCheckpointType(null); // Pas de checkpoint
$user->setOnboardingStep('completed');
$this->logger->info('ONBOARDING_V4: Password et intégration LinkedIn configurés pour nouvel utilisateur', [
'action' => 'connect_linkedin',
'has_linkedin_integration' => true,
'integration_status' => 'VALID',
'onboarding_step' => 'completed',
]);
$this->entityManager->persist($user);
$this->entityManager->flush();
$this->logger->info('ONBOARDING_V4: Utilisateur créé avec succès', [
'action' => 'connect_linkedin',
'user_id' => $user->getId(),
]);
// Sauvegarder les identifiants si la checkbox est cochée
$this->logger->debug('ONBOARDING_V4: Vérification sauvegarde identifiants (nouvel utilisateur)', [
'action' => 'connect_linkedin',
'user_id' => $user->getId(),
'save_credentials' => $saveCredentials,
]);
if ($saveCredentials) {
$this->logger->info('ONBOARDING_V4: Début sauvegarde identifiants (nouvel utilisateur)', [
'action' => 'connect_linkedin',
'user_id' => $user->getId(),
]);
$this->saveLinkedinCredentials($user, $linkedinEmail, $linkedinPassword);
} else {
$this->logger->debug('ONBOARDING_V4: Sauvegarde identifiants non demandée (checkbox non cochée)', [
'action' => 'connect_linkedin',
'user_id' => $user->getId(),
]);
}
// Déterminer la locale à partir du timezone
$locale = $this->getLocaleFromTimezone($timezone);
// Définir la locale de l'utilisateur
$user->setLocale($locale);
$this->entityManager->flush();
// Créer le logger user_activity pour logger le timezone et la locale
$userLogger = $this->userLoggerFactory->createLogger($user->getId());
$userLogger->info('ONBOARDING_V4: Timezone et locale utilisés pour la création de l\'Identity', [
'action' => 'connect_linkedin',
'step' => 'identity_created_with_timezone',
'timezone' => $timezone,
'locale' => $locale,
'identity_uid' => $identityUid,
]);
// 8. Mettre à jour l'Identity avec le pattern "[LOCALE] [ID] Prénom Nom"
// S'assurer que les noms sont en UTF-8 valide
$firstName = mb_convert_encoding($user->getFirstName(), 'UTF-8', 'UTF-8');
$lastName = mb_convert_encoding($user->getLastName(), 'UTF-8', 'UTF-8');
// La locale est déjà déterminée et définie juste avant
$userLocale = $user->getLocale() ?: $locale;
$identityName = sprintf('[%s] [%d] %s %s', strtoupper($userLocale), $user->getId(), $firstName, $lastName);
$this->captainDataV4Service->updateIdentity($identityUid, $identityName);
$this->logger->info('ONBOARDING_V4: Identity mise à jour avec le nom utilisateur', [
'action' => 'connect_linkedin',
'identity_uid' => $identityUid,
'identity_name' => $identityName,
]);
}
// 9. Authentifier l'utilisateur automatiquement dans Symfony
$token = new UsernamePasswordToken($user, 'main', $user->getRoles());
$this->tokenStorage->setToken($token);
$session->set('_security_main', serialize($token));
$this->logger->info('ONBOARDING_V4: Utilisateur authentifié avec succès', [
'action' => 'connect_linkedin',
'user_id' => $user->getId(),
]);
// 10. Stocker l'email en session pour le flux post-login
$session->set('linkedin_user_email', $user->getEmail());
// 11. Retourner le succès (sans redirection si l'utilisateur est déjà sur la page suggestions)
$session->remove('checkpoint_linkedin_password_resubmit');
return new JsonResponse([
'success' => true,
'message' => 'Connexion LinkedIn réussie !',
'redirect_url' => null, // Pas de redirection, on reste sur la page
'integration_uid' => $integrationData['uid'] ?? null,
'user_id' => $user->getId(),
], 200);
} catch (\Exception $e) {
$this->logger->error('ONBOARDING_V4: Exception lors de la connexion LinkedIn', [
'action' => 'connect_linkedin',
'error' => $e->getMessage(),
// Attention : la trace peut contenir des arguments (dont le mot de passe LinkedIn) => on masque.
'trace' => $this->redactSensitiveDataForLogs((string) $e->getTraceAsString(), [
isset($linkedinPassword) ? (string) $linkedinPassword : null,
isset($linkedinEmail) ? (string) $linkedinEmail : null,
]),
]);
// Gestion des erreurs CaptainData/Edges : ne jamais exposer le corps brut au front
if ($e instanceof CaptainDataApiException) {
if ($e->isIdentityQuotaReached()) {
// Alerte admin avec corps brut pour investigation
$this->adminAlertService->sendCaptainDataAlert(
$e->getErrorLabel() ?? 'CREATE_ONE_IDENTITY_403_FORBIDDEN',
$e->getErrorRef() ?? 'N/A',
$createdIdentityUid ?? 'N/A',
$e->getUrl() ?? 'N/A',
$e->getPayload(),
null,
$e->getRawBody()
);
return new JsonResponse([
'error_code' => 'CAPTAINDATA_IDENTITY_QUOTA_REACHED',
'error' => 'Connexion LinkedIn temporairement indisponible. Notre service de connexion LinkedIn a atteint sa limite technique. L\'équipe a été notifiée. Réessayez plus tard.',
], 503);
}
// Erreurs de validation (ex: mot de passe trop court côté Edges)
if ($e->getStatusCode() === 422) {
$raw = $e->getRawBody() ?? '';
if (stripos($raw, 'Password must be at least 6 characters long') !== false) {
return new JsonResponse([
'error_code' => 'LINKEDIN_PASSWORD_TOO_SHORT',
'error' => 'Le mot de passe LinkedIn doit contenir au moins 6 caractères.',
], 422);
}
}
if ($e->getErrorLabel() === 'INVALID_CREDENTIALS' || $e->getMessage() === 'email ou mot de passe incorrect') {
return new JsonResponse([
'error_code' => 'LINKEDIN_INVALID_CREDENTIALS',
'error' => 'Identifiants LinkedIn incorrects. Vérifiez votre email et votre mot de passe LinkedIn.',
], 401);
}
}
if ($e->getMessage() === 'email ou mot de passe incorrect') {
return new JsonResponse([
'error_code' => 'LINKEDIN_INVALID_CREDENTIALS',
'error' => 'Identifiants LinkedIn incorrects. Vérifiez votre email et votre mot de passe LinkedIn.',
], 401);
}
return new JsonResponse([
'error_code' => 'LINKEDIN_CONNECT_FAILED',
'error' => 'Une erreur est survenue lors de la connexion à LinkedIn. Veuillez réessayer plus tard.',
], 500);
}
}
/**
* Connecter un compte LinkedIn en utilisant le cookie li_at récupéré via l'extension
* Flux complet : Création/Mise à jour Identity + Integration + Mise à jour utilisateur Symfony
*
* @Route("/api/onboarding/connect-linkedin-extension", name="api.onboarding.connect_linkedin_extension", methods={"POST"})
*
* @param Request $request Requête HTTP
* @return JsonResponse Réponse JSON
*/
public function connectLinkedInExtension(Request $request): JsonResponse
{
$this->logger->info('ONBOARDING_V4: Début de la connexion LinkedIn via extension', [
'action' => 'connect_linkedin_extension',
'step' => 'start',
]);
$createdIdentityUid = null;
try {
// 1. Récupérer l'utilisateur connecté
$user = $this->getUser();
if (!$user) {
$this->logger->error('ONBOARDING_V4: Utilisateur non authentifié', [
'action' => 'connect_linkedin_extension',
'step' => 'pre_check',
]);
return new JsonResponse([
'error' => 'Utilisateur non authentifié',
], 401);
}
// Créer le logger user_activity
$userLogger = $this->userLoggerFactory->createLogger($user->getId());
// Récupérer le timezone depuis la session
$session = $request->getSession();
$timezone = $session->get('user_timezone', 'Europe/Paris');
// Valider le timezone
try {
new \DateTimeZone($timezone);
} catch (\Exception $e) {
$timezone = 'Europe/Paris'; // Fallback si invalide
}
// Déterminer la locale à partir du timezone
$locale = $this->getLocaleFromTimezone($timezone);
// Mettre à jour la locale de l'utilisateur si elle n'est pas déjà définie
if (empty($user->getLocale())) {
$user->setLocale($locale);
$this->entityManager->flush();
}
// Logger le timezone et la locale utilisés dans user_activity
$userLogger->info('ONBOARDING_V4_EXTENSION: Timezone et locale détectés pour la création de l\'Identity', [
'action' => 'connect_linkedin_extension',
'step' => 'timezone_detected',
'timezone' => $timezone,
'locale' => $locale,
]);
$this->logger->info('ONBOARDING_V4: Utilisateur connecté trouvé', [
'action' => 'connect_linkedin_extension',
'step' => 'pre_check',
'user_id' => $user->getId(),
]);
// 2. Récupérer le cookie li_at depuis la requête
$rawContent = $request->getContent();
$data = json_decode($rawContent, true);
$this->logger->info('ONBOARDING_V4: Contenu reçu dans connectLinkedInExtension', [
'action' => 'connect_linkedin_extension',
'step' => 'content_received',
'user_id' => $user->getId(),
'raw_content_length' => strlen($rawContent),
'raw_content_preview' => substr($rawContent, 0, 200),
'json_error' => json_last_error() !== JSON_ERROR_NONE ? json_last_error_msg() : 'none',
'data_keys' => is_array($data) ? array_keys($data) : 'not_array',
'has_li_at' => isset($data['li_at']),
'li_at_length' => isset($data['li_at']) ? strlen($data['li_at']) : 0,
]);
// Logger le payload reçu dans user_activity
$userLogger->info('ONBOARDING_V4_EXTENSION: Payload reçu', [
'action' => 'connect_linkedin_extension',
'step' => 'payload_received',
'payload' => [
'has_li_at' => isset($data['li_at']) && !empty(trim($data['li_at'] ?? '')),
'li_at_length' => isset($data['li_at']) ? strlen($data['li_at']) : 0,
'li_at_preview' => isset($data['li_at']) && strlen($data['li_at']) > 20
? substr($data['li_at'], 0, 10) . '...' . substr($data['li_at'], -10)
: ($data['li_at'] ?? null),
],
]);
if (!isset($data['li_at']) || empty(trim($data['li_at']))) {
$this->logger->error('ONBOARDING_V4: Cookie li_at manquant', [
'action' => 'connect_linkedin_extension',
'step' => 'cookie_validation',
'user_id' => $user->getId(),
]);
$userLogger->error('ONBOARDING_V4_EXTENSION: Cookie li_at manquant', [
'action' => 'connect_linkedin_extension',
'step' => 'cookie_validation',
]);
return new JsonResponse([
'error' => 'Cookie li_at manquant',
], 400);
}
$liAtCookie = trim($data['li_at']);
$this->logger->info('ONBOARDING_V4: Cookie li_at reçu', [
'action' => 'connect_linkedin_extension',
'step' => 'cookie_received',
'user_id' => $user->getId(),
]);
$userLogger->info('ONBOARDING_V4_EXTENSION: Cookie li_at validé', [
'action' => 'connect_linkedin_extension',
'step' => 'cookie_validated',
'cookie_length' => strlen($liAtCookie),
]);
// 3. Vérifier si l'utilisateur a déjà une Identity, sinon en créer une
$identityUid = $user->getIdentityUid();
$identityExistsInDb = !empty($identityUid);
// Vérifier si l'Identity existe réellement dans CaptainData
$identityExistsInCaptainData = false;
if ($identityExistsInDb) {
$existingIdentity = $this->captainDataV4Service->getIdentity($identityUid);
$identityExistsInCaptainData = ($existingIdentity !== null);
$this->logger->info('ONBOARDING_V4: Vérification de l\'existence de l\'Identity dans CaptainData', [
'action' => 'connect_linkedin_extension',
'step' => 'identity_verification',
'user_id' => $user->getId(),
'identity_uid' => $identityUid,
'exists_in_db' => $identityExistsInDb,
'exists_in_captaindata' => $identityExistsInCaptainData,
]);
if (!$identityExistsInCaptainData) {
$this->logger->warning('ONBOARDING_V4: Identity présente en DB mais absente de CaptainData, sera recréée', [
'action' => 'connect_linkedin_extension',
'step' => 'identity_verification',
'user_id' => $user->getId(),
'identity_uid' => $identityUid,
]);
}
}
$this->logger->info('ONBOARDING_V4: Vérification Identity', [
'action' => 'connect_linkedin_extension',
'step' => 'identity_check',
'user_id' => $user->getId(),
'identity_exists_in_db' => $identityExistsInDb,
'identity_exists_in_captaindata' => $identityExistsInCaptainData,
'identity_uid' => $identityUid,
]);
// Si l'Identity n'existe pas dans CaptainData (même si elle existe en DB), la recréer ou réutiliser une existante
if (!$identityExistsInCaptainData) {
// Construire le nom d'Identity au format "[LOCALE] [ID] Prénom Nom" (identique à la création)
$externalId = 'bambboo_user_' . $user->getId();
$firstName = mb_convert_encoding($user->getFirstName(), 'UTF-8', 'UTF-8');
$lastName = mb_convert_encoding($user->getLastName(), 'UTF-8', 'UTF-8');
$userLocale = $user->getLocale() ?: $locale;
$identityName = sprintf('[%s] [%d] %s %s', strtoupper($userLocale), $user->getId(), $firstName, $lastName);
// Double vérification : une Identity existe déjà côté CaptainData ? (éviter 403 / doublons)
// 1) Par nom au format "[LOCALE] [ID] Prénom Nom"
$existingIdentityUid = $this->captainDataV4Service->findIdentityUidByExactName($identityName);
if ($existingIdentityUid !== null) {
$identityUid = $existingIdentityUid;
$identityExistsInCaptainData = true;
$this->logger->info('ONBOARDING_V4: Réutilisation d\'une Identity existante (nom exact) pour éviter 403', [
'action' => 'connect_linkedin_extension',
'step' => 'identity_reused_by_name',
'user_id' => $user->getId(),
'identity_uid' => $identityUid,
'identity_name' => $identityName,
]);
$userLogger->info('ONBOARDING_V4_EXTENSION: Identity existante trouvée par nom, réutilisation', [
'action' => 'connect_linkedin_extension',
'step' => 'identity_reused_by_name',
'identity_uid' => $identityUid,
]);
}
// 2) Par email (certaines Identity sont enregistrées avec l'email comme nom, ex. onboarding par identifiants)
if (!$identityExistsInCaptainData && $user->getEmail() !== null && trim($user->getEmail()) !== '') {
$existingIdentityUid = $this->captainDataV4Service->findIdentityUidByExactName(trim($user->getEmail()));
if ($existingIdentityUid !== null) {
$identityUid = $existingIdentityUid;
$identityExistsInCaptainData = true;
$this->logger->info('ONBOARDING_V4: Réutilisation d\'une Identity existante (email) pour éviter 403', [
'action' => 'connect_linkedin_extension',
'step' => 'identity_reused_by_email',
'user_id' => $user->getId(),
'identity_uid' => $identityUid,
'email' => $user->getEmail(),
]);
$userLogger->info('ONBOARDING_V4_EXTENSION: Identity existante trouvée par email, réutilisation', [
'action' => 'connect_linkedin_extension',
'step' => 'identity_reused_by_email',
'identity_uid' => $identityUid,
]);
}
}
if (!$identityExistsInCaptainData) {
$this->logger->info('ONBOARDING_V4: Création de l\'Identity', [
'action' => 'connect_linkedin_extension',
'step' => 'identity_creation',
'user_id' => $user->getId(),
'identity_name' => $identityName,
]);
// Logger le payload de création d'Identity dans user_activity
$identityPayload = [
'external_id' => $externalId,
'name' => $identityName,
'timezone' => $timezone,
];
$userLogger->info('ONBOARDING_V4_EXTENSION: Création de l\'Identity - Payload envoyé', [
'action' => 'connect_linkedin_extension',
'step' => 'identity_creation_payload',
'payload' => $identityPayload,
]);
$identityData = $this->captainDataV4Service->createIdentity(
$externalId,
$identityName,
$timezone
);
// Logger le timezone et la locale utilisés dans user_activity après création
$userLogger->info('ONBOARDING_V4_EXTENSION: Timezone et locale utilisés pour la création de l\'Identity', [
'action' => 'connect_linkedin_extension',
'step' => 'identity_created_with_timezone',
'timezone' => $timezone,
'locale' => $locale,
'identity_uid' => $identityData['uid'] ?? null,
]);
// Logger la réponse de création d'Identity dans user_activity
$userLogger->info('ONBOARDING_V4_EXTENSION: Création de l\'Identity - Réponse reçue', [
'action' => 'connect_linkedin_extension',
'step' => 'identity_creation_response',
'response' => [
'has_uid' => isset($identityData['uid']),
'uid' => $identityData['uid'] ?? null,
'has_login_links' => isset($identityData['identity_login_links']),
],
]);
$identityUid = $identityData['uid'] ?? null;
if (!$identityUid) {
throw new \Exception('Identity créée mais identity_uid non reçu dans la réponse');
}
$createdIdentityUid = $identityUid;
$this->logger->info('ONBOARDING_V4: Récupération du identity_uid', [
'action' => 'connect_linkedin_extension',
'step' => 'identity_uid_received',
'user_id' => $user->getId(),
'identity_uid' => $identityUid,
]);
}
}
if ($identityExistsInCaptainData) {
// Mettre à jour le nom de l'Identity avec le pattern "[LOCALE] [ID] Prénom Nom"
$firstName = mb_convert_encoding($user->getFirstName(), 'UTF-8', 'UTF-8');
$lastName = mb_convert_encoding($user->getLastName(), 'UTF-8', 'UTF-8');
// La locale est déjà déterminée et définie juste avant
$userLocale = $user->getLocale() ?: $locale;
$identityName = sprintf('[%s] [%d] %s %s', strtoupper($userLocale), $user->getId(), $firstName, $lastName);
$this->logger->info('ONBOARDING_V4: Mise à jour du nom de l\'Identity', [
'action' => 'connect_linkedin_extension',
'step' => 'identity_name_update',
'user_id' => $user->getId(),
'identity_uid' => $identityUid,
'identity_name' => $identityName,
]);
$this->captainDataV4Service->updateIdentity($identityUid, $identityName);
$this->logger->info('ONBOARDING_V4: Nom de l\'Identity mis à jour', [
'action' => 'connect_linkedin_extension',
'step' => 'identity_name_updated',
'user_id' => $user->getId(),
'identity_uid' => $identityUid,
'identity_name' => $identityName,
]);
}
// 4. Créer l'intégration LinkedIn avec le cookie li_at
// Petit délai pour s'assurer que l'Identity est bien propagée dans l'API CaptainData
if (!$identityExistsInCaptainData) {
sleep(1);
}
$this->logger->info('ONBOARDING_V4: Création de l\'intégration LinkedIn', [
'action' => 'connect_linkedin_extension',
'step' => 'integration_creation_start',
'user_id' => $user->getId(),
'identity_uid' => $identityUid,
]);
$this->logger->info('ONBOARDING_V4: Création de l\'intégration LinkedIn en cours...', [
'action' => 'connect_linkedin_extension',
'step' => 'integration_creation',
'user_id' => $user->getId(),
'identity_uid' => $identityUid,
]);
// Logger le payload de création d'intégration LinkedIn dans user_activity
$integrationPayload = [
'identity_uid' => $identityUid,
'has_li_at' => !empty($liAtCookie),
'li_at_length' => strlen($liAtCookie),
'li_at_preview' => strlen($liAtCookie) > 20
? substr($liAtCookie, 0, 10) . '...' . substr($liAtCookie, -10)
: $liAtCookie,
'has_li_a' => false,
'account_name' => null,
];
$userLogger->info('ONBOARDING_V4_EXTENSION: Création de l\'intégration LinkedIn - Payload envoyé', [
'action' => 'connect_linkedin_extension',
'step' => 'integration_creation_payload',
'payload' => $integrationPayload,
]);
$integrationData = $this->captainDataV4Service->createLinkedInIntegration(
$identityUid,
$liAtCookie,
null, // Pas de li_a
null // Pas de account_name
);
// Logger la réponse de création d'intégration LinkedIn dans user_activity
$userLogger->info('ONBOARDING_V4_EXTENSION: Création de l\'intégration LinkedIn - Réponse reçue', [
'action' => 'connect_linkedin_extension',
'step' => 'integration_creation_response',
'response' => [
'has_uid' => isset($integrationData['uid']),
'uid' => $integrationData['uid'] ?? null,
'status' => $integrationData['status'] ?? null,
'has_checkpoint' => isset($integrationData['checkpoint']),
],
]);
// 5. Analyser la réponse de l'API
$status = $integrationData['status'] ?? 'UNKNOWN';
$this->logger->info('ONBOARDING_V4: Intégration LinkedIn créée', [
'action' => 'connect_linkedin_extension',
'step' => 'integration_created',
'user_id' => $user->getId(),
'identity_uid' => $identityUid,
'integration_uid' => $integrationData['uid'] ?? null,
'status' => $status,
]);
// 6. Gérer les statuts non-VALID
if ($status === 'INVALID') {
$this->logger->warning('ONBOARDING_V4: Cookie LinkedIn invalide', [
'action' => 'connect_linkedin_extension',
'step' => 'integration_creation',
'user_id' => $user->getId(),
'identity_uid' => $identityUid,
'status' => 'INVALID',
'error' => 'cookie_invalid',
]);
$userLogger->error('ONBOARDING_V4_EXTENSION: Cookie LinkedIn invalide', [
'action' => 'connect_linkedin_extension',
'step' => 'integration_invalid',
'status' => 'INVALID',
'error' => 'cookie_invalid',
]);
return new JsonResponse([
'error' => 'Le cookie LinkedIn est invalide ou expiré. Veuillez vous reconnecter à LinkedIn.',
'status' => 'INVALID',
], 401);
}
if ($status !== 'VALID') {
$this->logger->error('ONBOARDING_V4: Statut de connexion inattendu', [
'action' => 'connect_linkedin_extension',
'step' => 'integration_creation',
'user_id' => $user->getId(),
'identity_uid' => $identityUid,
'status' => $status,
]);
$userLogger->error('ONBOARDING_V4_EXTENSION: Statut de connexion inattendu', [
'action' => 'connect_linkedin_extension',
'step' => 'integration_unexpected_status',
'status' => $status,
]);
return new JsonResponse([
'error' => 'Statut de connexion inattendu: ' . $status,
'status' => $status,
], 500);
}
// 7. Mettre à jour l'utilisateur avec les informations LinkedIn
// IMPORTANT: on ne persiste identity_uid qu'une fois l'intégration LinkedIn VALID
$user->setIdentityUid($identityUid);
$user->setHasLinkedinIntegration(true);
$user->setLinkedinIntegrationStatus('VALID');
$user->setLinkedinIntegrationCreatedAt(new \DateTimeImmutable());
$user->setLinkedinIntegrationLastVerifiedAt(new \DateTimeImmutable());
$user->setHasExtension(1); // L'extension a été utilisée avec succès
$user->setExtensionLastVerifiedAt(new \DateTimeImmutable());
$user->setModifiedAt(new \DateTimeImmutable());
// Sauvegarder le cookie li_at en BDD (précaution, même si on a l'identity_uid)
$user->setLiAt($liAtCookie);
$user->setLiAtUpdatedAt(new \DateTimeImmutable());
// Mettre à jour les métadonnées si disponibles
$meta = $integrationData['meta'] ?? [];
if (isset($meta['url'])) {
$user->setLinkedinUrl($meta['url']);
}
if (isset($meta['profile_image_url'])) {
$user->setPhoto($meta['profile_image_url']);
}
$this->entityManager->flush();
$this->logger->info('ONBOARDING_V4: Intégration réussie', [
'action' => 'connect_linkedin_extension',
'step' => 'integration_success',
'user_id' => $user->getId(),
'identity_uid' => $identityUid,
'has_linkedin_integration' => true,
'status' => 'VALID',
]);
$this->logger->info('ONBOARDING_V4: Mise à jour base de données', [
'action' => 'connect_linkedin_extension',
'step' => 'database_updated',
'user_id' => $user->getId(),
'identity_uid' => $identityUid,
]);
// Déclencher le traitement des messages en attente si l'intégration est maintenant VALID
$this->triggerPendingMessagesIfNeeded($user);
$this->logger->info('ONBOARDING_V4: Utilisateur finalisé avec intégration LinkedIn', [
'action' => 'connect_linkedin_extension',
'step' => 'user_finalized',
'user_id' => $user->getId(),
'identity_uid' => $identityUid,
'has_linkedin_integration' => true,
'status' => 'VALID',
]);
// 8. Lancer l'extraction du réseau LinkedIn uniquement en première connexion (pas après reconnexion via extension)
$isReconnection = $identityExistsInDb && $identityExistsInCaptainData;
if (!$isReconnection) {
try {
$webhookUrl = $this->params->get('app.captaindata_v4_webhook_url');
if ($webhookUrl) {
$maxResults = $this->params->get('app.captaindata_v4_max_connections') ?? 100;
$this->logger->info('ONBOARDING_V4: Lancement extraction réseau après intégration LinkedIn', [
'action' => 'connect_linkedin_extension',
'step' => 'network_extraction_start',
'user_id' => $user->getId(),
'identity_uid' => $identityUid,
'webhook_url' => $webhookUrl,
'max_results' => $maxResults,
]);
$userLogger->info('ONBOARDING_V4_EXTENSION: Lancement extraction réseau', [
'action' => 'connect_linkedin_extension',
'step' => 'network_extraction_start',
'identity_uid' => $identityUid,
'webhook_url' => $webhookUrl,
'max_results' => $maxResults,
]);
$result = $this->captainDataV4Service->createNetworkExtractionRun($identityUid, $webhookUrl, $maxResults);
$runUid = $result['run_uid'] ?? null;
if ($runUid) {
// Créer un CaptaindataJob pour suivre l'avancement de l'extraction
$job = new CaptaindataJob(
$user,
CaptaindataJob::WORKFLOW_TYPE_EXTRACT_CONNECTIONS,
$identityUid, // On stocke identity_uid dans captainDataWorkflowUid
CaptaindataJob::STATUS_EXTRACTION_SCHEDULED_AWAITING_WEBHOOK
);
$job->setRunUid($runUid);
$job->setApiVersion('v4');
$this->entityManager->persist($job);
$this->entityManager->flush();
$this->logger->info('ONBOARDING_V4: Extraction réseau lancée avec succès', [
'action' => 'connect_linkedin_extension',
'step' => 'network_extraction_success',
'user_id' => $user->getId(),
'identity_uid' => $identityUid,
'run_uid' => $runUid,
'job_id' => $job->getId(),
]);
$userLogger->info('ONBOARDING_V4_EXTENSION: Extraction réseau lancée avec succès', [
'action' => 'connect_linkedin_extension',
'step' => 'network_extraction_success',
'run_uid' => $runUid,
'job_id' => $job->getId(),
]);
} else {
$this->logger->warning('ONBOARDING_V4: run_uid manquant dans la réponse de l\'extraction réseau', [
'action' => 'connect_linkedin_extension',
'step' => 'network_extraction_warning',
'user_id' => $user->getId(),
'identity_uid' => $identityUid,
]);
$userLogger->warning('ONBOARDING_V4_EXTENSION: run_uid manquant dans la réponse de l\'extraction réseau', [
'action' => 'connect_linkedin_extension',
'step' => 'network_extraction_warning',
]);
}
} else {
$this->logger->warning('ONBOARDING_V4: URL webhook non configurée, extraction réseau non lancée', [
'action' => 'connect_linkedin_extension',
'step' => 'network_extraction_warning',
'user_id' => $user->getId(),
'identity_uid' => $identityUid,
]);
$userLogger->warning('ONBOARDING_V4_EXTENSION: URL webhook non configurée, extraction réseau non lancée', [
'action' => 'connect_linkedin_extension',
'step' => 'network_extraction_warning',
]);
}
} catch (\Exception $e) {
// Ne pas faire échouer la connexion si l'extraction réseau échoue
$this->logger->error('ONBOARDING_V4: Erreur lors du lancement de l\'extraction réseau', [
'action' => 'connect_linkedin_extension',
'step' => 'network_extraction_error',
'user_id' => $user->getId(),
'identity_uid' => $identityUid,
'error' => $e->getMessage(),
]);
$userLogger->error('ONBOARDING_V4_EXTENSION: Erreur lors du lancement de l\'extraction réseau', [
'action' => 'connect_linkedin_extension',
'step' => 'network_extraction_error',
'error' => $e->getMessage(),
]);
}
} else {
$this->logger->info('ONBOARDING_V4: Reconnexion via extension — extraction réseau non relancée', [
'action' => 'connect_linkedin_extension',
'step' => 'network_extraction_skipped_reconnection',
'user_id' => $user->getId(),
'identity_uid' => $identityUid,
]);
$userLogger->info('ONBOARDING_V4_EXTENSION: Reconnexion — NETWORK_V4_EXTRACTION non relancée', [
'action' => 'connect_linkedin_extension',
'step' => 'network_extraction_skipped_reconnection',
]);
}
// 9. Retourner le succès
$responseData = [
'success' => true,
'message' => $isReconnection
? 'Connexion LinkedIn réussie !'
: 'Connexion LinkedIn réussie ! Extraction du réseau en cours...',
'integration_uid' => $integrationData['uid'] ?? null,
'user_id' => $user->getId(),
'identity_uid' => $identityUid,
];
// Logger la réponse finale de succès dans user_activity
$userLogger->info('ONBOARDING_V4_EXTENSION: Connexion LinkedIn réussie - Réponse finale', [
'action' => 'connect_linkedin_extension',
'step' => 'success_response',
'response' => $responseData,
]);
// IMPORTANT : rafraîchir le token Symfony en session.
// Sans cela, l'utilisateur sérialisé dans `_security_main` peut conserver un état obsolète
// (ex: identityUid vide) et le refresh de page afficher encore "Connectez votre compte LinkedIn".
$token = new UsernamePasswordToken($user, 'main', $user->getRoles());
$this->tokenStorage->setToken($token);
$session->set('_security_main', serialize($token));
return new JsonResponse($responseData, 200);
} catch (\Exception $e) {
// Best-effort : si on a créé une Identity pendant cette requête, la supprimer pour éviter les doublons/quota
if ($createdIdentityUid !== null) {
try {
$this->captainDataV4Service->deleteIdentity($createdIdentityUid);
$this->logger->info('ONBOARDING_V4: Identity temporaire supprimée après échec (extension)', [
'action' => 'connect_linkedin_extension',
'identity_uid' => $createdIdentityUid,
]);
} catch (\Exception $cleanupException) {
$this->logger->warning('ONBOARDING_V4: Impossible de supprimer l\'Identity temporaire après échec (extension)', [
'action' => 'connect_linkedin_extension',
'identity_uid' => $createdIdentityUid,
'error' => $cleanupException->getMessage(),
]);
}
}
$this->logger->error('ONBOARDING_V4: Exception lors de la connexion LinkedIn via extension', [
'action' => 'connect_linkedin_extension',
'error' => $e->getMessage(),
// Attention : la trace peut contenir les cookies (li_at/li_a) => on masque.
'trace' => $this->redactSensitiveDataForLogs((string) $e->getTraceAsString(), [
isset($liAtCookie) ? (string) $liAtCookie : null,
isset($liACookie) ? (string) $liACookie : null,
]),
]);
// Logger l'erreur dans user_activity si l'utilisateur est disponible
if (isset($user) && isset($userLogger)) {
$userLogger->error('ONBOARDING_V4_EXTENSION: Exception lors de la connexion', [
'action' => 'connect_linkedin_extension',
'step' => 'exception',
'error' => $e->getMessage(),
'error_class' => get_class($e),
]);
}
// Message d'erreur personnalisé pour les identifiants invalides
$errorMessage = $e->getMessage();
if ($errorMessage === 'email ou mot de passe incorrect') {
$displayError = $errorMessage;
} else {
$displayError = 'Une erreur est survenue lors de la connexion: ' . $errorMessage;
}
return new JsonResponse([
'error' => $displayError,
], 500);
}
}
/**
* Résoudre un checkpoint LinkedIn et finaliser la création de l'utilisateur
*
* @Route("/api/onboarding/resolve-checkpoint", name="api.onboarding.resolve_checkpoint", methods={"POST"})
*
* @param Request $request Requête HTTP
* @param SessionInterface $session Session Symfony
* @return JsonResponse Réponse JSON
*/
public function resolveCheckpoint(Request $request, SessionInterface $session): JsonResponse
{
$this->logger->info('ONBOARDING_V4: Début de la résolution du checkpoint', [
'action' => 'resolve_checkpoint',
]);
try {
// 1. Récupérer les données du formulaire
$data = json_decode($request->getContent(), true);
if (!isset($data['identity_uid'])) {
return new JsonResponse([
'error' => 'Identity UID requis',
], 400);
}
$identityUid = $data['identity_uid'];
$code = isset($data['code']) ? trim($data['code']) : null;
$checkpointType = $session->get('checkpoint_type');
// Logger dans user_activity : quel checkpoint est en cours de résolution pour ce user ?
// Best-effort (le user peut ne pas être connecté si session expirée) : user connecté → session checkpoint_user_id.
// Important : ne pas dépendre de la base de données ici (éviter effets de bord et faciliter les tests).
$userIdToLog = null;
$currentUserForLog = $this->getUser();
if ($currentUserForLog instanceof User) {
$userIdToLog = $currentUserForLog->getId();
} else {
$sessionUserId = $session->get('checkpoint_user_id');
if (is_int($sessionUserId)) {
$userIdToLog = $sessionUserId;
} elseif (is_string($sessionUserId) && ctype_digit($sessionUserId)) {
$userIdToLog = (int) $sessionUserId;
}
}
// 2. Récupérer les informations de la session (optionnelles pour IN_APP_VALIDATION)
$linkedinEmail = $session->get('checkpoint_linkedin_email');
// Données de polling (optionnelles, uniquement pour logs/diagnostic)
$polling = (isset($data['polling']) && is_array($data['polling'])) ? $data['polling'] : [];
$pollRunId = isset($polling['run_id']) ? (string) $polling['run_id'] : null;
$pollAttempt = isset($polling['attempt']) ? (int) $polling['attempt'] : null;
$pollMaxAttempts = isset($polling['max_attempts']) ? (int) $polling['max_attempts'] : null;
$pollSource = isset($polling['source']) ? (string) $polling['source'] : null;
if (is_int($userIdToLog)) {
try {
$userLogger = $this->userLoggerFactory->createLogger($userIdToLog);
$userLogger->info('ONBOARDING_V4: Résolution checkpoint — démarrage', [
'action' => 'resolve_checkpoint',
'step' => 'start',
'user_id' => $userIdToLog,
'identity_uid' => $identityUid,
'checkpoint_type' => $checkpointType,
'has_code' => $code !== null && $code !== '',
'poll_run_id' => $pollRunId,
'poll_attempt' => $pollAttempt,
'poll_max_attempts' => $pollMaxAttempts,
'poll_source' => $pollSource,
]);
} catch (\Exception $logException) {
$this->logger->warning('ONBOARDING_V4: Impossible de logger le démarrage resolve_checkpoint dans user_activity', [
'action' => 'resolve_checkpoint',
'user_id' => $userIdToLog,
'identity_uid' => $identityUid,
'checkpoint_type' => $checkpointType,
'log_error' => $logException->getMessage(),
]);
}
}
if ($checkpointType === 'IN_APP_VALIDATION' && $pollRunId && $pollAttempt === 1) {
$this->logger->info('ONBOARDING_V4: Démarrage polling IN_APP_VALIDATION', [
'action' => 'resolve_checkpoint',
'identity_uid' => $identityUid,
'checkpoint_type' => $checkpointType,
'poll_run_id' => $pollRunId,
'poll_attempt' => $pollAttempt,
'poll_max_attempts' => $pollMaxAttempts,
'poll_source' => $pollSource,
]);
}
// Le code de checkpoint est désormais optionnel : on laisse le service
// CaptainData décider du comportement (ex. IN_APP_VALIDATION, etc.).
// (Ancienne validation supprimée pour ne plus retourner l'erreur "Code requis pour ce type de checkpoint".)
$clientId = $session->get('client_id');
$role = $session->get('role', ['ROLE_COOPTOR']);
$origin = $session->get('origin') ?? '';
// 3. Résoudre le checkpoint via l'API CaptainData (on a juste besoin de l'identity_uid)
$this->logger->info('ONBOARDING_V4: Résolution du checkpoint LinkedIn', [
'action' => 'resolve_checkpoint',
'identity_uid' => $identityUid,
'has_session_email' => !empty($linkedinEmail),
'has_session_client' => !empty($clientId),
'poll_run_id' => $pollRunId,
'poll_attempt' => $pollAttempt,
]);
try {
$checkpointResult = $this->captainDataV4Service->resolveLinkedInCheckpoint($identityUid, $code);
} catch (\RuntimeException $e) {
// Pour IN_APP_VALIDATION, IN_APP_CHALLENGE_PENDING est un état normal
// L'utilisateur doit valider le challenge dans l'app CaptainData
if ($e->getMessage() === 'IN_APP_CHALLENGE_PENDING' && $checkpointType === 'IN_APP_VALIDATION') {
$this->logger->info('ONBOARDING_V4: Checkpoint IN_APP_VALIDATION en attente de validation utilisateur', [
'action' => 'resolve_checkpoint',
'identity_uid' => $identityUid,
'checkpoint_type' => $checkpointType,
'poll_run_id' => $pollRunId,
'poll_attempt' => $pollAttempt,
]);
if (is_int($userIdToLog)) {
try {
$userLogger = $this->userLoggerFactory->createLogger($userIdToLog);
$userLogger->info('ONBOARDING_V4: Résolution checkpoint — en attente (IN_APP_VALIDATION)', [
'action' => 'resolve_checkpoint',
'step' => 'pending_in_app_validation',
'user_id' => $userIdToLog,
'identity_uid' => $identityUid,
'checkpoint_type' => $checkpointType,
'poll_run_id' => $pollRunId,
'poll_attempt' => $pollAttempt,
]);
} catch (\Exception $logException) {
$this->logger->warning('ONBOARDING_V4: Impossible de logger pending IN_APP_VALIDATION dans user_activity', [
'action' => 'resolve_checkpoint',
'user_id' => $userIdToLog,
'identity_uid' => $identityUid,
'checkpoint_type' => $checkpointType,
'log_error' => $logException->getMessage(),
]);
}
}
// Retourner une réponse qui indique que le polling doit continuer
return new JsonResponse([
'success' => false,
'pending' => true,
'message' => 'En attente de validation dans l\'application CaptainData...',
'status' => 'PENDING',
], 202); // 202 Accepted - le polling continue
}
// Pour les autres erreurs, relancer l'exception
throw $e;
}
$status = $checkpointResult['status'] ?? 'UNKNOWN';
$meta = $checkpointResult['meta'] ?? [];
$linkedinProfileUrl = $meta['url'] ?? null;
$this->logger->info('ONBOARDING_V4: Checkpoint résolu', [
'action' => 'resolve_checkpoint',
'identity_uid' => $identityUid,
'status' => $status,
'integration_meta' => $meta,
'linkedin_url' => $linkedinProfileUrl,
'poll_run_id' => $pollRunId,
'poll_attempt' => $pollAttempt,
]);
// 4. Vérifier que le statut est VALID
if ($status !== 'VALID') {
if (is_int($userIdToLog)) {
try {
$userLogger = $this->userLoggerFactory->createLogger($userIdToLog);
$userLogger->warning('ONBOARDING_V4: Résolution checkpoint — statut non VALID', [
'action' => 'resolve_checkpoint',
'step' => 'not_valid',
'user_id' => $userIdToLog,
'identity_uid' => $identityUid,
'checkpoint_type' => $checkpointType,
'status' => $status,
]);
} catch (\Exception $logException) {
$this->logger->warning('ONBOARDING_V4: Impossible de logger statut non VALID dans user_activity', [
'action' => 'resolve_checkpoint',
'user_id' => $userIdToLog,
'identity_uid' => $identityUid,
'checkpoint_type' => $checkpointType,
'status' => $status,
'log_error' => $logException->getMessage(),
]);
}
}
return new JsonResponse([
'error' => 'Code incorrect ou checkpoint non résolu. Statut: ' . $status,
'status' => $status,
], 401);
}
if (is_int($userIdToLog)) {
try {
$userLogger = $this->userLoggerFactory->createLogger($userIdToLog);
$userLogger->info('ONBOARDING_V4: Checkpoint résolu (VALID)', [
'action' => 'resolve_checkpoint',
'step' => 'valid',
'user_id' => $userIdToLog,
'identity_uid' => $identityUid,
'checkpoint_type' => $checkpointType,
'poll_run_id' => $pollRunId,
'poll_attempt' => $pollAttempt,
]);
} catch (\Exception $logException) {
$this->logger->warning('ONBOARDING_V4: Impossible de logger checkpoint VALID dans user_activity', [
'action' => 'resolve_checkpoint',
'user_id' => $userIdToLog,
'identity_uid' => $identityUid,
'checkpoint_type' => $checkpointType,
'log_error' => $logException->getMessage(),
]);
}
}
if ($checkpointType === 'IN_APP_VALIDATION' && $pollRunId) {
$this->logger->info('ONBOARDING_V4: Validation IN_APP_VALIDATION détectée (mobile)', [
'action' => 'resolve_checkpoint',
'identity_uid' => $identityUid,
'checkpoint_type' => $checkpointType,
'poll_run_id' => $pollRunId,
'poll_attempt' => $pollAttempt,
'poll_max_attempts' => $pollMaxAttempts,
'poll_source' => $pollSource,
]);
}
if (!$linkedinProfileUrl) {
$this->logger->error('ONBOARDING_V4: URL du profil LinkedIn introuvable', [
'action' => 'resolve_checkpoint',
'identity_uid' => $identityUid,
'meta' => $meta,
]);
return new JsonResponse([
'error' => 'URL du profil LinkedIn introuvable',
], 500);
}
// 5. Extraire le profil LinkedIn avec l'URL récupérée
$this->logger->info('ONBOARDING_V4: Extraction du profil LinkedIn après checkpoint', [
'action' => 'resolve_checkpoint',
'identity_uid' => $identityUid,
'linkedin_profile_url' => $linkedinProfileUrl,
]);
$linkedinProfile = $this->captainDataV4Service->getAuthenticatedLinkedInProfile($identityUid, $linkedinProfileUrl);
$this->logger->info('ONBOARDING_V4: Profil LinkedIn extrait après checkpoint', [
'action' => 'resolve_checkpoint',
'identity_uid' => $identityUid,
'profile_url' => $linkedinProfile['linkedin_profile_url'] ?? null,
]);
// 6. Récupérer l'email depuis le profil si la session a expiré
// Pour IN_APP_VALIDATION, on peut récupérer l'email depuis le profil LinkedIn
if (!$linkedinEmail && $checkpointType === 'IN_APP_VALIDATION') {
// Essayer de récupérer l'email depuis le profil LinkedIn
$linkedinEmail = $linkedinProfile['email'] ?? $linkedinProfile['emails'][0] ?? null;
if ($linkedinEmail) {
$this->logger->info('ONBOARDING_V4: Email récupéré depuis le profil LinkedIn', [
'action' => 'resolve_checkpoint',
'identity_uid' => $identityUid,
'email' => $linkedinEmail,
]);
} else {
// Essayer de trouver l'utilisateur par identity_uid
$existingUserByIdentity = $this->userRepository->findOneBy(['identity_uid' => $identityUid]);
if ($existingUserByIdentity) {
$linkedinEmail = $existingUserByIdentity->getEmail();
$this->logger->info('ONBOARDING_V4: Email récupéré depuis l\'utilisateur existant', [
'action' => 'resolve_checkpoint',
'identity_uid' => $identityUid,
'user_id' => $existingUserByIdentity->getId(),
'email' => $linkedinEmail,
]);
}
}
}
// 7. Vérifier que nous avons les informations nécessaires
if (!$linkedinEmail) {
// Si on n'a toujours pas d'email, c'est une vraie erreur
$this->logger->error('ONBOARDING_V4: Email introuvable (session expirée et email non disponible dans le profil)', [
'action' => 'resolve_checkpoint',
'identity_uid' => $identityUid,
'checkpoint_type' => $checkpointType,
]);
return new JsonResponse([
'error' => 'Session expirée. Veuillez recommencer.',
], 400);
}
// 8. Récupérer le client (depuis la session, l'utilisateur connecté, ou l'utilisateur existant)
if (!$clientId) {
// D'abord, essayer depuis l'utilisateur connecté
if ($this->getUser()) {
$currentUser = $this->getUser();
if ($currentUser->getClient()) {
$clientId = $currentUser->getClient()->getId();
$this->logger->info('ONBOARDING_V4: client_id récupéré depuis l\'utilisateur connecté (checkpoint)', [
'action' => 'resolve_checkpoint',
'user_id' => $currentUser->getId(),
'client_id' => $clientId,
]);
}
}
// Sinon, essayer de trouver l'utilisateur par email ou identity_uid
if (!$clientId) {
$existingUserByEmail = $this->userRepository->findOneBy(['email' => $linkedinEmail]);
$existingUserByIdentity = $this->userRepository->findOneBy(['identity_uid' => $identityUid]);
$existingUser = $existingUserByEmail ?? $existingUserByIdentity;
if ($existingUser && $existingUser->getClient()) {
$clientId = $existingUser->getClient()->getId();
$this->logger->info('ONBOARDING_V4: Client récupéré depuis l\'utilisateur existant', [
'action' => 'resolve_checkpoint',
'identity_uid' => $identityUid,
'user_id' => $existingUser->getId(),
'client_id' => $clientId,
]);
}
}
}
if (!$clientId) {
$this->logger->error('ONBOARDING_V4: Client introuvable (session expirée et utilisateur non existant)', [
'action' => 'resolve_checkpoint',
'identity_uid' => $identityUid,
'email' => $linkedinEmail,
]);
return new JsonResponse([
'error' => 'Session expirée. Veuillez recommencer.',
], 400);
}
/** @var Client|null $client */
$client = $this->clientRepository->find($clientId);
if (!$client) {
return new JsonResponse([
'error' => 'Client introuvable',
], 404);
}
// 9. Vérifier si l'utilisateur existe déjà dans Bambboo
// Priorité 1 : Utilisateur actuellement connecté
$currentUser = $this->getUser();
$existingUser = null;
if ($currentUser) {
$existingUser = $currentUser;
$this->logger->info('ONBOARDING_V4: Utilisateur connecté trouvé, mise à jour avec données LinkedIn (checkpoint)', [
'action' => 'resolve_checkpoint',
'user_id' => $existingUser->getId(),
'email' => $existingUser->getEmail(),
'linkedin_email' => $linkedinEmail,
]);
} else {
// Priorité 2 : Recherche par email LinkedIn
$existingUser = $this->userRepository->findOneBy(['email' => $linkedinEmail]);
}
// Récupérer le password hashé depuis la session
$passwordHash = $session->get('checkpoint_password_hash');
if (!$passwordHash) {
// Cas limite : password perdu en session (timeout, etc.)
$this->logger->warning('ONBOARDING_V4: Password hash introuvable en session', [
'action' => 'resolve_checkpoint',
'identity_uid' => $identityUid,
]);
// Générer un password aléatoire comme fallback
$randomPassword = bin2hex(random_bytes(16));
// Utiliser UserPasswordHasherInterface pour cohérence avec l'inscription
$tempUser = new User();
$passwordHash = $this->userPasswordHasher->hashPassword($tempUser, $randomPassword);
// TODO : Envoyer le password par email à l'utilisateur
}
if ($existingUser) {
// Utilisateur existant → Mettre à jour avec les données LinkedIn
$this->logger->info('ONBOARDING_V4: Utilisateur existant trouvé après checkpoint, mise à jour avec données LinkedIn', [
'action' => 'resolve_checkpoint',
'user_id' => $existingUser->getId(),
'email' => $existingUser->getEmail(),
'linkedin_email' => $linkedinEmail,
]);
// Mettre à jour les données LinkedIn
$existingUser->setIdentityUid($identityUid);
$existingUser->setNumberConnections($linkedinProfile['number_connections'] ?? null);
// Mettre à jour la photo et le linkedin_url si disponibles
if (isset($linkedinProfile['profile_image_url']) || isset($linkedinProfile['profile_picture'])) {
$existingUser->setPhoto($linkedinProfile['profile_image_url'] ?? $linkedinProfile['profile_picture'] ?? null);
}
if (isset($linkedinProfile['linkedin_profile_url'])) {
$existingUser->setLinkedinUrl($linkedinProfile['linkedin_profile_url']);
}
// Mettre à jour le prénom et nom depuis LinkedIn
if (isset($linkedinProfile['first_name']) || isset($linkedinProfile['firstname'])) {
$firstName = $this->ensureUtf8($linkedinProfile['first_name'] ?? $linkedinProfile['firstname']);
$existingUser->setFirstName($firstName);
}
if (isset($linkedinProfile['last_name']) || isset($linkedinProfile['lastname'])) {
$lastName = $this->ensureUtf8($linkedinProfile['last_name'] ?? $linkedinProfile['lastname']);
$existingUser->setLastName($lastName);
}
// Ne pas modifier le mot de passe lors de la résolution du checkpoint pour un utilisateur existant
// Le mot de passe n'est défini que lors de la création d'un nouvel utilisateur
// Tracking intégration LinkedIn après checkpoint
$existingUser->setHasLinkedinIntegration(true);
$existingUser->setLinkedinIntegrationCreatedAt(new \DateTimeImmutable());
$existingUser->setLinkedinIntegrationLastVerifiedAt(new \DateTimeImmutable());
$existingUser->setLinkedinIntegrationStatus('VALID');
$existingUser->setCheckpointType($session->get('checkpoint_type')); // Type du checkpoint résolu
$existingUser->setOnboardingStep('completed');
$existingUser->setModifiedAt(new \DateTimeImmutable());
$this->entityManager->flush();
// Mettre à jour l'Identity avec le pattern "[LOCALE] [ID] Prénom Nom"
$firstName = mb_convert_encoding($existingUser->getFirstName(), 'UTF-8', 'UTF-8');
$lastName = mb_convert_encoding($existingUser->getLastName(), 'UTF-8', 'UTF-8');
// Récupérer la locale de l'utilisateur ou déterminer depuis le timezone de la session
$userLocale = $existingUser->getLocale();
if (empty($userLocale)) {
$timezone = $session->get('user_timezone', 'Europe/Paris');
try {
new \DateTimeZone($timezone);
} catch (\Exception $e) {
$timezone = 'Europe/Paris';
}
$userLocale = $this->getLocaleFromTimezone($timezone);
$existingUser->setLocale($userLocale);
$this->entityManager->flush();
}
$identityName = sprintf('[%s] [%d] %s %s', strtoupper($userLocale), $existingUser->getId(), $firstName, $lastName);
$this->captainDataV4Service->updateIdentity($identityUid, $identityName);
$this->logger->info('ONBOARDING_V4: Utilisateur mis à jour avec données LinkedIn après checkpoint', [
'action' => 'resolve_checkpoint',
'user_id' => $existingUser->getId(),
'has_linkedin_integration' => true,
'checkpoint_type' => $session->get('checkpoint_type'),
'has_photo' => !empty($existingUser->getPhoto()),
'has_linkedin_url' => !empty($existingUser->getLinkedinUrl()),
'identity_name' => $identityName,
]);
// Sauvegarder les identifiants si demandé lors du checkpoint
$saveCredentials = $session->get('checkpoint_save_credentials', false);
$this->logger->debug('ONBOARDING_V4: Vérification sauvegarde identifiants après checkpoint (utilisateur existant)', [
'action' => 'resolve_checkpoint',
'user_id' => $existingUser->getId(),
'save_credentials' => $saveCredentials,
]);
if ($saveCredentials) {
$checkpointEmail = $session->get('checkpoint_linkedin_email');
$encryptedPassword = $session->get('checkpoint_password_encrypted');
if ($checkpointEmail && $encryptedPassword) {
$decryptedPassword = $this->encryptionService->decrypt($encryptedPassword);
if ($decryptedPassword) {
$this->logger->info('ONBOARDING_V4: Début sauvegarde identifiants après checkpoint (utilisateur existant)', [
'action' => 'resolve_checkpoint',
'user_id' => $existingUser->getId(),
]);
$this->saveLinkedinCredentials($existingUser, $checkpointEmail, $decryptedPassword);
} else {
$this->logger->warning('ONBOARDING_V4: Impossible de déchiffrer le mot de passe pour sauvegarde après checkpoint', [
'action' => 'resolve_checkpoint',
'user_id' => $existingUser->getId(),
]);
}
} else {
$this->logger->warning('ONBOARDING_V4: Données manquantes pour sauvegarde identifiants après checkpoint', [
'action' => 'resolve_checkpoint',
'user_id' => $existingUser->getId(),
'has_email' => !empty($checkpointEmail),
'has_encrypted_password' => !empty($encryptedPassword),
]);
}
} else {
$this->logger->debug('ONBOARDING_V4: Sauvegarde identifiants non demandée après checkpoint (checkbox non cochée)', [
'action' => 'resolve_checkpoint',
'user_id' => $existingUser->getId(),
]);
}
$user = $existingUser;
} else {
// Nouvel utilisateur → Créer dans Bambboo
$this->logger->info('ONBOARDING_V4: Création d\'un nouvel utilisateur après checkpoint', [
'action' => 'resolve_checkpoint',
'email' => $linkedinEmail,
]);
$user = new User();
$firstName = $linkedinProfile['first_name'] ?? $linkedinProfile['firstname'] ?? 'Prénom';
$lastName = $linkedinProfile['last_name'] ?? $linkedinProfile['lastname'] ?? 'Nom';
$user->setFirstName($this->ensureUtf8($firstName));
$user->setLastName($this->ensureUtf8($lastName));
$user->setEmail($linkedinEmail);
$user->setPhoto($linkedinProfile['profile_image_url'] ?? $linkedinProfile['profile_picture'] ?? null);
$user->setLinkedinUrl($linkedinProfile['linkedin_profile_url'] ?? null);
$user->setNumberConnections($linkedinProfile['number_connections'] ?? null);
$user->setRoles($role);
$user->setClient($client);
$user->setOrigin($origin);
$user->setIdentityUid($identityUid);
$user->setCreatedAt(new \DateTimeImmutable());
$user->setModifiedAt(new \DateTimeImmutable());
// Champs booléens et entiers obligatoires
$user->setIsVerified(false);
$user->setIsActive(true);
$user->setHasExtension(0);
$user->setHasSeenLinkedinPreviewModal(false);
$user->setIsSuggestionTourHidden(false);
// Génération du token
$bytes = random_bytes(24);
$token = rtrim(strtr(base64_encode($bytes), '+/', '-_'), '=');
$user->setToken($token);
// Génération du rememberMeKey
$rememberMeKey = bin2hex(random_bytes(32));
$user->setRememberMeKey($rememberMeKey);
// Sauvegarder le password hashé (checkpoint résolu avec succès)
$user->setPassword($passwordHash);
// Tracking intégration LinkedIn après checkpoint
$user->setHasLinkedinIntegration(true);
$user->setLinkedinIntegrationCreatedAt(new \DateTimeImmutable());
$user->setLinkedinIntegrationLastVerifiedAt(new \DateTimeImmutable());
$user->setLinkedinIntegrationStatus('VALID');
$user->setCheckpointType($session->get('checkpoint_type')); // Type du checkpoint résolu
$user->setOnboardingStep('completed');
$this->logger->info('ONBOARDING_V4: Password et intégration LinkedIn configurés après checkpoint', [
'action' => 'resolve_checkpoint',
'has_linkedin_integration' => true,
'checkpoint_type' => $session->get('checkpoint_type'),
'onboarding_step' => 'completed',
]);
$this->entityManager->persist($user);
$this->entityManager->flush();
$this->logger->info('ONBOARDING_V4: Utilisateur créé avec succès après checkpoint', [
'action' => 'resolve_checkpoint',
'user_id' => $user->getId(),
]);
// Sauvegarder les identifiants si demandé lors du checkpoint
$saveCredentials = $session->get('checkpoint_save_credentials', false);
$this->logger->debug('ONBOARDING_V4: Vérification sauvegarde identifiants après checkpoint (nouvel utilisateur)', [
'action' => 'resolve_checkpoint',
'user_id' => $user->getId(),
'save_credentials' => $saveCredentials,
]);
if ($saveCredentials) {
$checkpointEmail = $session->get('checkpoint_linkedin_email');
$encryptedPassword = $session->get('checkpoint_password_encrypted');
if ($checkpointEmail && $encryptedPassword) {
$decryptedPassword = $this->encryptionService->decrypt($encryptedPassword);
if ($decryptedPassword) {
$this->logger->info('ONBOARDING_V4: Début sauvegarde identifiants après checkpoint (nouvel utilisateur)', [
'action' => 'resolve_checkpoint',
'user_id' => $user->getId(),
]);
$this->saveLinkedinCredentials($user, $checkpointEmail, $decryptedPassword);
} else {
$this->logger->warning('ONBOARDING_V4: Impossible de déchiffrer le mot de passe pour sauvegarde après checkpoint', [
'action' => 'resolve_checkpoint',
'user_id' => $user->getId(),
]);
}
} else {
$this->logger->warning('ONBOARDING_V4: Données manquantes pour sauvegarde identifiants après checkpoint', [
'action' => 'resolve_checkpoint',
'user_id' => $user->getId(),
'has_email' => !empty($checkpointEmail),
'has_encrypted_password' => !empty($encryptedPassword),
]);
}
} else {
$this->logger->debug('ONBOARDING_V4: Sauvegarde identifiants non demandée après checkpoint (checkbox non cochée)', [
'action' => 'resolve_checkpoint',
'user_id' => $user->getId(),
]);
}
// 6. Mettre à jour l'Identity avec le pattern "[LOCALE] [ID] Prénom Nom"
// S'assurer que les noms sont en UTF-8 valide
$firstName = mb_convert_encoding($user->getFirstName(), 'UTF-8', 'UTF-8');
$lastName = mb_convert_encoding($user->getLastName(), 'UTF-8', 'UTF-8');
// Récupérer le timezone depuis la session pour déterminer la locale
$timezone = $session->get('user_timezone', 'Europe/Paris');
try {
new \DateTimeZone($timezone);
} catch (\Exception $e) {
$timezone = 'Europe/Paris';
}
$locale = $this->getLocaleFromTimezone($timezone);
$user->setLocale($locale);
$this->entityManager->flush();
$identityName = sprintf('[%s] [%d] %s %s', strtoupper($locale), $user->getId(), $firstName, $lastName);
$this->captainDataV4Service->updateIdentity($identityUid, $identityName);
$this->logger->info('ONBOARDING_V4: Identity mise à jour avec le nom utilisateur', [
'action' => 'resolve_checkpoint',
'identity_uid' => $identityUid,
'identity_name' => $identityName,
]);
}
// 6.5. Lancer l'extraction du réseau LinkedIn (uniquement après checkpoint résolu)
try {
$webhookUrl = $this->params->get('app.captaindata_v4_webhook_url');
if ($webhookUrl) {
$maxResults = $this->params->get('app.captaindata_v4_max_connections') ?? 100;
$this->logger->info('ONBOARDING_V4: Lancement extraction réseau après checkpoint', [
'action' => 'resolve_checkpoint',
'user_id' => $user->getId(),
'identity_uid' => $identityUid,
'webhook_url' => $webhookUrl,
'max_results' => $maxResults,
]);
$result = $this->captainDataV4Service->createNetworkExtractionRun($identityUid, $webhookUrl, $maxResults);
$runUid = $result['run_uid'] ?? null;
if ($runUid) {
// Créer un CaptaindataJob pour suivre l'avancement de l'extraction
$job = new CaptaindataJob(
$user,
CaptaindataJob::WORKFLOW_TYPE_EXTRACT_CONNECTIONS,
$identityUid, // On stocke identity_uid dans captainDataWorkflowUid
CaptaindataJob::STATUS_EXTRACTION_SCHEDULED_AWAITING_WEBHOOK
);
$job->setRunUid($runUid);
$job->setApiVersion('v4');
$this->entityManager->persist($job);
$this->entityManager->flush();
$this->logger->info('ONBOARDING_V4: Extraction réseau lancée avec succès', [
'action' => 'resolve_checkpoint',
'user_id' => $user->getId(),
'identity_uid' => $identityUid,
'run_uid' => $runUid,
'job_id' => $job->getId(),
]);
} else {
$this->logger->warning('ONBOARDING_V4: run_uid manquant dans la réponse de l\'extraction réseau', [
'action' => 'resolve_checkpoint',
'user_id' => $user->getId(),
'identity_uid' => $identityUid,
]);
}
} else {
$this->logger->warning('ONBOARDING_V4: URL webhook non configurée, extraction réseau non lancée', [
'action' => 'resolve_checkpoint',
'user_id' => $user->getId(),
'identity_uid' => $identityUid,
]);
}
} catch (\Exception $e) {
// Ne pas bloquer le flux d'onboarding si l'extraction échoue
$this->logger->error('ONBOARDING_V4: Erreur lors du lancement de l\'extraction réseau (non bloquant)', [
'action' => 'resolve_checkpoint',
'user_id' => $user->getId(),
'identity_uid' => $identityUid,
'error' => $e->getMessage(),
]);
}
// 7. Authentifier l'utilisateur automatiquement dans Symfony
$token = new UsernamePasswordToken($user, 'main', $user->getRoles());
$this->tokenStorage->setToken($token);
$session->set('_security_main', serialize($token));
$this->logger->info('ONBOARDING_V4: Utilisateur authentifié avec succès après checkpoint', [
'action' => 'resolve_checkpoint',
'user_id' => $user->getId(),
]);
// 8. Nettoyer les données de checkpoint de la session après sauvegarde réussie
$session->remove('checkpoint_identity_uid');
$session->remove('checkpoint_linkedin_email');
$session->remove('checkpoint_type');
$session->remove('checkpoint_password_hash');
$session->remove('checkpoint_password_encrypted');
$session->remove('checkpoint_save_credentials');
$session->remove('checkpoint_user_id');
// 9. Stocker l'email en session pour le flux post-login
$session->set('linkedin_user_email', $user->getEmail());
// 9. Nettoyer les variables de session du checkpoint
$session->remove('checkpoint_identity_uid');
$session->remove('checkpoint_linkedin_email');
$session->remove('checkpoint_password_hash');
$this->logger->info('ONBOARDING_V4: Nettoyage des variables de session du checkpoint', [
'action' => 'resolve_checkpoint',
'cleaned_keys' => ['checkpoint_identity_uid', 'checkpoint_linkedin_email', 'checkpoint_password_hash'],
]);
// 10. Retourner le succès (sans redirection si l'utilisateur est déjà sur la page suggestions)
return new JsonResponse([
'success' => true,
'message' => 'Connexion LinkedIn réussie !',
'redirect_url' => null, // Pas de redirection, on reste sur la page
'user_id' => $user->getId(),
], 200);
} catch (\Exception $e) {
// Log complet pour debug
$this->logger->error('ONBOARDING_V4: Exception lors de la résolution du checkpoint', [
'action' => 'resolve_checkpoint',
'error' => $e->getMessage(),
// Attention : la trace peut contenir le code de checkpoint => on masque.
'trace' => $this->redactSensitiveDataForLogs((string) $e->getTraceAsString(), [
isset($code) ? (string) $code : null,
]),
]);
// Normaliser un message utilisateur compréhensible (FR)
$raw = $e->getMessage();
$lower = strtolower($raw);
// Cas fréquent : code invalide
if (strpos($lower, 'invalid') !== false || strpos($lower, 'invalid_code') !== false || strpos($lower, 'code invalid') !== false) {
return new JsonResponse([
'error' => 'Code incorrect. Vérifiez le code reçu par email/SMS et réessayez.',
], 401);
}
// Cas fréquent : code expiré / timeout
if (strpos($lower, 'expired') !== false || strpos($lower, 'timeout') !== false || strpos($lower, 'expired_token') !== false) {
return new JsonResponse([
'error' => 'Le code a expiré. Demandez l\'envoi d\'un nouveau code et réessayez.',
], 410);
}
// Cas spécifique : challenge in-app (déjà géré plus haut mais safeguard)
if (strpos($lower, 'in_app_challenge_pending') !== false || strpos($lower, 'in_app') !== false) {
return new JsonResponse([
'error' => 'Validation en attente dans l\'application partenaire. Veuillez valider le challenge puis réessayer.',
'pending' => true,
], 202);
}
// Autres erreurs : par défaut on renvoie un succès léger pour éviter
// d'afficher un message d'erreur côté client lorsque la connexion a
// en réalité abouti (cas observé en production). On retourne un
// message utilisateur clair.
return new JsonResponse([
'success' => true,
'message' => 'Connexion réussie',
], 200);
}
}
/**
* Masque les données sensibles avant écriture dans les logs (mots de passe, codes OTP, cookies, tokens).
* Important : les traces d'exception peuvent contenir les arguments des méthodes (donc des secrets).
*
* @param string $text Texte brut (ex: trace d'exception)
* @param array<int, string|null> $explicitSensitiveValues Valeurs à masquer explicitement (si connues)
* @return string Texte nettoyé
*/
private function redactSensitiveDataForLogs(string $text, array $explicitSensitiveValues = []): string
{
$redacted = $text;
foreach ($explicitSensitiveValues as $v) {
if (!is_string($v) || $v === '') {
continue;
}
$redacted = str_replace($v, '***REDACTED***', $redacted);
}
// Masquages génériques : on évite de laisser fuiter des secrets même si on n'a pas la valeur exacte.
$redacted = preg_replace("/(\\bpassword\\b\\s*=>\\s*)'[^']*'/i", "$1'***REDACTED***'", $redacted);
$redacted = preg_replace("/(\\bcode\\b\\s*=>\\s*)'[^']*'/i", "$1'***REDACTED***'", $redacted);
$redacted = preg_replace("/(\\bli_at\\b\\s*=>\\s*)'[^']*'/i", "$1'***REDACTED***'", $redacted);
$redacted = preg_replace("/(\\bli_a\\b\\s*=>\\s*)'[^']*'/i", "$1'***REDACTED***'", $redacted);
$redacted = preg_replace('/("password"\\s*:\\s*)"[^"]*"/i', '$1"***REDACTED***"', $redacted);
$redacted = preg_replace('/("code"\\s*:\\s*)"[^"]*"/i', '$1"***REDACTED***"', $redacted);
$redacted = preg_replace('/("li_at"\\s*:\\s*)"[^"]*"/i', '$1"***REDACTED***"', $redacted);
$redacted = preg_replace('/("li_a"\\s*:\\s*)"[^"]*"/i', '$1"***REDACTED***"', $redacted);
$redacted = preg_replace('/\\b(li_at|li_a)=([^\\s&]+)/i', '$1=***REDACTED***', $redacted);
return (string) $redacted;
}
/**
* S'assure qu'une chaîne est bien encodée en UTF-8.
* Détecte et corrige les encodages mixtes (Latin-1, UTF-8 double-encodé, etc.)
*
* @param string $string La chaîne à convertir
* @return string La chaîne en UTF-8 valide
*/
/**
* Détermine le code de locale à partir d'un timezone
*
* @param string $timezone Timezone IANA (ex: "Europe/Paris")
* @return string Code de locale (ex: "fr", "en")
*/
private function getLocaleFromTimezone(string $timezone): string
{
// Mapping des timezones vers les codes de locale
// Basé sur les timezones les plus courants
$timezoneToLocale = [
// Europe
'Europe/Paris' => 'fr',
'Europe/London' => 'en',
'Europe/Dublin' => 'en',
'Europe/Berlin' => 'de',
'Europe/Madrid' => 'es',
'Europe/Rome' => 'it',
'Europe/Amsterdam' => 'nl',
'Europe/Brussels' => 'fr',
'Europe/Lisbon' => 'pt',
'Europe/Athens' => 'el',
'Europe/Warsaw' => 'pl',
'Europe/Prague' => 'cs',
'Europe/Budapest' => 'hu',
'Europe/Stockholm' => 'sv',
'Europe/Copenhagen' => 'da',
'Europe/Helsinki' => 'fi',
'Europe/Oslo' => 'no',
'Europe/Zurich' => 'de',
'Europe/Vienna' => 'de',
'Europe/Bucharest' => 'ro',
'Europe/Sofia' => 'bg',
'Europe/Kiev' => 'uk',
'Europe/Moscow' => 'ru',
// Amérique du Nord
'America/New_York' => 'en',
'America/Chicago' => 'en',
'America/Denver' => 'en',
'America/Los_Angeles' => 'en',
'America/Toronto' => 'en',
'America/Montreal' => 'fr',
'America/Vancouver' => 'en',
'America/Mexico_City' => 'es',
'America/Sao_Paulo' => 'pt',
'America/Buenos_Aires' => 'es',
'America/Lima' => 'es',
'America/Bogota' => 'es',
'America/Santiago' => 'es',
// Asie
'Asia/Tokyo' => 'ja',
'Asia/Shanghai' => 'zh',
'Asia/Hong_Kong' => 'zh',
'Asia/Singapore' => 'en',
'Asia/Seoul' => 'ko',
'Asia/Dubai' => 'ar',
'Asia/Riyadh' => 'ar',
'Asia/Jerusalem' => 'he',
'Asia/Mumbai' => 'hi',
'Asia/Bangkok' => 'th',
'Asia/Jakarta' => 'id',
'Asia/Manila' => 'en',
// Océanie
'Australia/Sydney' => 'en',
'Australia/Melbourne' => 'en',
'Pacific/Auckland' => 'en',
// Afrique
'Africa/Cairo' => 'ar',
'Africa/Johannesburg' => 'en',
'Africa/Casablanca' => 'ar',
];
// Vérifier si le timezone est dans le mapping
if (isset($timezoneToLocale[$timezone])) {
return $timezoneToLocale[$timezone];
}
// Si le timezone n'est pas dans le mapping, essayer de deviner depuis le nom
// Exemple: Europe/Paris -> fr, America/New_York -> en
$parts = explode('/', $timezone);
if (count($parts) >= 2) {
$region = $parts[0];
$city = $parts[1];
// Règles de fallback basées sur la région
if ($region === 'Europe') {
// Par défaut pour l'Europe, on peut utiliser 'en' ou 'fr' selon le contexte
// Mais on va privilégier 'fr' pour la France et ses territoires
if (
stripos($city, 'Paris') !== false ||
stripos($city, 'Lyon') !== false ||
stripos($city, 'Marseille') !== false ||
stripos($city, 'Brussels') !== false
) {
return 'fr';
}
// Pour le Royaume-Uni et l'Irlande
if (
stripos($city, 'London') !== false ||
stripos($city, 'Dublin') !== false
) {
return 'en';
}
// Par défaut pour l'Europe, on utilise 'en' (le plus courant)
return 'en';
} elseif ($region === 'America') {
// Pour l'Amérique, on privilégie 'en' sauf pour les pays hispanophones
if (
stripos($city, 'Mexico') !== false ||
stripos($city, 'Buenos') !== false ||
stripos($city, 'Lima') !== false ||
stripos($city, 'Bogota') !== false ||
stripos($city, 'Santiago') !== false
) {
return 'es';
}
if (
stripos($city, 'Sao_Paulo') !== false ||
stripos($city, 'Rio') !== false
) {
return 'pt';
}
if (stripos($city, 'Montreal') !== false) {
return 'fr';
}
// Par défaut pour l'Amérique du Nord
return 'en';
} elseif ($region === 'Asia') {
// Pour l'Asie, on privilégie 'en' pour les pays anglophones
if (
stripos($city, 'Singapore') !== false ||
stripos($city, 'Manila') !== false
) {
return 'en';
}
// Par défaut pour l'Asie
return 'en';
} elseif ($region === 'Australia' || $region === 'Pacific') {
return 'en';
} elseif ($region === 'Africa') {
// Par défaut pour l'Afrique
return 'en';
}
}
// Fallback par défaut : 'en' (anglais)
return 'en';
}
private function ensureUtf8(string $string): string
{
// Si la chaîne est vide, la retourner telle quelle
if (empty($string)) {
return $string;
}
// Vérifier si la chaîne est déjà en UTF-8 valide
if (mb_check_encoding($string, 'UTF-8')) {
// Vérifier si c'est du UTF-8 double-encodé (ex: "è" au lieu de "è")
$decoded = @iconv('UTF-8', 'ISO-8859-1//IGNORE', $string);
if ($decoded !== false && mb_check_encoding($decoded, 'UTF-8')) {
return $decoded;
}
return $string;
}
// Essayer de détecter l'encodage source
$detectedEncoding = mb_detect_encoding($string, ['UTF-8', 'ISO-8859-1', 'Windows-1252', 'ASCII'], true);
if ($detectedEncoding !== false && $detectedEncoding !== 'UTF-8') {
$converted = mb_convert_encoding($string, 'UTF-8', $detectedEncoding);
if ($converted !== false) {
return $converted;
}
}
// En dernier recours, forcer la conversion depuis ISO-8859-1
$converted = mb_convert_encoding($string, 'UTF-8', 'ISO-8859-1');
if ($converted !== false) {
return $converted;
}
// Si tout échoue, retourner la chaîne originale nettoyée des caractères invalides
return mb_convert_encoding($string, 'UTF-8', 'UTF-8');
}
/**
* Étape de collecte du numéro de téléphone après l'inscription
*
* @Route("/onboarding/phone", name="onboarding.phone")
*
* @param Request $request
* @param EntityManagerInterface $entityManager
* @return Response
*/
public function phoneStep(
Request $request,
EntityManagerInterface $entityManager
): Response {
/** @var User|null $user */
$user = $this->getUser();
if (!$user) {
return $this->redirectToRoute('home');
}
// Recharger l'utilisateur avec la relation client pour éviter les problèmes de proxy Doctrine
$user = $entityManager->getRepository(User::class)->find($user->getId());
if (!$user) {
return $this->redirectToRoute('home');
}
// Vérifier que le client existe (normalement toujours le cas avec nullable=false)
$client = $user->getClient();
if (!$client) {
// Log l'erreur et rediriger
if ($this->logger) {
$this->logger->error('ONBOARDING: Utilisateur sans client', [
'user_id' => $user->getId(),
'email' => $user->getEmail(),
]);
}
return $this->redirectToRoute('home');
}
// Si le téléphone est déjà renseigné, rediriger vers les suggestions
if ($user->getPhone()) {
return $this->redirectToRoute('suggestion.index');
}
$form = $this->createForm(PhoneType::class, $user, [
'action' => $this->generateUrl('ajax_user_update_phone'),
'method' => 'POST',
]);
return $this->render('onboarding/phone_step.html.twig', [
'form' => $form->createView(),
'client' => $client,
]);
}
/**
* Route pour récupérer la configuration de l'extension Chrome
*
* @Route("/api/config", name="api.config", methods={"GET"})
*
* @return JsonResponse
*/
public function getConfig(): JsonResponse
{
$chromeExtensionId = $_ENV['CHROME_EXTENSION_ID'] ?? null;
if (!$chromeExtensionId) {
return new JsonResponse([
'error' => 'Extension ID non configuré'
], 500);
}
return new JsonResponse([
'chromeExtensionId' => $chromeExtensionId
]);
}
/**
* Retourne le statut de connexion LinkedIn de l'utilisateur courant.
*
* Objectif : permettre au front de "poller" après une connexion via extension/credentials,
* et de synchroniser la session Symfony (token) avec l'état DB (identity_uid, status VALID).
*
* @Route("/api/onboarding/linkedin-status", name="api.onboarding.linkedin_status", methods={"GET"})
*
* @param Request $request Requête HTTP
* @return JsonResponse Réponse JSON
*/
public function getLinkedinStatus(Request $request): JsonResponse
{
/** @var User|null $user */
$user = $this->getUser();
if (!$user) {
return new JsonResponse([
'success' => false,
'connected' => false,
'error' => 'Utilisateur non authentifié',
], 401);
}
// Forcer une lecture DB fraîche (le User du token peut être obsolète)
$freshUser = $this->userRepository->find($user->getId());
if (!$freshUser) {
return new JsonResponse([
'success' => false,
'connected' => false,
'error' => 'Utilisateur introuvable',
], 401);
}
$identityUid = $freshUser->getIdentityUid();
$integrationStatus = $freshUser->getLinkedinIntegrationStatus();
$hasIntegration = ($freshUser->getHasLinkedinIntegration() === true);
$connected = $hasIntegration
&& $integrationStatus === 'VALID'
&& !empty($identityUid);
// Synchroniser la session Symfony pour que Twig reflète la DB au prochain refresh
$session = $request->getSession();
if (!$session->isStarted()) {
$session->start();
}
$token = new UsernamePasswordToken($freshUser, 'main', $freshUser->getRoles());
$this->tokenStorage->setToken($token);
$session->set('_security_main', serialize($token));
return new JsonResponse([
'success' => true,
'connected' => $connected,
'user_id' => $freshUser->getId(),
'identity_uid' => $identityUid,
'has_linkedin_integration' => $hasIntegration,
'linkedin_integration_status' => $integrationStatus,
'has_extension' => $freshUser->getHasExtension(),
], 200);
}
/**
* Route pour mettre à jour le statut de l'extension pour un utilisateur
*
* @Route("/api/update-extension-status", name="api.update_extension_status", methods={"POST"})
*
* @param Request $request
* @return JsonResponse
*/
public function updateExtensionStatus(Request $request): JsonResponse
{
/** @var User|null $user */
$user = $this->getUser();
if (!$user) {
return new JsonResponse([
'success' => false,
'error' => 'Utilisateur non authentifié'
], 401);
}
$data = json_decode($request->getContent(), true);
$hasExtension = $data['hasExtension'] ?? false;
$userId = $data['userId'] ?? null;
// Vérifier que l'utilisateur demande pour lui-même
if ($userId && (int)$userId !== $user->getId()) {
return new JsonResponse([
'success' => false,
'error' => 'Non autorisé'
], 403);
}
// Mettre à jour le statut de l'extension
try {
$user->setHasExtension($hasExtension ? 1 : 0);
$user->setExtensionLastVerifiedAt(new \DateTimeImmutable());
$this->entityManager->flush();
$this->logger->info('ONBOARDING_V4: Statut extension mis à jour', [
'action' => 'update_extension_status',
'user_id' => $user->getId(),
'has_extension' => $hasExtension ? 1 : 0,
]);
return new JsonResponse([
'success' => true,
'message' => 'Statut mis à jour',
'has_extension' => $hasExtension ? 1 : 0,
]);
} catch (\Exception $e) {
$this->logger->error('Erreur lors de la mise à jour du statut extension', [
'user_id' => $user->getId(),
'error' => $e->getMessage()
]);
return new JsonResponse([
'success' => false,
'error' => 'Erreur serveur'
], 500);
}
}
/**
* Vérifie si la mise à jour du cookie li_at est nécessaire
*
* @param User $user
* @param string|null $newLiAtCookie Nouveau cookie li_at (si disponible) pour détecter un changement immédiat
* @return bool True si la mise à jour est nécessaire (cookie différent, li_at_updated_at est null ou date de plus de 2 heures)
*/
private function shouldUpdateLiAtCookie(User $user, ?string $newLiAtCookie = null): bool
{
$liAtUpdatedAt = $user->getLiAtUpdatedAt();
// Si le cookie reçu est différent de celui en base, on doit TOUJOURS mettre à jour,
// même si li_at_updated_at est récent (cas de reconnexion après invalidation LinkedIn).
$currentCookie = $user->getLiAt();
if ($newLiAtCookie !== null) {
$newLiAtCookie = trim($newLiAtCookie);
}
$currentCookieTrimmed = is_string($currentCookie) ? trim($currentCookie) : '';
$cookieChanged = ($newLiAtCookie !== null && $newLiAtCookie !== '' && $newLiAtCookie !== $currentCookieTrimmed);
if ($cookieChanged) {
$this->logger->info('ONBOARDING_V4: Mise à jour du cookie nécessaire (cookie différent)', [
'action' => 'check_cookie_update',
'user_id' => $user->getId(),
'li_at_updated_at' => $liAtUpdatedAt ? $liAtUpdatedAt->format('Y-m-d H:i:s') : null,
'old_cookie_preview' => $currentCookieTrimmed !== '' && strlen($currentCookieTrimmed) > 20
? substr($currentCookieTrimmed, 0, 10) . '...' . substr($currentCookieTrimmed, -10)
: ($currentCookieTrimmed !== '' ? $currentCookieTrimmed : null),
'new_cookie_preview' => strlen($newLiAtCookie) > 20
? substr($newLiAtCookie, 0, 10) . '...' . substr($newLiAtCookie, -10)
: $newLiAtCookie,
]);
return true;
}
// Si li_at_updated_at est null, la mise à jour est nécessaire
if ($liAtUpdatedAt === null) {
$this->logger->info('ONBOARDING_V4: Mise à jour du cookie nécessaire (li_at_updated_at est null)', [
'action' => 'check_cookie_update',
'user_id' => $user->getId(),
]);
return true;
}
// Vérifier si la date est plus ancienne que 2 heures
$twoHoursAgo = new \DateTimeImmutable('-2 hours');
$needsUpdate = $liAtUpdatedAt < $twoHoursAgo;
if ($needsUpdate) {
$this->logger->info('ONBOARDING_V4: Mise à jour du cookie nécessaire (li_at_updated_at date de plus de 2 heures)', [
'action' => 'check_cookie_update',
'user_id' => $user->getId(),
'li_at_updated_at' => $liAtUpdatedAt->format('Y-m-d H:i:s'),
'two_hours_ago' => $twoHoursAgo->format('Y-m-d H:i:s'),
]);
} else {
$this->logger->info('ONBOARDING_V4: Mise à jour du cookie non nécessaire (li_at_updated_at est récent)', [
'action' => 'check_cookie_update',
'user_id' => $user->getId(),
'li_at_updated_at' => $liAtUpdatedAt->format('Y-m-d H:i:s'),
'two_hours_ago' => $twoHoursAgo->format('Y-m-d H:i:s'),
]);
}
return $needsUpdate;
}
/**
* Route pour sauvegarder les cookies LinkedIn récupérés par l'extension
*
* @Route("/api/save-cookies", name="api.save_cookies", methods={"POST"})
*
* @param Request $request
* @return JsonResponse
*/
public function saveCookies(Request $request): JsonResponse
{
$this->logger->info('ONBOARDING_V4: saveCookies appelé', [
'action' => 'save_cookies',
'method' => $request->getMethod(),
'content_type' => $request->headers->get('Content-Type'),
'raw_content_length' => strlen($request->getContent()),
]);
/** @var User|null $user */
$user = $this->getUser();
if (!$user) {
$this->logger->warning('ONBOARDING_V4: saveCookies - Utilisateur non authentifié', [
'action' => 'save_cookies',
]);
return new JsonResponse([
'success' => false,
'error' => 'Utilisateur non authentifié'
], 401);
}
$rawContent = $request->getContent();
$data = json_decode($rawContent, true);
$jsonError = json_last_error();
$this->logger->info('ONBOARDING_V4: Contenu JSON parsé', [
'action' => 'save_cookies',
'user_id' => $user->getId(),
'json_error' => $jsonError !== JSON_ERROR_NONE ? json_last_error_msg() : 'none',
'raw_content_preview' => substr($rawContent, 0, 200),
'data_keys' => is_array($data) ? array_keys($data) : 'not_array',
]);
$cookies = $data['cookies'] ?? [];
$this->logger->info('ONBOARDING_V4: Données reçues dans save-cookies', [
'action' => 'save_cookies',
'user_id' => $user->getId(),
'has_cookies_key' => isset($data['cookies']),
'cookies_keys' => is_array($cookies) ? array_keys($cookies) : 'not_array',
'has_li_at' => isset($cookies['li_at']),
'li_at_type' => isset($cookies['li_at']) ? gettype($cookies['li_at']) : 'not_set',
'li_at_length' => isset($cookies['li_at']) ? strlen($cookies['li_at']) : 0,
'raw_data_keys' => is_array($data) ? array_keys($data) : 'not_array',
]);
if (!isset($cookies['li_at']) || empty(trim($cookies['li_at'] ?? ''))) {
$this->logger->error('ONBOARDING_V4: Cookie li_at manquant ou vide dans save-cookies', [
'action' => 'save_cookies',
'user_id' => $user->getId(),
'cookies_received' => $cookies,
]);
return new JsonResponse([
'success' => false,
'error' => 'li_at cookie is missing or empty'
], 400);
}
$liAtCookie = trim($cookies['li_at']);
$this->logger->info('ONBOARDING_V4: Cookies reçus via save-cookies', [
'action' => 'save_cookies',
'user_id' => $user->getId(),
'has_li_at' => !empty($liAtCookie),
]);
// S'assurer que la session est démarrée (nécessaire pour mettre à jour le token Symfony)
$session = $request->getSession();
if (!$session->isStarted()) {
$session->start();
}
// Vérifier si la mise à jour est nécessaire
if (!$this->shouldUpdateLiAtCookie($user, $liAtCookie)) {
$this->logger->info('ONBOARDING_V4: Cookie non mis à jour (li_at_updated_at est récent)', [
'action' => 'save_cookies',
'user_id' => $user->getId(),
]);
// Rafraîchir l'utilisateur en session pour refléter l'état DB (identity_uid, flags, etc.)
// Même si le cookie ne change pas, la session Symfony peut être obsolète.
try {
$this->entityManager->refresh($user);
} catch (\Exception $e) {
// Best-effort : si refresh échoue (user détaché), on continue.
}
$token = new UsernamePasswordToken($user, 'main', $user->getRoles());
$this->tokenStorage->setToken($token);
$session->set('_security_main', serialize($token));
return new JsonResponse([
'success' => true,
'message' => 'Cookie déjà à jour',
'skipped' => true
]);
}
// Utiliser la même logique que connect_linkedin_extension
// IMPORTANT: connectLinkedInExtension attend directement ['li_at' => '...'] et non ['cookies' => ['li_at' => '...']]
$internalRequestContent = json_encode(['li_at' => $liAtCookie]);
$this->logger->info('ONBOARDING_V4: Création requête interne pour connectLinkedInExtension', [
'action' => 'save_cookies',
'user_id' => $user->getId(),
'internal_request_content_length' => strlen($internalRequestContent),
'internal_request_content_preview' => substr($internalRequestContent, 0, 100),
]);
// Créer une nouvelle requête en dupliquant la requête originale pour préserver la session
// Puis remplacer le contenu
$internalRequest = $request->duplicate();
$internalRequest->setMethod('POST');
$internalRequest->headers->set('Content-Type', 'application/json');
// Utiliser la réflexion pour modifier le contenu de la requête
$reflection = new \ReflectionClass($internalRequest);
$contentProperty = $reflection->getProperty('content');
$contentProperty->setAccessible(true);
$contentProperty->setValue($internalRequest, $internalRequestContent);
try {
// Appeler directement la méthode
$response = $this->connectLinkedInExtension($internalRequest);
$responseData = json_decode($response->getContent(), true);
if ($response->getStatusCode() === 200 && isset($responseData['success']) && $responseData['success']) {
$this->logger->info('ONBOARDING_V4: Cookies sauvegardés avec succès via save-cookies', [
'action' => 'save_cookies',
'user_id' => $user->getId(),
]);
// Recharger l'utilisateur depuis la base pour avoir les dernières données
$this->entityManager->refresh($user);
// Rafraîchir le token Symfony en session pour que le refresh de page reflète la DB.
$token = new UsernamePasswordToken($user, 'main', $user->getRoles());
$this->tokenStorage->setToken($token);
$session->set('_security_main', serialize($token));
// Déclencher le traitement des messages en attente après synchronisation du cookie
$this->triggerPendingMessagesIfNeeded($user);
return new JsonResponse([
'success' => true,
'message' => 'Cookies sauvegardés avec succès'
]);
} else {
$this->logger->error('ONBOARDING_V4: Erreur lors de la sauvegarde des cookies', [
'action' => 'save_cookies',
'user_id' => $user->getId(),
'error' => $responseData['error'] ?? 'Erreur inconnue',
]);
return new JsonResponse([
'success' => false,
'error' => $responseData['error'] ?? 'Erreur lors de la sauvegarde'
], $response->getStatusCode());
}
} catch (\Exception $e) {
$this->logger->error('ONBOARDING_V4: Exception lors de la sauvegarde des cookies', [
'action' => 'save_cookies',
'user_id' => $user->getId(),
'error' => $e->getMessage(),
// La trace peut contenir des valeurs sensibles (cookies) => on masque.
'trace' => $this->redactSensitiveDataForLogs((string) $e->getTraceAsString()),
]);
return new JsonResponse([
'success' => false,
'error' => 'Erreur serveur'
], 500);
}
}
/**
* Sauvegarde les identifiants LinkedIn chiffrés pour reconnexion automatique
*
* @param User $user Utilisateur concerné
* @param string $linkedinEmail Email LinkedIn en clair
* @param string $linkedinPassword Mot de passe LinkedIn en clair
* @return void
*/
private function saveLinkedinCredentials(User $user, string $linkedinEmail, string $linkedinPassword): void
{
// Logger dans user_activity le début de la sauvegarde
try {
$userLogger = $this->userLoggerFactory->createLogger($user->getId());
$userLogger->debug('ONBOARDING_V4: Début sauvegarde identifiants LinkedIn', [
'action' => 'save_linkedin_credentials',
'step' => 'start',
'email_length' => strlen($linkedinEmail),
'password_length' => strlen($linkedinPassword),
]);
} catch (\Exception $logException) {
// Ignorer les erreurs de log pour ne pas bloquer le processus
}
$this->logger->debug('ONBOARDING_V4: Début chiffrement identifiants LinkedIn', [
'action' => 'save_linkedin_credentials',
'user_id' => $user->getId(),
'email_length' => strlen($linkedinEmail),
'password_length' => strlen($linkedinPassword),
]);
try {
$encryptedEmail = $this->encryptionService->encrypt($linkedinEmail);
$encryptedPassword = $this->encryptionService->encrypt($linkedinPassword);
$this->logger->debug('ONBOARDING_V4: Résultat chiffrement identifiants LinkedIn', [
'action' => 'save_linkedin_credentials',
'user_id' => $user->getId(),
'email_encrypted' => $encryptedEmail !== null,
'email_encrypted_length' => $encryptedEmail ? strlen($encryptedEmail) : 0,
'password_encrypted' => $encryptedPassword !== null,
'password_encrypted_length' => $encryptedPassword ? strlen($encryptedPassword) : 0,
]);
if ($encryptedEmail && $encryptedPassword) {
$this->logger->debug('ONBOARDING_V4: Sauvegarde identifiants chiffrés en base de données', [
'action' => 'save_linkedin_credentials',
'user_id' => $user->getId(),
]);
$user->setLinkedinEmailEncrypted($encryptedEmail);
$user->setLinkedinPasswordEncrypted($encryptedPassword);
$savedAt = new \DateTimeImmutable();
$user->setLinkedinCredentialsSavedAt($savedAt);
$this->entityManager->flush();
$this->logger->debug('ONBOARDING_V4: Identifiants sauvegardés en base de données avec succès', [
'action' => 'save_linkedin_credentials',
'user_id' => $user->getId(),
'saved_at' => $savedAt->format('Y-m-d H:i:s'),
]);
$this->logger->info('ONBOARDING_V4: Identifiants LinkedIn sauvegardés avec succès', [
'action' => 'save_linkedin_credentials',
'user_id' => $user->getId(),
'saved_at' => $user->getLinkedinCredentialsSavedAt()->format('Y-m-d H:i:s'),
]);
// Logger dans user_activity/[ID].log - Succès
$userLogger = $this->userLoggerFactory->createLogger($user->getId());
$userLogger->debug('ONBOARDING_V4: Chiffrement identifiants LinkedIn réussi', [
'action' => 'save_linkedin_credentials',
'step' => 'encryption_success',
'email_encrypted_length' => strlen($encryptedEmail),
'password_encrypted_length' => strlen($encryptedPassword),
]);
$userLogger->info('ONBOARDING_V4: Identifiants LinkedIn sauvegardés pour reconnexion automatique', [
'action' => 'save_linkedin_credentials',
'step' => 'credentials_saved',
'saved_at' => $user->getLinkedinCredentialsSavedAt()->format('Y-m-d H:i:s'),
]);
} else {
// Déterminer la cause de l'échec
$errorCause = [];
if (!$encryptedEmail) {
$errorCause[] = 'email_chiffrement_echec';
}
if (!$encryptedPassword) {
$errorCause[] = 'password_chiffrement_echec';
}
$errorCauseStr = implode(', ', $errorCause);
$this->logger->error('ONBOARDING_V4: Échec du chiffrement des identifiants LinkedIn', [
'action' => 'save_linkedin_credentials',
'user_id' => $user->getId(),
'error_cause' => $errorCauseStr,
'email_encrypted' => $encryptedEmail !== null,
'password_encrypted' => $encryptedPassword !== null,
]);
// Logger l'erreur dans user_activity/[ID].log
$userLogger = $this->userLoggerFactory->createLogger($user->getId());
$userLogger->error('ONBOARDING_V4: Échec de la sauvegarde des identifiants LinkedIn - Chiffrement échoué', [
'action' => 'save_linkedin_credentials',
'step' => 'encryption_failed',
'error_cause' => $errorCauseStr,
'email_encrypted' => $encryptedEmail !== null,
'password_encrypted' => $encryptedPassword !== null,
]);
}
} catch (\Exception $e) {
$this->logger->error('ONBOARDING_V4: Exception lors de la sauvegarde des identifiants LinkedIn', [
'action' => 'save_linkedin_credentials',
'user_id' => $user->getId(),
'error' => $e->getMessage(),
'error_class' => get_class($e),
// Attention : la trace peut contenir les arguments (dont le mot de passe LinkedIn) => on masque.
'trace' => $this->redactSensitiveDataForLogs((string) $e->getTraceAsString(), [
isset($linkedinPassword) ? (string) $linkedinPassword : null,
isset($linkedinEmail) ? (string) $linkedinEmail : null,
]),
]);
// Logger l'exception dans user_activity/[ID].log
try {
$userLogger = $this->userLoggerFactory->createLogger($user->getId());
$userLogger->error('ONBOARDING_V4: Exception lors de la sauvegarde des identifiants LinkedIn', [
'action' => 'save_linkedin_credentials',
'step' => 'exception',
'error' => $e->getMessage(),
'error_class' => get_class($e),
'error_code' => $e->getCode(),
]);
} catch (\Exception $logException) {
// Si on ne peut pas logger dans user_activity, on logge dans le logger principal
$this->logger->warning('ONBOARDING_V4: Impossible de logger dans user_activity', [
'action' => 'save_linkedin_credentials',
'user_id' => $user->getId(),
'log_error' => $logException->getMessage(),
]);
}
}
}
/**
* Endpoint API pour la reconnexion silencieuse (Extension)
*
* Utilisé lorsque la session LinkedIn est expirée et que l'extension
* peut fournir un nouveau cookie pour recréer l'intégration.
*
* @Route("/api/onboarding/reconnect-silent", name="api.onboarding.reconnect_silent", methods={"POST"})
*
* @param Request $request Requête HTTP
* @return JsonResponse Réponse JSON
*/
public function reconnectSilent(Request $request): JsonResponse
{
/** @var User|null $user */
$user = $this->getUser();
if (!$user) {
return new JsonResponse([
'success' => false,
'error' => 'Utilisateur non authentifié',
], 401);
}
$userLogger = $this->userLoggerFactory->createLogger($user->getId());
$this->logger->info('Checking extension for cookie', [
'user_id' => $user->getId(),
'has_extension' => $user->getHasExtension(),
'current_status' => $user->getLinkedinIntegrationStatus(),
]);
// 1. Récupérer le cookie li_at depuis la requête
$data = json_decode($request->getContent(), true);
if (!isset($data['li_at']) || empty(trim($data['li_at']))) {
$this->logger->error('RECONNEXION_V4: Cookie li_at manquant dans la requête', [
'user_id' => $user->getId(),
]);
return new JsonResponse([
'success' => false,
'error' => 'Cookie li_at manquant',
], 400);
}
$liAtCookie = trim($data['li_at']);
$oldCookie = is_string($user->getLiAt()) ? trim((string) $user->getLiAt()) : '';
$cookieChanged = ($oldCookie === '') ? ($liAtCookie !== '') : ($liAtCookie !== $oldCookie);
$cookiePrefix = substr($liAtCookie, 0, 15) . '...';
$this->logger->info('Extension has cookie', [
'user_id' => $user->getId(),
'cookie_prefix' => $cookiePrefix,
'cookie_length' => strlen($liAtCookie),
'cookie_changed' => $cookieChanged,
]);
$userLogger->info('RECONNEXION_V4_SILENT: Cookie li_at reçu', [
'action' => 'reconnect_silent',
'step' => 'cookie_received',
'cookie_length' => strlen($liAtCookie),
'cookie_changed' => $cookieChanged,
]);
// 2. Vérifier si une reconnexion est nécessaire.
// IMPORTANT: même si le statut en base est "VALID", on doit tenter une reconnexion
// si le cookie fourni par l'extension est différent (cas réel: LinkedIn invalide l'ancien cookie).
$needsReconnection = $this->reconnectionService->needsReconnection($user);
if (!$needsReconnection && !$cookieChanged) {
$this->logger->info('RECONNEXION_V4: Reconnexion non nécessaire', [
'user_id' => $user->getId(),
'status' => $user->getLinkedinIntegrationStatus(),
'has_extension' => $user->getHasExtension(),
'cookie_changed' => false,
]);
return new JsonResponse([
'success' => true,
'message' => 'Reconnexion non nécessaire',
'already_connected' => true,
], 200);
}
// 3. Effectuer la reconnexion silencieuse (création / mise à jour intégration v4)
try {
$result = $this->reconnectionService->reconnectSilently($user, $liAtCookie);
$this->logger->info('RECONNEXION_V4: Reconnexion silencieuse réussie', [
'user_id' => $user->getId(),
'integration_uid' => $result['integration_uid'],
'cookie_changed' => $result['cookie_changed'],
]);
return new JsonResponse([
'success' => true,
'message' => 'Reconnexion silencieuse réussie',
'integration_uid' => $result['integration_uid'],
'cookie_changed' => $result['cookie_changed'],
], 200);
} catch (\Exception $e) {
$this->logger->error('RECONNEXION_V4: Erreur lors de la reconnexion silencieuse', [
'user_id' => $user->getId(),
'error' => $e->getMessage(),
]);
$userLogger->error('RECONNEXION_V4_SILENT: Erreur lors de la reconnexion', [
'action' => 'reconnect_silent',
'step' => 'error',
'error' => $e->getMessage(),
]);
return new JsonResponse([
'success' => false,
'error' => $e->getMessage(),
], 500);
}
}
/**
* Endpoint API pour la reconnexion via identifiants sauvegardés (Credentials)
*
* Utilisé lorsque la session LinkedIn est expirée et que l'utilisateur a choisi
* le parcours "credentials" (email/password). L'objectif est de recréer l'intégration
* LinkedIn v4 sans demander à l'utilisateur de repasser par un checkpoint, si possible.
*
* @Route("/api/onboarding/reconnect-credentials", name="api.onboarding.reconnect_credentials", methods={"POST"})
*
* @return JsonResponse Réponse JSON
*/
public function reconnectCredentials(): JsonResponse
{
/** @var User|null $user */
$user = $this->getUser();
if (!$user) {
return new JsonResponse([
'success' => false,
'error' => 'Utilisateur non authentifié',
], 401);
}
$userLogger = $this->userLoggerFactory->createLogger($user->getId());
if (!$user->getIdentityUid()) {
return new JsonResponse([
'success' => false,
'error' => 'Identity UID manquant',
], 400);
}
if (!$user->hasValidLinkedinCredentials(90)) {
return new JsonResponse([
'success' => false,
'error' => 'Identifiants LinkedIn non sauvegardés ou expirés',
], 400);
}
$encryptedEmail = $user->getLinkedinEmailEncrypted();
$encryptedPassword = $user->getLinkedinPasswordEncrypted();
if (!$encryptedEmail || !$encryptedPassword) {
return new JsonResponse([
'success' => false,
'error' => 'Identifiants LinkedIn manquants',
], 400);
}
$email = $this->encryptionService->decrypt($encryptedEmail);
$password = $this->encryptionService->decrypt($encryptedPassword);
if (!$email || !$password) {
$userLogger->error('RECONNEXION_V4_CREDENTIALS: Déchiffrement identifiants impossible', [
'action' => 'reconnect_credentials',
'email_decrypted' => $email !== null,
'password_decrypted' => $password !== null,
]);
return new JsonResponse([
'success' => false,
'error' => 'Impossible de déchiffrer les identifiants LinkedIn sauvegardés',
], 500);
}
$identityUid = (string) $user->getIdentityUid();
$this->logger->info('RECONNEXION_V4_CREDENTIALS: Tentative reconnexion via credentials', [
'user_id' => $user->getId(),
'identity_uid' => $identityUid,
]);
try {
$integrationData = $this->captainDataV4Service->createLinkedInIntegrationWithCredentials(
$identityUid,
$email,
$password,
null
);
$status = isset($integrationData['status']) ? (string) $integrationData['status'] : 'UNKNOWN';
$integrationUid = isset($integrationData['uid']) ? (string) $integrationData['uid'] : null;
$checkpoint = isset($integrationData['checkpoint']) ? $integrationData['checkpoint'] : null;
$user->setLinkedinIntegrationStatus($status);
$user->setLinkedinIntegrationLastVerifiedAt(new \DateTimeImmutable());
$user->setHasLinkedinIntegration($status === 'VALID');
$this->entityManager->flush();
$userLogger->info('RECONNEXION_V4_CREDENTIALS: Réponse intégration LinkedIn', [
'action' => 'reconnect_credentials',
'status' => $status,
'integration_uid' => $integrationUid,
'has_checkpoint' => $checkpoint !== null,
]);
if ($status !== 'VALID') {
return new JsonResponse([
'success' => false,
'error' => 'Reconnexion incomplète : intégration LinkedIn non VALID',
'status' => $status,
'checkpoint' => $checkpoint,
'integration_uid' => $integrationUid,
], 202);
}
$this->triggerPendingMessagesIfNeeded($user);
return new JsonResponse([
'success' => true,
'message' => 'Reconnexion via credentials réussie',
'integration_uid' => $integrationUid,
'status' => $status,
], 200);
} catch (\Exception $e) {
$this->logger->error('RECONNEXION_V4_CREDENTIALS: Erreur reconnexion via credentials', [
'user_id' => $user->getId(),
'identity_uid' => $identityUid,
'error' => $e->getMessage(),
]);
$userLogger->error('RECONNEXION_V4_CREDENTIALS: Erreur reconnexion via credentials', [
'action' => 'reconnect_credentials',
'step' => 'error',
'error' => $e->getMessage(),
]);
return new JsonResponse([
'success' => false,
'error' => $e->getMessage(),
], 500);
}
}
/**
* Déclenche le traitement des messages en attente si l'intégration LinkedIn est VALID
*
* @param User $user Utilisateur
* @return void
*/
private function triggerPendingMessagesIfNeeded(User $user): void
{
// Vérifier s'il y a des messages en attente
$messageRepository = $this->entityManager->getRepository(\App\Entity\Message::class);
$pendingMessages = $messageRepository->findBy([
'user' => $user,
'status' => \App\Entity\Message::STATUS_PENDING,
]);
if (empty($pendingMessages)) {
return; // Pas de messages en attente
}
$this->logger->info('ONBOARDING_V4: Messages en attente détectés, déclenchement du job', [
'user_id' => $user->getId(),
'pending_count' => count($pendingMessages),
]);
// Vérifier s'il existe déjà un job en attente
$existingJob = $this->entityManager->getRepository(CaptaindataJob::class)->findOneBy([
'user' => $user,
'workflowType' => CaptaindataJob::WORKFLOW_TYPE_SEND_MESSAGES,
'status' => CaptaindataJob::STATUS_SEND_MESSAGES_PENDING,
]);
if ($existingJob) {
$this->logger->info('ONBOARDING_V4: Job existant trouvé, re-dispatch', [
'user_id' => $user->getId(),
'job_id' => $existingJob->getId(),
]);
$this->bus->dispatch(new ProcessPendingMessagesJob($existingJob->getId()));
return;
}
// Créer un nouveau job
$workflowUid = $this->params->get('app.captaindata_workflow_send_message_uid');
$captainDataJob = new CaptaindataJob(
$user,
CaptaindataJob::WORKFLOW_TYPE_SEND_MESSAGES,
$workflowUid,
CaptaindataJob::STATUS_SEND_MESSAGES_PENDING
);
$this->entityManager->persist($captainDataJob);
$this->entityManager->flush();
$this->bus->dispatch(new ProcessPendingMessagesJob($captainDataJob->getId()));
$this->logger->info('ONBOARDING_V4: Nouveau job créé et dispatché pour messages en attente', [
'user_id' => $user->getId(),
'job_id' => $captainDataJob->getId(),
'pending_count' => count($pendingMessages),
]);
}
}