src/Controller/Api/ConversationsController.php line 694

Open in your IDE?
  1. <?php
  2. namespace App\Controller\Api;
  3. use App\Entity\Core\Notifications;
  4. use App\Entity\Core\Users;
  5. use App\Entity\Messenger\Groups;
  6. use App\Entity\Messenger\Messages;
  7. use App\Services\NotificationService;
  8. use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
  9. use Symfony\Component\HttpFoundation\Request;
  10. use Symfony\Component\HttpFoundation\Response;
  11. use Symfony\Component\HttpFoundation\JsonResponse;
  12. use Doctrine\ORM\EntityManagerInterface;
  13. use Knp\Component\Pager\PaginatorInterface;
  14. use App\Services\Core\AccesPremium;
  15. class ConversationsController extends AbstractController
  16. {
  17. /* Le verrou premium. Voir `App\Services\Core\AccesPremium`.
  18. ⚠️ IL NE S'APPLIQUE QU'AUX RECRUTEURS.
  19. `sendMessage` sert AUSSI aux candidats, et un candidat n'est
  20. jamais premium. Verrouiller sur `isPremium()` seul aurait coupé
  21. la parole à tous les candidats du site — et aucun test fait avec
  22. un compte entreprise ne l'aurait montré. */
  23. use AccesPremium;
  24. private $em;
  25. private $paginator;
  26. private $notificationService;
  27. public function __construct(EntityManagerInterface $em,
  28. PaginatorInterface $paginator,
  29. NotificationService $notificationService
  30. ) {
  31. $this->em = $em;
  32. $this->paginator = $paginator;
  33. $this->notificationService = $notificationService;
  34. }
  35. /* ══════════════════════════════════════════════════════════════════
  36. MASQUAGE D'IDENTITÉ — profil anonyme
  37. Une conversation peut porter `anonymousUserId` : l'utilisateur ainsi
  38. désigné est masqué pour SON INTERLOCUTEUR. C'est posé quand un
  39. recruteur écrit depuis /cv/{anonRef}, la porte anonyme du profil.
  40. ⚠️ UN SEUL ENDROIT DÉCIDE, ET TOUT LE MONDE L'APPELLE.
  41. L'identité fuyait par QUATRE canaux : la liste des conversations,
  42. chaque message, la notification in-app, et le payload poussé au
  43. WebSocket. Quatre masquages écrits séparément, c'est trois
  44. masquages corrects et un oubli — et l'oubli révèle quelqu'un qui
  45. avait demandé à ne pas l'être.
  46. ⚠️ L'E-MAIL AUSSI. Ce n'est pas qu'une question de nom : la liste
  47. des conversations renvoyait `userEmail`. Un nom masqué à côté
  48. d'une adresse `prenom.nom@societe.fr` ne masque rien.
  49. ══════════════════════════════════════════════════════════════════ */
  50. /**
  51. * L'identité à afficher pour `$autre` dans `$conversation`, vue par
  52. * `$lecteur`.
  53. *
  54. * @return array{fullname: string, avatar: string, email: string, masked: bool, reference?: ?string}
  55. */
  56. private function identite(Groups $conversation, ?Users $autre, ?Users $lecteur): array
  57. {
  58. $nomReel = $autre ? trim($autre->getName() . ' ' . $autre->getLastname()) : '';
  59. $avatarReel = $this->getDefaultAvatar();
  60. if ($autre && $autre->getImage()) {
  61. $avatarReel = $autre->getImageBase64();
  62. }
  63. /* On ne masque JAMAIS quelqu'un pour lui-même : le candidat doit se
  64. reconnaître dans son propre fil. Le masque protège de l'autre,
  65. pas de soi. */
  66. $memePersonne = $autre && $lecteur && $autre->getId() === $lecteur->getId();
  67. if ($memePersonne || !$autre || !$conversation->masque($autre->getId())) {
  68. return [
  69. 'fullname' => $nomReel,
  70. 'avatar' => $avatarReel,
  71. 'email' => $autre ? (string) $autre->getEmail() : '',
  72. 'masked' => false,
  73. ];
  74. }
  75. /* ══════════════════════════════════════════════════════════════
  76. LA RÉFÉRENCE DU CANDIDAT ANONYME, À LA PLACE DU NOM
  77. « Candidat anonyme » ne distingue rien : un recruteur qui a
  78. contacté trois CV anonymes voyait trois fils du même nom. La
  79. référence de la porte anonyme (`anon_ref`, celle de /cv/{ref})
  80. est ce qu'il a sous les yeux depuis le début — elle identifie le
  81. fil sans identifier la personne. Elle est publique par
  82. construction : c'est l'adresse qu'il a ouverte.
  83. ══════════════════════════════════════════════════════════════ */
  84. $reference = null;
  85. if ($autre && $autre->getCandidate() && $autre->getCandidate()->getAnonRef()) {
  86. $reference = $autre->getCandidate()->getAnonRef();
  87. }
  88. return [
  89. 'fullname' => $reference ? 'Candidat anonyme · ' . $reference : 'Candidat anonyme',
  90. 'avatar' => $this->getDefaultAvatar(),
  91. 'email' => '',
  92. 'masked' => true,
  93. 'reference' => $reference,
  94. ];
  95. }
  96. /**
  97. * Liste des conversations récentes de l'utilisateur connecté
  98. */
  99. public function recents(Request $request): JsonResponse
  100. {
  101. $user = $this->getUser();
  102. $limit = $request->query->get('limit', 20);
  103. $conversations = $this->em->getRepository(Groups::class)->recents($user, $limit);
  104. $array = [];
  105. foreach ($conversations as $conversation) {
  106. $otherUser = $conversation->getOtherUser($user);
  107. if (!$otherUser) {
  108. continue;
  109. }
  110. $countMessages = $this->em->getRepository(Messages::class)->countByConversation($conversation);
  111. /* Identité masquée si la conversation le prévoit — voir identite(). */
  112. $ident = $this->identite($conversation, $otherUser, $user);
  113. $fullname = $ident['fullname'];
  114. $avatar = $ident['avatar'];
  115. $array[] = [
  116. "id" => (string)$conversation->getId(),
  117. "conversationHash" => $conversation->getConversationHash(),
  118. "title" => $conversation->getTitle() ?: 'Conversation avec ' . $fullname,
  119. "fullname" => $fullname,
  120. "userId" => $otherUser->getId(),
  121. "userEmail" => $ident['email'],
  122. "anonymous" => $ident['masked'],
  123. "avatar" => $avatar,
  124. "count_messages" => $countMessages,
  125. "open" => false,
  126. "lastmessage" => $conversation->getLastMessageContent() ?? '',
  127. "lastMessageAt" => $conversation->getLastMessageAt() ? $conversation->getLastMessageAt()->format('c') : null,
  128. "createdAt" => $conversation->getCreatedAt() ? $conversation->getCreatedAt()->format('c') : null,
  129. ];
  130. }
  131. return new JsonResponse($array);
  132. }
  133. /**
  134. * Afficher les messages d'une conversation
  135. */
  136. public function show(Request $request, int $id): JsonResponse
  137. {
  138. $user = $this->getUser();
  139. $conversation = $this->em->getRepository(Groups::class)->find($id);
  140. if (!$conversation) {
  141. return new JsonResponse(['error' => 'Conversation non trouvée'], Response::HTTP_NOT_FOUND);
  142. }
  143. if (!$conversation->hasUser($user)) {
  144. return new JsonResponse(['error' => 'Accès interdit'], Response::HTTP_FORBIDDEN);
  145. }
  146. $limit = $request->query->get('limit', 100);
  147. $offset = $request->query->get('offset', 0);
  148. $messages = $this->em->getRepository(Messages::class)->findByConversation($conversation, $limit, $offset);
  149. $array = [];
  150. foreach ($messages as $message) {
  151. $isOwn = $message->getUser()->getId() === $user->getId();
  152. /* Chaque bulle porte le nom de son auteur : masquer la liste
  153. sans masquer les bulles laisserait le nom réel s'afficher au
  154. premier message reçu. */
  155. $ident = $this->identite($conversation, $message->getUser(), $user);
  156. $fullname = $ident['fullname'];
  157. $avatar = $ident['avatar'];
  158. $array[] = [
  159. "id" => (string)$message->getId(),
  160. "message" => (string)$message->getMessage(),
  161. "isOwn" => $isOwn,
  162. "userId" => $message->getUser()->getId(),
  163. "userName" => $fullname,
  164. "avatar" => $avatar,
  165. "anonymous" => $ident['masked'],
  166. "createdAt" => $message->getCreatedAt() ? $message->getCreatedAt()->format('c') : null,
  167. ];
  168. }
  169. return new JsonResponse($array);
  170. }
  171. /**
  172. * Envoyer un message
  173. */
  174. public function sendMessage(Request $request, int $id): JsonResponse
  175. {
  176. $user = $this->getUser();
  177. $conversation = $this->em->getRepository(Groups::class)->find($id);
  178. if (!$conversation) {
  179. return new JsonResponse(['error' => 'Conversation non trouvée'], Response::HTTP_NOT_FOUND);
  180. }
  181. if (!$conversation->hasUser($user)) {
  182. return new JsonResponse(['error' => 'Accès interdit'], Response::HTTP_FORBIDDEN);
  183. }
  184. /* ⚠️ « TOUT CONTACT » INCLUT LA RÉPONSE À UN FIL EXISTANT.
  185. Sans cette ligne, le verrou se contournerait tout seul : il
  186. suffirait d'avoir ouvert une conversation du temps de son
  187. abonnement, ou qu'un candidat écrive le premier, pour
  188. continuer d'écrire indéfiniment sans payer.
  189. Conséquence à connaître : un recruteur dont l'abonnement
  190. expire ne peut plus répondre, même à un candidat qui lui
  191. écrit. C'est le choix demandé — il se relâche en retirant
  192. ces quatre lignes. */
  193. if ($refus = $this->refuserSansPremium($user, 'contact')) {
  194. return $refus;
  195. }
  196. $data = json_decode($request->getContent(), true);
  197. $messageText = $data['message'] ?? null;
  198. if (empty($messageText)) {
  199. return new JsonResponse(['error' => 'Message vide'], Response::HTTP_BAD_REQUEST);
  200. }
  201. $emetteur = $user;
  202. $destinataire = null;
  203. if($conversation->getUserOne() !== $user) {
  204. $destinataire = $conversation->getUserOne();
  205. }
  206. if($conversation->getUserTwo() !== $user) {
  207. $destinataire = $conversation->getUserTwo();
  208. }
  209. // Créer le message
  210. $message = new Messages();
  211. $message->setMessage($messageText);
  212. $message->setUser($user);
  213. $message->setGroup($conversation);
  214. $message->setCreatedAt(new \DateTime());
  215. $this->em->persist($message);
  216. $this->em->flush();
  217. // Mettre à jour la conversation (avec le nouveau champ lastMessageSenderId)
  218. $conversation->setLastMessageContent(substr($messageText, 0, 100));
  219. $conversation->setLastMessageId($message->getId());
  220. $conversation->setLastMessageAt(new \DateTime());
  221. $conversation->setLastMessageSenderId($user->getId()); // ← NOUVEAU
  222. $this->em->persist($conversation);
  223. $this->em->flush();
  224. // Vers le destinataire
  225. $notification = new Notifications();
  226. $notification->setViewed(false);
  227. $notification->setOnlyAdmin(false);
  228. $notification->setTitle("message");
  229. if($conversation->getUserOne()->getLanguage() == "fr") {
  230. /* La notification nomme l'émetteur : sans masquage, elle
  231. annulerait tout le reste en une ligne. */
  232. $notification->setDescription("Message reçu par ".$this->identite($conversation, $emetteur, $destinataire)['fullname']);
  233. $this->notificationService->sendToUser(
  234. $destinataire,
  235. 'Nouveau message',
  236. $this->cleanMessageNotification($message->getMessage()),
  237. ['type' => 'message', 'conversationId' => $conversation->getId()],
  238. );
  239. } else {
  240. $this->notificationService->sendToUser(
  241. $destinataire,
  242. 'New message',
  243. $this->cleanMessageNotification($message->getMessage()),
  244. ['type' => 'message', 'conversationId' => $conversation->getId()],
  245. );
  246. $notification->setDescription("Message received by ".$this->identite($conversation, $emetteur, $destinataire)['fullname']);
  247. }
  248. $notification->setType("default");
  249. $notification->setUser($conversation->getUserTwo());
  250. $this->em->persist($notification);
  251. $this->em->flush();
  252. /* ⚠️ LE POINT LE PLUS FACILE À OUBLIER.
  253. Ce payload part vers le serveur WebSocket, qui le REDIFFUSE TEL
  254. QUEL au navigateur de l'interlocuteur. Masquer les endpoints REST
  255. en laissant celui-ci intact reviendrait à masquer la porte en
  256. laissant la fenêtre ouverte : le nom réel s'afficherait en direct,
  257. à la seconde où le message part.
  258. Le serveur WebSocket n'a rien à savoir de tout ça — c'est un
  259. relais. On lui donne déjà le texte masqué. */
  260. $identEmetteur = $this->identite($conversation, $user, $conversation->getOtherUser($user));
  261. $fullname = $identEmetteur['fullname'];
  262. $avatar = $identEmetteur['avatar'];
  263. try {
  264. $client = new \GuzzleHttp\Client([
  265. 'timeout' => 5.0,
  266. 'verify' => false,
  267. ]);
  268. $websocketUrl = rtrim((string) ($_ENV["WEBSOCKET_HTTP_URL"] ?? ''), '/').'/broadcast';
  269. /* ⚠️ LE SECRET PARTAGÉ AVEC LE SERVEUR WEBSOCKET.
  270. Le serveur WS protège `/broadcast` par `BROADCAST_SECRET`
  271. (en-tête `X-Internal-Secret`). Tant qu'on ne l'envoyait pas,
  272. poser ce secret côté WS coupait TOUTES les notifications
  273. temps réel — 403 à chaque message, visible seulement dans les
  274. journaux. La même valeur, dans `WEBSOCKET_BROADCAST_SECRET`
  275. ici : l'en-tête n'est envoyé que si elle est renseignée, donc
  276. un serveur WS sans secret continue de fonctionner. */
  277. $secretBroadcast = trim((string) ($_ENV["WEBSOCKET_BROADCAST_SECRET"] ?? ''));
  278. $entetesBroadcast = ['Content-Type' => 'application/json'];
  279. if ($secretBroadcast !== '') {
  280. $entetesBroadcast['X-Internal-Secret'] = $secretBroadcast;
  281. }
  282. error_log("╔═══════════════════════════════════════════════════════╗");
  283. error_log("║ 📡 BROADCAST WEBSOCKET - ENVOI MESSAGE ║");
  284. error_log("╚═══════════════════════════════════════════════════════╝");
  285. error_log("🔡 [WebSocket] URL cible : " . $websocketUrl);
  286. error_log("🔡 [WebSocket] Group ID : " . $conversation->getId());
  287. error_log("🔡 [WebSocket] Message ID: " . $message->getId());
  288. error_log("🔡 [WebSocket] User ID : " . $user->getId());
  289. error_log("🔡 [WebSocket] Fullname : " . $fullname);
  290. $broadcastData = [
  291. 'groupId' => (int) $conversation->getId(),
  292. 'message' => [
  293. 'id' => (int) $message->getId(),
  294. 'message' => $message->getMessage(),
  295. 'isOwn' => false,
  296. 'userId' => $user->getId(),
  297. 'userName' => $fullname,
  298. 'avatar' => $avatar,
  299. 'anonymous' => $identEmetteur['masked'],
  300. 'reference' => $identEmetteur['reference'] ?? null,
  301. 'createdAt' => $message->getCreatedAt()->format('c'),
  302. ]
  303. ];
  304. error_log("📤 [WebSocket] Données envoyées: " . json_encode($broadcastData));
  305. $response = $client->post($websocketUrl, [
  306. 'json' => $broadcastData,
  307. 'headers' => $entetesBroadcast,
  308. ]);
  309. $statusCode = $response->getStatusCode();
  310. $body = $response->getBody()->getContents();
  311. $bodyData = json_decode($body, true);
  312. error_log("✅ [WebSocket] Notification envoyée - Status: " . $statusCode);
  313. error_log("✅ [WebSocket] Réponse: " . $body);
  314. if (isset($bodyData['clientsNotified'])) {
  315. $clientsNotified = $bodyData['clientsNotified'];
  316. error_log("📊 [WebSocket] Clients notifiés: " . $clientsNotified);
  317. if ($clientsNotified === 0) {
  318. error_log("⚠️ [WebSocket] ATTENTION: Aucun client n'a reçu le message !");
  319. error_log(" Raisons possibles:");
  320. error_log(" 1. Aucun navigateur connecté à cette conversation");
  321. error_log(" 2. Les clients ne sont pas dans la bonne room");
  322. error_log(" 3. Les clients n'ont pas appelé joinGroup()");
  323. }
  324. }
  325. error_log("═══════════════════════════════════════════════════════");
  326. } catch (\GuzzleHttp\Exception\ConnectException $e) {
  327. error_log("╔═══════════════════════════════════════════════════════╗");
  328. error_log("║ ❌ ERREUR DE CONNEXION WEBSOCKET ║");
  329. error_log("╚═══════════════════════════════════════════════════════╝");
  330. error_log("❌ [WebSocket] Impossible de se connecter au serveur");
  331. error_log(" URL tentée: " . ($websocketUrl ?? 'undefined'));
  332. error_log(" Message: " . $e->getMessage());
  333. error_log(" ⚠️ Le serveur WebSocket est-il démarré ?");
  334. error_log(" Commande: pm2 status");
  335. error_log("═══════════════════════════════════════════════════════");
  336. } catch (\GuzzleHttp\Exception\RequestException $e) {
  337. error_log("╔═══════════════════════════════════════════════════════╗");
  338. error_log("║ ❌ ERREUR REQUÊTE WEBSOCKET ║");
  339. error_log("╚═══════════════════════════════════════════════════════╝");
  340. error_log("❌ [WebSocket] Erreur lors de la requête");
  341. error_log(" Message: " . $e->getMessage());
  342. if ($e->hasResponse()) {
  343. error_log(" Status: " . $e->getResponse()->getStatusCode());
  344. error_log(" Body: " . $e->getResponse()->getBody()->getContents());
  345. }
  346. error_log("═══════════════════════════════════════════════════════");
  347. } catch (\Exception $e) {
  348. error_log("╔═══════════════════════════════════════════════════════╗");
  349. error_log("║ ❌ ERREUR GÉNÉRALE WEBSOCKET ║");
  350. error_log("╚═══════════════════════════════════════════════════════╝");
  351. error_log("❌ [WebSocket] Erreur: " . $e->getMessage());
  352. error_log(" Type: " . get_class($e));
  353. error_log(" File: " . $e->getFile() . ":" . $e->getLine());
  354. error_log("⚠️ Le message a été enregistré mais la notification temps réel n'a pas pu être envoyée");
  355. error_log("═══════════════════════════════════════════════════════");
  356. }
  357. return new JsonResponse([
  358. 'id' => (string)$message->getId(),
  359. 'message' => $message->getMessage(),
  360. 'isOwn' => true,
  361. 'userId' => $user->getId(),
  362. 'userName' => $fullname,
  363. 'avatar' => $avatar,
  364. 'createdAt' => $message->getCreatedAt() ? $message->getCreatedAt()->format('c') : null,
  365. ], Response::HTTP_CREATED);
  366. }
  367. /**
  368. * Récupérer les nouveaux messages depuis un certain ID (polling)
  369. */
  370. public function getNewMessages(Request $request, int $id): JsonResponse
  371. {
  372. $user = $this->getUser();
  373. $conversation = $this->em->getRepository(Groups::class)->find($id);
  374. if (!$conversation) {
  375. return new JsonResponse(['error' => 'Conversation non trouvée'], Response::HTTP_NOT_FOUND);
  376. }
  377. if (!$conversation->hasUser($user)) {
  378. return new JsonResponse(['error' => 'Accès interdit'], Response::HTTP_FORBIDDEN);
  379. }
  380. $lastMessageId = $request->query->get('lastMessageId', 0);
  381. $messages = $this->em->getRepository(Messages::class)->findAfterMessage($conversation, $lastMessageId);
  382. $array = [];
  383. foreach ($messages as $message) {
  384. $isOwn = $message->getUser()->getId() === $user->getId();
  385. /* Masquage : voir identite(). */
  386. $ident = $this->identite($conversation, $message->getUser(), $user);
  387. $fullname = $ident['fullname'];
  388. $avatar = $ident['avatar'];
  389. $array[] = [
  390. "id" => (string)$message->getId(),
  391. "message" => (string)$message->getMessage(),
  392. "isOwn" => $isOwn,
  393. "userId" => $message->getUser()->getId(),
  394. "userName" => $fullname,
  395. "avatar" => $avatar,
  396. "createdAt" => $message->getCreatedAt() ? $message->getCreatedAt()->format('c') : null,
  397. ];
  398. }
  399. return new JsonResponse($array);
  400. }
  401. /**
  402. * Rechercher dans les conversations
  403. */
  404. public function search(Request $request): JsonResponse
  405. {
  406. $user = $this->getUser();
  407. $searchTerm = $request->query->get('q', '');
  408. if (empty(trim($searchTerm))) {
  409. return new JsonResponse(['error' => 'Terme de recherche requis'], Response::HTTP_BAD_REQUEST);
  410. }
  411. $conversations = $this->em->getRepository(Groups::class)->searchConversations($user, $searchTerm);
  412. $array = [];
  413. foreach ($conversations as $conversation) {
  414. $otherUser = $conversation->getOtherUser($user);
  415. if (!$otherUser) {
  416. continue;
  417. }
  418. $countMessages = $this->em->getRepository(Messages::class)->countByConversation($conversation);
  419. /* Masquage : voir identite(). */
  420. $ident = $this->identite($conversation, $otherUser, $user);
  421. $fullname = $ident['fullname'];
  422. $avatar = $ident['avatar'];
  423. $array[] = [
  424. "id" => (string)$conversation->getId(),
  425. "conversationHash" => $conversation->getConversationHash(),
  426. "title" => $conversation->getTitle() ?: 'Conversation avec ' . $fullname,
  427. "fullname" => $fullname,
  428. "userId" => $otherUser->getId(),
  429. "avatar" => $avatar,
  430. "count_messages" => $countMessages,
  431. "lastmessage" => $conversation->getLastMessageContent() ?? '',
  432. "lastMessageAt" => $conversation->getLastMessageAt() ? $conversation->getLastMessageAt()->format('c') : null,
  433. ];
  434. }
  435. return new JsonResponse($array);
  436. }
  437. /**
  438. * Supprimer une conversation (soft delete ou hard delete selon votre besoin)
  439. */
  440. public function deleteConversation(int $id): JsonResponse
  441. {
  442. $user = $this->getUser();
  443. $conversation = $this->em->getRepository(Groups::class)->find($id);
  444. if (!$conversation) {
  445. return new JsonResponse(['error' => 'Conversation non trouvée'], Response::HTTP_NOT_FOUND);
  446. }
  447. if (!$conversation->hasUser($user)) {
  448. return new JsonResponse(['error' => 'Accès interdit'], Response::HTTP_FORBIDDEN);
  449. }
  450. return new JsonResponse(['message' => 'Conversation supprimée avec succès']);
  451. }
  452. /**
  453. * Démarrer une conversation
  454. */
  455. public function startConversation(Request $request, int $userId): JsonResponse
  456. {
  457. $user = $this->getUser();
  458. if (!$user) {
  459. return new JsonResponse(['error' => 'Non authentifié'], Response::HTTP_UNAUTHORIZED);
  460. }
  461. /* ⚠️ AVANT DE CHERCHER L'AUTRE UTILISATEUR.
  462. Sinon un compte non premium pourrait, par la différence entre
  463. « utilisateur non trouvé » et « accès refusé », tester des
  464. identifiants au hasard pour savoir lesquels existent. */
  465. if ($refus = $this->refuserSansPremium($user, 'contact')) {
  466. return $refus;
  467. }
  468. $otherUser = $this->em->getRepository(Users::class)->find($userId);
  469. if (!$otherUser) {
  470. return new JsonResponse(['error' => 'Utilisateur non trouvé'], Response::HTTP_NOT_FOUND);
  471. }
  472. if ($user->getId() === $otherUser->getId()) {
  473. return new JsonResponse(['error' => 'Impossible de démarrer une conversation avec soi-même'], Response::HTTP_BAD_REQUEST);
  474. }
  475. $conversation = $this->em->getRepository(Groups::class)->findOrCreateConversation($user, $otherUser);
  476. /* Masquage : voir identite(). Cet endpoint renvoie le nom au moment
  477. d'ouvrir un fil — c'est exactement là qu'un anonyme se ferait
  478. démasquer sans que rien ne le signale. */
  479. $ident = $this->identite($conversation, $otherUser, $user);
  480. return new JsonResponse([
  481. 'id' => (string)$conversation->getId(),
  482. 'conversationHash' => $conversation->getConversationHash(),
  483. 'fullname' => $ident['fullname'],
  484. 'anonymous' => $ident['masked'],
  485. 'reference' => $ident['reference'] ?? null,
  486. 'userId' => $otherUser->getId(),
  487. ], Response::HTTP_CREATED);
  488. }
  489. // ========================================
  490. // NOUVELLES MÉTHODES — V2 avec lu/non lu
  491. // ========================================
  492. /**
  493. * Liste des conversations récentes V2 — avec lastMessageSenderId et unread_count
  494. * Utilise le vrai compteur de messages non lus (basé sur readAt)
  495. */
  496. public function recentsV2(Request $request): JsonResponse
  497. {
  498. $user = $this->getUser();
  499. if (!$user) {
  500. return new JsonResponse(['error' => 'Non authentifié'], Response::HTTP_UNAUTHORIZED);
  501. }
  502. $limit = $request->query->get('limit', 20);
  503. $conversations = $this->em->getRepository(Groups::class)->recents($user, $limit);
  504. $messagesRepo = $this->em->getRepository(Messages::class);
  505. $array = [];
  506. foreach ($conversations as $conversation) {
  507. $otherUser = $conversation->getOtherUser($user);
  508. if (!$otherUser) {
  509. continue;
  510. }
  511. // Compteur de messages NON LUS (messages de l'autre que je n'ai pas lus)
  512. $unreadCount = $messagesRepo->countUnreadByConversation($conversation, $user);
  513. /* Masquage : voir identite(). */
  514. $ident = $this->identite($conversation, $otherUser, $user);
  515. $fullname = $ident['fullname'];
  516. $avatar = $ident['avatar'];
  517. $array[] = [
  518. "id" => (string)$conversation->getId(),
  519. "conversationHash" => $conversation->getConversationHash(),
  520. "title" => $conversation->getTitle() ?: 'Conversation avec ' . $fullname,
  521. "fullname" => $fullname,
  522. "userId" => $otherUser->getId(),
  523. "userEmail" => $otherUser->getEmail(),
  524. "avatar" => $avatar,
  525. "count_messages" => $unreadCount, // ← maintenant = non lus uniquement
  526. "lastMessageSenderId" => $conversation->getLastMessageSenderId(), // ← NOUVEAU
  527. "open" => false,
  528. "lastmessage" => $conversation->getLastMessageContent() ?? '',
  529. "lastMessageAt" => $conversation->getLastMessageAt() ? $conversation->getLastMessageAt()->format('c') : null,
  530. "createdAt" => $conversation->getCreatedAt() ? $conversation->getCreatedAt()->format('c') : null,
  531. ];
  532. }
  533. return new JsonResponse($array);
  534. }
  535. /**
  536. * Marquer tous les messages d'une conversation comme lus
  537. * Appelé quand l'utilisateur ouvre une conversation
  538. */
  539. public function markAsRead(int $id): JsonResponse
  540. {
  541. $user = $this->getUser();
  542. if (!$user) {
  543. return new JsonResponse(['error' => 'Non authentifié'], Response::HTTP_UNAUTHORIZED);
  544. }
  545. $conversation = $this->em->getRepository(Groups::class)->find($id);
  546. if (!$conversation) {
  547. return new JsonResponse(['error' => 'Conversation non trouvée'], Response::HTTP_NOT_FOUND);
  548. }
  549. if (!$conversation->hasUser($user)) {
  550. return new JsonResponse(['error' => 'Accès interdit'], Response::HTTP_FORBIDDEN);
  551. }
  552. // Marquer comme lus tous les messages de l'autre utilisateur
  553. $markedCount = $this->em->getRepository(Messages::class)
  554. ->markAsReadByConversation($conversation, $user);
  555. error_log("✅ [MarkAsRead] Conv {$id}{$markedCount} messages marqués comme lus pour user {$user->getId()}");
  556. return new JsonResponse([
  557. 'success' => true,
  558. 'markedCount' => $markedCount,
  559. ]);
  560. }
  561. /**
  562. * Obtenir le nombre total de messages non lus (pour le badge de l'onglet Messages)
  563. */
  564. public function totalUnread(): JsonResponse
  565. {
  566. $user = $this->getUser();
  567. if (!$user) {
  568. return new JsonResponse(['error' => 'Non authentifié'], Response::HTTP_UNAUTHORIZED);
  569. }
  570. $totalUnread = $this->em->getRepository(Messages::class)
  571. ->countTotalUnreadForUser($user);
  572. return new JsonResponse([
  573. 'totalUnread' => $totalUnread,
  574. ]);
  575. }
  576. // ========================================
  577. // HELPERS PRIVÉS
  578. // ========================================
  579. private function cleanMessageNotification($texte, $longueur = 20) {
  580. if (mb_strlen($texte) <= $longueur) {
  581. return $texte;
  582. }
  583. return mb_substr($texte, 0, $longueur) . '...';
  584. }
  585. /**
  586. * Avatar SVG par défaut
  587. */
  588. private function getDefaultAvatar(): string
  589. {
  590. return "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjIwMCIgdmlld0JveD0iMCAwIDIwMCAyMDAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CiAgPGRlZnM+CiAgICA8bGluZWFyR3JhZGllbnQgaWQ9ImdyYWQiIHgxPSIwJSIgeTE9IjAlIiB4Mj0iMTAwJSIgeTI9IjEwMCUiPgogICAgICA8c3RvcCBvZmZzZXQ9IjAlIiBzdHlsZT0ic3RvcC1jb2xvcjojNjY3ZWVhO3N0b3Atb3BhY2l0eToxIiAvPgogICAgICA8c3RvcCBvZmZzZXQ9IjEwMCUiIHN0eWxlPSJzdG9wLWNvbG9yOiM3NjRiYTI7c3RvcC1vcGFjaXR5OjEiIC8+CiAgICA8L2xpbmVhckdyYWRpZW50PgogIDwvZGVmcz4KICA8Y2lyY2xlIGN4PSIxMDAiIGN5PSIxMDAiIHI9IjEwMCIgZmlsbD0idXJsKCNncmFkKSIvPgogIDxnIHRyYW5zZm9ybT0idHJhbnNsYXRlKDEwMCwgMTAwKSI+CiAgICA8Y2lyY2xlIGN4PSIwIiBjeT0iLTE1IiByPSIzNSIgZmlsbD0iI2ZmZmZmZiIgb3BhY2l0eT0iMC45Ii8+CiAgICA8cGF0aCBkPSJNIC01MCA2NSBRIC01MCAyNSAtMzUgMTAgUSAtMTUgLTUgMCAtNSBRIDE1IC01IDM1IDEwIFEgNTAgMjUgNTAgNjUgWiIgZmlsbD0iI2ZmZmZmZiIgb3BhY2l0eT0iMC45Ii8+CiAgPC9nPgo8L3N2Zz4K";
  591. }
  592. }