src/Controller/Api/ConversationsController.php line 659

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