db = \Database::getInstance(); } /** * GET /api/chat/rooms * Liste des conversations filtrées par rôle et entité */ public function getRooms(): void { // L'authentification est déjà vérifiée par le Router pour les routes privées try { $userId = \Session::getUserId(); $entityId = \Session::getEntityId(); // Vérifier si c'est une synchronisation incrémentale $updatedAfter = $_GET['updated_after'] ?? null; $isIncrementalSync = !empty($updatedAfter); // Log pour débugger if ($isIncrementalSync) { \LogService::log('Sync incrémentale demandée', [ 'level' => 'debug', 'updated_after_raw' => $updatedAfter, 'updated_after_decoded' => urldecode($updatedAfter), 'current_time' => gmdate('Y-m-d\TH:i:s\Z') ]); } // Récupérer le rôle de l'utilisateur $userRole = $this->getUserRole($userId); // Timestamp de synchronisation actuel en UTC // Utiliser le format ISO 8601 avec timezone UTC $syncTimestamp = gmdate('Y-m-d\TH:i:s\Z'); // Convertir updated_after de UTC vers le timezone de la BD si nécessaire if ($isIncrementalSync) { // Le paramètre arrive en UTC, on doit le convertir pour la comparaison avec la BD // Si la BD stocke en heure locale (Europe/Paris = UTC+2 en été) $updatedAfterUTC = new \DateTime($updatedAfter, new \DateTimeZone('UTC')); $updatedAfterLocal = clone $updatedAfterUTC; $updatedAfterLocal->setTimezone(new \DateTimeZone('Europe/Paris')); $updatedAfter = $updatedAfterLocal->format('Y-m-d H:i:s'); \LogService::log('Conversion timezone pour sync', [ 'level' => 'debug', 'updated_after_utc' => $updatedAfterUTC->format('Y-m-d H:i:s'), 'updated_after_local' => $updatedAfter ]); } // Construction de la requête selon le rôle $sql = ' SELECT DISTINCT r.id, r.title, r.type, r.created_at, r.created_by, r.updated_at, -- Dernier message (SELECT m.content FROM chat_messages m WHERE m.room_id = r.id AND m.is_deleted = 0 ORDER BY m.sent_at DESC LIMIT 1) as last_message, (SELECT m.sent_at FROM chat_messages m WHERE m.room_id = r.id AND m.is_deleted = 0 ORDER BY m.sent_at DESC LIMIT 1) as last_message_at, -- Nombre de messages non lus (SELECT COUNT(*) FROM chat_messages m WHERE m.room_id = r.id AND m.sender_id != :user_id_count AND NOT EXISTS ( SELECT 1 FROM chat_read_receipts rr WHERE rr.message_id = m.id AND rr.user_id = :user_id_receipts )) as unread_count, -- Participants (SELECT COUNT(*) FROM chat_participants cp WHERE cp.room_id = r.id AND cp.left_at IS NULL) as participant_count, r.is_active, -- Permission ecriture CASE WHEN r.type = "broadcast" THEN (r.created_by = :user_id_write) ELSE COALESCE(p.can_write, 1) END as can_write FROM chat_rooms r INNER JOIN chat_participants p ON r.id = p.room_id WHERE p.user_id = :user_id '; // Pour la synchronisation incrémentale if ($isIncrementalSync) { $sql .= ' AND ( -- Rooms avec nouveaux messages SEULEMENT (r.is_active = 1 AND p.left_at IS NULL AND EXISTS ( SELECT 1 FROM chat_messages m WHERE m.room_id = r.id AND m.sent_at > :updated_after_msg ) ) OR -- Nouvelles rooms créées après updated_after (r.is_active = 1 AND p.left_at IS NULL AND r.created_at > :updated_after_created) OR -- Rooms récemment supprimées (seulement si supprimées après updated_after) (r.is_active = 0 AND r.updated_at > :updated_after_deleted) )'; } else { // Synchronisation initiale : seulement les rooms actives $sql .= ' AND r.is_active = 1 AND p.left_at IS NULL'; } // Filtrage supplémentaire selon le rôle if ($userRole == 1) { // Utilisateur simple : seulement ses conversations privées et de groupe $sql .= ' AND r.type IN ("private", "group")'; } elseif ($userRole == 2) { // Admin d'entité : toutes les conversations de son entité $sql .= ' AND (p.entite_id = :entity_id OR r.type = "broadcast")'; } // Rôle > 2 : accès à toutes les conversations $sql .= ' ORDER BY COALESCE(last_message_at, r.created_at) DESC'; $stmt = $this->db->prepare($sql); $params = [ 'user_id' => $userId, 'user_id_count' => $userId, 'user_id_receipts' => $userId, 'user_id_write' => $userId ]; if ($isIncrementalSync) { $params['updated_after_created'] = $updatedAfter; $params['updated_after_msg'] = $updatedAfter; $params['updated_after_deleted'] = $updatedAfter; } if ($userRole == 2) { $params['entity_id'] = $entityId; } $stmt->execute($params); $rooms = $stmt->fetchAll(\PDO::FETCH_ASSOC); // Pour chaque room, récupérer les participants et les messages récents foreach ($rooms as &$room) { // Ajouter le flag deleted si la room est inactive $room['deleted'] = ($room['is_active'] == 0); unset($room['is_active']); // Retirer is_active de la réponse // Convertir can_write en booléen $room['can_write'] = (bool)$room['can_write']; // Pour les rooms supprimées, pas besoin des détails if (!$room['deleted']) { $room['participants'] = $this->getRoomParticipants($room['id']); if ($isIncrementalSync) { // Sync incrémentale : seulement les nouveaux messages $newMessages = $this->getNewMessages($room['id'], $userId, $updatedAfter); $room['recent_messages'] = $newMessages; // Si pas de nouveaux messages mais la room est quand même retournée, // c'est qu'elle a été créée récemment - on prend les 5 derniers if (empty($newMessages) && $room['created_at'] > $updatedAfter) { $room['recent_messages'] = $this->getRecentMessages($room['id'], $userId, 5); } } else { // Sync initiale : les 5 derniers messages $room['recent_messages'] = $this->getRecentMessages($room['id'], $userId, 5); } } else { $room['participants'] = []; $room['recent_messages'] = []; } } \LogService::log('Récupération des conversations', [ 'level' => 'debug', 'user_id' => $userId, 'room_count' => count($rooms), 'is_incremental' => $isIncrementalSync, 'updated_after' => $updatedAfter ?? 'N/A' ]); \Response::json([ 'status' => 'success', 'sync_timestamp' => $syncTimestamp, 'has_changes' => !empty($rooms), 'rooms' => $rooms ]); } catch (\PDOException $e) { \LogService::log('Erreur lors de la récupération des conversations', [ 'level' => 'error', 'error' => $e->getMessage() ]); \Response::json([ 'status' => 'error', 'message' => 'Erreur serveur' ], 500); } } /** * POST /api/chat/rooms * Créer une nouvelle conversation */ public function createRoom(): void { // L'authentification est déjà vérifiée par le Router pour les routes privées try { $data = \Request::getJson(); $userId = \Session::getUserId(); $entityId = \Session::getEntityId(); $userRole = $this->getUserRole($userId); // Récupérer le temp_id s'il est fourni (pour la synchronisation offline) $tempId = $data['temp_id'] ?? null; // Validation des données if (!isset($data['type']) || !in_array($data['type'], ['private', 'group', 'broadcast'])) { \Response::json([ 'status' => 'error', 'message' => 'Type de conversation invalide' ], 400); return; } // Vérification des permissions pour broadcast // Seuls les super admins (role = 9) peuvent créer des broadcasts if ($data['type'] === 'broadcast' && $userRole != 9) { \Response::json([ 'status' => 'error', 'message' => 'Seuls les super administrateurs peuvent créer des annonces' ], 403); return; } // Validation des participants if (!isset($data['participants']) || !is_array($data['participants']) || empty($data['participants'])) { \Response::json([ 'status' => 'error', 'message' => 'Au moins un participant requis' ], 400); return; } // Pour une conversation privée, limiter à 2 participants (incluant le créateur) if ($data['type'] === 'private' && count($data['participants']) > 1) { \Response::json([ 'status' => 'error', 'message' => 'Une conversation privée ne peut avoir que 2 participants' ], 400); return; } // Vérifier que tous les participants existent et sont accessibles $participantIds = array_map('intval', $data['participants']); if (!in_array($userId, $participantIds)) { $participantIds[] = $userId; // Ajouter le créateur } // Vérifier l'existence d'une conversation privée existante if ($data['type'] === 'private' && count($participantIds) === 2) { $existingRoom = $this->findExistingPrivateRoom($participantIds[0], $participantIds[1]); if ($existingRoom) { // Ajouter le temp_id même pour une room existante if ($tempId !== null) { $existingRoom['temp_id'] = $tempId; } \Response::json([ 'status' => 'success', 'room' => $existingRoom, 'existing' => true ]); return; } } // Générer un UUID pour la room $roomId = $this->generateUUID(); // Titre de la conversation $title = $data['title'] ?? null; if (!$title && $data['type'] === 'private') { // Pour une conversation privée, pas de titre par défaut $title = null; } $this->db->beginTransaction(); try { // Créer la room $stmt = $this->db->prepare(' INSERT INTO chat_rooms (id, title, type, created_by, created_at) VALUES (:id, :title, :type, :created_by, NOW()) '); $stmt->execute([ 'id' => $roomId, 'title' => $title, 'type' => $data['type'], 'created_by' => $userId ]); // Ajouter les participants $participantStmt = $this->db->prepare(' INSERT INTO chat_participants (room_id, user_id, role, entite_id, is_admin, can_write) VALUES (:room_id, :user_id, :role, :entite_id, :is_admin, :can_write) '); foreach ($participantIds as $participantId) { $participantData = $this->getUserData($participantId); if (!$participantData) { throw new \Exception("Participant invalide: $participantId"); } // Pour les broadcasts, seul le créateur peut écrire $canWrite = true; if ($data['type'] === 'broadcast') { $canWrite = ($participantId === $userId); } $participantStmt->execute([ 'room_id' => $roomId, 'user_id' => $participantId, 'role' => $participantData['fk_role'], 'entite_id' => $participantData['fk_entite'], 'is_admin' => ($participantId === $userId) ? 1 : 0, 'can_write' => $canWrite ? 1 : 0 ]); } // Si un message initial est fourni, l'envoyer if (isset($data['initial_message']) && !empty($data['initial_message'])) { $messageId = $this->generateUUID(); $msgStmt = $this->db->prepare(' INSERT INTO chat_messages (id, room_id, content, sender_id, sent_at) VALUES (:id, :room_id, :content, :sender_id, NOW()) '); $msgStmt->execute([ 'id' => $messageId, 'room_id' => $roomId, 'content' => $data['initial_message'], 'sender_id' => $userId ]); } $this->db->commit(); \LogService::log('Conversation créée', [ 'level' => 'info', 'room_id' => $roomId, 'type' => $data['type'], 'created_by' => $userId, 'participant_count' => count($participantIds) ]); // Récupérer la room créée avec ses détails $room = $this->getRoomDetails($roomId); // Ajouter le temp_id à la réponse si fourni if ($tempId !== null) { $room['temp_id'] = $tempId; } \Response::json([ 'status' => 'success', 'room' => $room ], 201); } catch (\Exception $e) { $this->db->rollBack(); throw $e; } } catch (\PDOException $e) { \LogService::log('Erreur lors de la création de la conversation', [ 'level' => 'error', 'error' => $e->getMessage() ]); \Response::json([ 'status' => 'error', 'message' => 'Erreur serveur' ], 500); } catch (\Exception $e) { \LogService::log('Erreur lors de la création de la conversation', [ 'level' => 'error', 'error' => $e->getMessage() ]); \Response::json([ 'status' => 'error', 'message' => $e->getMessage() ], 400); } } /** * PUT /api/chat/rooms/{id} * Mettre à jour une conversation */ public function updateRoom(string $roomId): void { // L'authentification est déjà vérifiée par le Router pour les routes privées try { $data = \Request::getJson(); $userId = \Session::getUserId(); // Récupérer le temp_id s'il est fourni (pour la synchronisation offline) $tempId = $data['temp_id'] ?? null; // Vérifier que la room existe et que l'utilisateur est admin ou créateur $stmt = $this->db->prepare(' SELECT r.created_by, r.is_active, p.is_admin FROM chat_rooms r INNER JOIN chat_participants p ON r.id = p.room_id WHERE r.id = ? AND p.user_id = ? '); $stmt->execute([$roomId, $userId]); $room = $stmt->fetch(\PDO::FETCH_ASSOC); if (!$room) { \Response::json([ 'status' => 'error', 'message' => 'Conversation non trouvée ou accès non autorisé' ], 404); return; } // Vérifier les permissions if ($room['created_by'] != $userId && !$room['is_admin']) { \Response::json([ 'status' => 'error', 'message' => 'Seul le créateur ou un admin peut modifier la conversation' ], 403); return; } // Mettre à jour le titre si fourni if (isset($data['title'])) { $updateStmt = $this->db->prepare(' UPDATE chat_rooms SET title = :title, updated_at = NOW() WHERE id = :room_id '); $updateStmt->execute([ 'title' => $data['title'], 'room_id' => $roomId ]); } // Récupérer la room mise à jour $updatedRoom = $this->getRoomDetails($roomId); // Ajouter le temp_id à la réponse si fourni if ($tempId !== null) { $updatedRoom['temp_id'] = $tempId; } \LogService::log('Conversation mise à jour', [ 'level' => 'info', 'room_id' => $roomId, 'updated_by' => $userId ]); \Response::json([ 'status' => 'success', 'room' => $updatedRoom ]); } catch (\PDOException $e) { \LogService::log('Erreur lors de la mise à jour de la conversation', [ 'level' => 'error', 'room_id' => $roomId, 'error' => $e->getMessage() ]); \Response::json([ 'status' => 'error', 'message' => 'Erreur serveur' ], 500); } } /** * DELETE /api/chat/rooms/{id} * Supprimer une conversation (soft delete) * Seul le créateur peut supprimer */ public function deleteRoom(string $roomId): void { // L'authentification est déjà vérifiée par le Router pour les routes privées try { $userId = \Session::getUserId(); // Vérifier que la room existe et récupérer le créateur $stmt = $this->db->prepare(' SELECT created_by, is_active FROM chat_rooms WHERE id = ? '); $stmt->execute([$roomId]); $room = $stmt->fetch(\PDO::FETCH_ASSOC); // Vérifier que la room existe if (!$room) { \Response::json([ 'status' => 'error', 'message' => 'Conversation non trouvée' ], 404); return; } // Vérifier que la room n'est pas déjà supprimée if ($room['is_active'] == 0) { \Response::json([ 'status' => 'error', 'message' => 'Cette conversation est déjà supprimée' ], 400); return; } // Vérifier que l'utilisateur est le créateur if ($room['created_by'] != $userId) { \Response::json([ 'status' => 'error', 'message' => 'Seul le créateur de la conversation peut la supprimer' ], 403); return; } $this->db->beginTransaction(); try { // Soft delete : marquer comme inactive $updateStmt = $this->db->prepare(' UPDATE chat_rooms SET is_active = 0, updated_at = NOW() WHERE id = ? '); $updateStmt->execute([$roomId]); // Marquer tous les participants comme ayant quitté $participantsStmt = $this->db->prepare(' UPDATE chat_participants SET left_at = NOW() WHERE room_id = ? AND left_at IS NULL '); $participantsStmt->execute([$roomId]); $this->db->commit(); \LogService::log('Conversation supprimée', [ 'level' => 'info', 'room_id' => $roomId, 'deleted_by' => $userId ]); \Response::json([ 'status' => 'success', 'message' => 'Conversation supprimée avec succès' ]); } catch (\Exception $e) { if ($this->db->inTransaction()) { $this->db->rollBack(); } throw $e; } } catch (\PDOException $e) { \LogService::log('Erreur lors de la suppression de la conversation', [ 'level' => 'error', 'room_id' => $roomId, 'error' => $e->getMessage() ]); \Response::json([ 'status' => 'error', 'message' => 'Erreur serveur' ], 500); } } /** * GET /api/chat/rooms/{id}/messages * Récupérer les messages d'une conversation */ public function getRoomMessages(string $roomId): void { // L'authentification est déjà vérifiée par le Router pour les routes privées try { $userId = \Session::getUserId(); // Vérifier que l'utilisateur est participant if (!$this->isUserInRoom($userId, $roomId)) { \Response::json([ 'status' => 'error', 'message' => 'Accès non autorisé à cette conversation' ], 403); return; } // Paramètres de pagination $limit = isset($_GET['limit']) ? min(100, max(1, (int)$_GET['limit'])) : 50; $before = $_GET['before'] ?? null; // Message ID pour pagination $sql = ' SELECT m.id, m.content, m.sender_id, m.sent_at, m.edited_at, m.is_deleted, u.encrypted_name as sender_name, u.first_name as sender_first_name, -- Statut de lecture (SELECT COUNT(*) FROM chat_read_receipts r WHERE r.message_id = m.id) as read_count, (SELECT COUNT(*) FROM chat_read_receipts r WHERE r.message_id = m.id AND r.user_id = :user_id) as is_read FROM chat_messages m INNER JOIN users u ON m.sender_id = u.id WHERE m.room_id = :room_id '; $params = [ 'room_id' => $roomId, 'user_id' => $userId ]; if ($before) { $sql .= ' AND m.sent_at < (SELECT sent_at FROM chat_messages WHERE id = :before)'; $params['before'] = $before; } $sql .= ' ORDER BY m.sent_at DESC LIMIT :limit'; $stmt = $this->db->prepare($sql); foreach ($params as $key => $value) { if ($key === 'limit') { $stmt->bindValue($key, $value, \PDO::PARAM_INT); } else { $stmt->bindValue($key, $value); } } $stmt->bindValue('limit', $limit, \PDO::PARAM_INT); $stmt->execute(); $messages = $stmt->fetchAll(\PDO::FETCH_ASSOC); // Déchiffrer les noms foreach ($messages as &$message) { $message['sender_name'] = \ApiService::decryptData($message['sender_name']); $message['is_read'] = (bool)$message['is_read']; $message['is_mine'] = ($message['sender_id'] == $userId); } // Inverser pour avoir l'ordre chronologique $messages = array_reverse($messages); // Marquer automatiquement tous les messages comme lus $markedCount = $this->markAllMessagesAsRead($roomId, $userId); // Mettre à jour last_read_at pour ce participant $this->updateLastRead($roomId, $userId); // Compter les messages non lus restants (devrait être 0) $unreadCount = $this->getUnreadCount($roomId, $userId); \Response::json([ 'status' => 'success', 'messages' => $messages, 'has_more' => count($messages) === $limit, 'marked_as_read' => $markedCount, 'unread_count' => $unreadCount ]); } catch (\PDOException $e) { \LogService::log('Erreur lors de la récupération des messages', [ 'level' => 'error', 'room_id' => $roomId, 'error' => $e->getMessage() ]); \Response::json([ 'status' => 'error', 'message' => 'Erreur serveur' ], 500); } } /** * POST /api/chat/rooms/{id}/messages * Envoyer un message dans une conversation */ public function sendMessage(string $roomId): void { // L'authentification est déjà vérifiée par le Router pour les routes privées try { $data = \Request::getJson(); $userId = \Session::getUserId(); // Récupérer le temp_id s'il est fourni (pour la synchronisation offline) $tempId = $data['temp_id'] ?? null; // Vérifier que l'utilisateur est participant if (!$this->isUserInRoom($userId, $roomId)) { \Response::json([ 'status' => 'error', 'message' => 'Accès non autorisé à cette conversation' ], 403); return; } // Vérifier les permissions d'écriture pour les broadcasts $roomInfo = $this->getRoomInfo($roomId); if ($roomInfo && $roomInfo['type'] === 'broadcast') { // Pour les broadcasts, seul le créateur peut écrire if ($roomInfo['created_by'] != $userId) { \Response::json([ 'status' => 'error', 'message' => 'Seul l\'administrateur peut poster dans une annonce' ], 403); return; } } else { // Pour les autres types, vérifier can_write if (!$this->canUserWrite($userId, $roomId)) { \Response::json([ 'status' => 'error', 'message' => 'Vous n\'avez pas la permission d\'écrire dans cette conversation' ], 403); return; } } // Validation du contenu if (!isset($data['content']) || empty(trim($data['content']))) { \Response::json([ 'status' => 'error', 'message' => 'Le message ne peut pas être vide' ], 400); return; } $content = trim($data['content']); // Limiter la longueur du message if (mb_strlen($content, 'UTF-8') > 5000) { \Response::json([ 'status' => 'error', 'message' => 'Message trop long (max 5000 caractères)' ], 400); return; } $messageId = $this->generateUUID(); // Insérer le message $stmt = $this->db->prepare(' INSERT INTO chat_messages (id, room_id, content, sender_id, sent_at) VALUES (:id, :room_id, :content, :sender_id, NOW()) '); $stmt->execute([ 'id' => $messageId, 'room_id' => $roomId, 'content' => $content, 'sender_id' => $userId ]); // Mettre à jour la date de dernière modification de la room $updateStmt = $this->db->prepare(' UPDATE chat_rooms SET updated_at = NOW() WHERE id = :room_id '); $updateStmt->execute(['room_id' => $roomId]); // Récupérer le message créé avec les infos du sender $msgStmt = $this->db->prepare(' SELECT m.id, m.content, m.sender_id, m.sent_at, u.encrypted_name as sender_name, u.first_name as sender_first_name FROM chat_messages m INNER JOIN users u ON m.sender_id = u.id WHERE m.id = :id '); $msgStmt->execute(['id' => $messageId]); $message = $msgStmt->fetch(\PDO::FETCH_ASSOC); $message['sender_name'] = \ApiService::decryptData($message['sender_name']); $message['is_mine'] = true; $message['is_read'] = false; $message['read_count'] = 0; // Ajouter le temp_id à la réponse si fourni if ($tempId !== null) { $message['temp_id'] = $tempId; } \LogService::log('Message envoyé', [ 'level' => 'debug', 'room_id' => $roomId, 'message_id' => $messageId, 'sender_id' => $userId ]); \Response::json([ 'status' => 'success', 'message' => $message ], 201); } catch (\PDOException $e) { \LogService::log('Erreur lors de l\'envoi du message', [ 'level' => 'error', 'room_id' => $roomId, 'error' => $e->getMessage() ]); \Response::json([ 'status' => 'error', 'message' => 'Erreur serveur' ], 500); } } /** * PUT /api/chat/messages/{id} * Mettre à jour un message (édition) */ public function updateMessage(string $messageId): void { // L'authentification est déjà vérifiée par le Router pour les routes privées try { $data = \Request::getJson(); $userId = \Session::getUserId(); // Récupérer le temp_id s'il est fourni (pour la synchronisation offline) $tempId = $data['temp_id'] ?? null; // Vérifier que le message existe et appartient à l'utilisateur $stmt = $this->db->prepare(' SELECT m.id, m.sender_id, m.room_id, m.content, m.is_deleted FROM chat_messages m WHERE m.id = ? '); $stmt->execute([$messageId]); $message = $stmt->fetch(\PDO::FETCH_ASSOC); if (!$message) { \Response::json([ 'status' => 'error', 'message' => 'Message non trouvé' ], 404); return; } // Vérifier que l'utilisateur est le sender du message if ($message['sender_id'] != $userId) { \Response::json([ 'status' => 'error', 'message' => 'Vous ne pouvez modifier que vos propres messages' ], 403); return; } // Vérifier que le message n'est pas supprimé if ($message['is_deleted']) { \Response::json([ 'status' => 'error', 'message' => 'Ce message a été supprimé' ], 400); return; } // Validation du contenu if (!isset($data['content']) || empty(trim($data['content']))) { \Response::json([ 'status' => 'error', 'message' => 'Le message ne peut pas être vide' ], 400); return; } $content = trim($data['content']); // Limiter la longueur du message if (mb_strlen($content, 'UTF-8') > 5000) { \Response::json([ 'status' => 'error', 'message' => 'Message trop long (max 5000 caractères)' ], 400); return; } // Mettre à jour le message $updateStmt = $this->db->prepare(' UPDATE chat_messages SET content = :content, edited_at = NOW() WHERE id = :message_id '); $updateStmt->execute([ 'content' => $content, 'message_id' => $messageId ]); // Récupérer le message mis à jour $msgStmt = $this->db->prepare(' SELECT m.id, m.content, m.sender_id, m.sent_at, m.edited_at, u.encrypted_name as sender_name, u.first_name as sender_first_name FROM chat_messages m INNER JOIN users u ON m.sender_id = u.id WHERE m.id = :id '); $msgStmt->execute(['id' => $messageId]); $updatedMessage = $msgStmt->fetch(\PDO::FETCH_ASSOC); $updatedMessage['sender_name'] = \ApiService::decryptData($updatedMessage['sender_name']); $updatedMessage['is_mine'] = true; // Ajouter le temp_id à la réponse si fourni if ($tempId !== null) { $updatedMessage['temp_id'] = $tempId; } \LogService::log('Message modifié', [ 'level' => 'debug', 'message_id' => $messageId, 'sender_id' => $userId ]); \Response::json([ 'status' => 'success', 'message' => $updatedMessage ]); } catch (\PDOException $e) { \LogService::log('Erreur lors de la modification du message', [ 'level' => 'error', 'message_id' => $messageId, 'error' => $e->getMessage() ]); \Response::json([ 'status' => 'error', 'message' => 'Erreur serveur' ], 500); } } /** * POST /api/chat/rooms/{id}/read * Marquer les messages comme lus */ public function markAsRead(string $roomId): void { // L'authentification est déjà vérifiée par le Router pour les routes privées try { $data = \Request::getJson(); $userId = \Session::getUserId(); // Vérifier que l'utilisateur est participant if (!$this->isUserInRoom($userId, $roomId)) { \Response::json([ 'status' => 'error', 'message' => 'Accès non autorisé à cette conversation' ], 403); return; } // Si des message_ids spécifiques sont fournis if (isset($data['message_ids']) && is_array($data['message_ids'])) { $messageIds = $data['message_ids']; // Marquer ces messages spécifiques comme lus $stmt = $this->db->prepare(' INSERT IGNORE INTO chat_read_receipts (message_id, user_id, read_at) VALUES (:message_id, :user_id, NOW()) '); foreach ($messageIds as $messageId) { $stmt->execute([ 'message_id' => $messageId, 'user_id' => $userId ]); } } else { // Marquer tous les messages non lus de la room comme lus $stmt = $this->db->prepare(' INSERT IGNORE INTO chat_read_receipts (message_id, user_id, read_at) SELECT m.id, :user_id, NOW() FROM chat_messages m WHERE m.room_id = :room_id AND m.id NOT IN ( SELECT message_id FROM chat_read_receipts WHERE user_id = :user_id_check ) '); $stmt->execute([ 'user_id' => $userId, 'user_id_check' => $userId, 'room_id' => $roomId ]); } // Mettre à jour last_read_at $this->updateLastRead($roomId, $userId); // Compter les messages non lus restants $countStmt = $this->db->prepare(' SELECT COUNT(*) as unread_count FROM chat_messages m WHERE m.room_id = :room_id AND m.sender_id != :user_id AND m.id NOT IN ( SELECT message_id FROM chat_read_receipts WHERE user_id = :user_id_check ) '); $countStmt->execute([ 'room_id' => $roomId, 'user_id' => $userId, 'user_id_check' => $userId ]); $result = $countStmt->fetch(\PDO::FETCH_ASSOC); \Response::json([ 'status' => 'success', 'unread_count' => (int)$result['unread_count'] ]); } catch (\PDOException $e) { \LogService::log('Erreur lors du marquage comme lu', [ 'level' => 'error', 'room_id' => $roomId, 'error' => $e->getMessage() ]); \Response::json([ 'status' => 'error', 'message' => 'Erreur serveur' ], 500); } } /** * GET /api/chat/recipients * Liste des destinataires possibles selon le rôle */ public function getRecipients(): void { // L'authentification est déjà vérifiée par le Router pour les routes privées try { $userId = \Session::getUserId(); $entityId = \Session::getEntityId(); $userRole = $this->getUserRole($userId); $sql = ' SELECT u.id, u.encrypted_name as name, u.first_name, u.sect_name, u.fk_role as role, u.fk_entite as entite_id, e.encrypted_name as entite_name FROM users u LEFT JOIN entites e ON u.fk_entite = e.id WHERE u.chk_active = 1 AND u.id != :user_id '; $params = ['user_id' => $userId]; // Filtrage selon le rôle if ($userRole == 1) { // Utilisateur simple : seulement les utilisateurs de son entité $sql .= ' AND u.fk_entite = :entity_id'; $params['entity_id'] = $entityId; } elseif ($userRole == 2) { // Admin d'entité : // - Tous les membres actifs de son amicale (même entité) // - Les super-admins (fk_role=9) de l'entité 1 $sql .= ' AND ( u.fk_entite = :entity_id OR (u.fk_role = 9 AND u.fk_entite = 1) )'; $params['entity_id'] = $entityId; } elseif ($userRole == 9) { // Super-administrateur : // - SEULEMENT les administrateurs actifs des amicales (fk_role=2) // - PAS les autres super-admins pour éviter les broadcasts entre super-admins $sql .= ' AND u.fk_role = 2'; } // Autres rôles (3-8) : pas de filtrage spécifique pour le moment $sql .= ' ORDER BY u.fk_entite, u.encrypted_name'; $stmt = $this->db->prepare($sql); $stmt->execute($params); $recipients = $stmt->fetchAll(\PDO::FETCH_ASSOC); // Déchiffrer les données et organiser par entité $recipientsByEntity = []; $recipientsDecrypted = []; foreach ($recipients as &$recipient) { // Déchiffrer le nom $recipient['name'] = \ApiService::decryptData($recipient['name']); // Déchiffrer le nom de l'entité $entiteName = $recipient['entite_name'] ? \ApiService::decryptData($recipient['entite_name']) : 'Sans entité'; // Créer une copie pour recipients_by_entity $recipientCopy = $recipient; unset($recipientCopy['entite_name']); // Organiser par entité if (!isset($recipientsByEntity[$entiteName])) { $recipientsByEntity[$entiteName] = []; } $recipientsByEntity[$entiteName][] = $recipientCopy; // Remplacer entite_name chiffré par la version déchiffrée $recipient['entite_name'] = $entiteName; // Ajouter à la liste déchiffrée $recipientsDecrypted[] = $recipient; } \Response::json([ 'status' => 'success', 'recipients' => $recipientsDecrypted, 'recipients_by_entity' => $recipientsByEntity ]); } catch (\PDOException $e) { \LogService::log('Erreur lors de la récupération des destinataires', [ 'level' => 'error', 'error' => $e->getMessage() ]); \Response::json([ 'status' => 'error', 'message' => 'Erreur serveur' ], 500); } } // ===== Méthodes utilitaires privées ===== /** * Récupérer le rôle d'un utilisateur */ private function getUserRole(int $userId): int { $stmt = $this->db->prepare('SELECT fk_role FROM users WHERE id = ?'); $stmt->execute([$userId]); $result = $stmt->fetch(\PDO::FETCH_ASSOC); return $result ? (int)$result['fk_role'] : 1; } /** * Récupérer les données d'un utilisateur */ private function getUserData(int $userId): ?array { $stmt = $this->db->prepare(' SELECT id, fk_role, fk_entite, encrypted_name, first_name FROM users WHERE id = ? AND chk_active = 1 '); $stmt->execute([$userId]); $result = $stmt->fetch(\PDO::FETCH_ASSOC); return $result ?: null; } /** * Vérifier si un utilisateur est dans une room */ private function isUserInRoom(int $userId, string $roomId): bool { $stmt = $this->db->prepare(' SELECT COUNT(*) as count FROM chat_participants WHERE room_id = ? AND user_id = ? AND left_at IS NULL '); $stmt->execute([$roomId, $userId]); $result = $stmt->fetch(\PDO::FETCH_ASSOC); return $result && $result['count'] > 0; } /** * Récupérer les participants d'une room */ private function getRoomParticipants(string $roomId): array { $stmt = $this->db->prepare(' SELECT p.user_id, p.is_admin, u.encrypted_name as name, u.first_name FROM chat_participants p INNER JOIN users u ON p.user_id = u.id WHERE p.room_id = ? AND p.left_at IS NULL '); $stmt->execute([$roomId]); $participants = $stmt->fetchAll(\PDO::FETCH_ASSOC); foreach ($participants as &$participant) { $participant['name'] = \ApiService::decryptData($participant['name']); } return $participants; } /** * Récupérer les détails d'une room */ private function getRoomDetails(string $roomId): array { $stmt = $this->db->prepare(' SELECT r.id, r.title, r.type, r.created_at, r.created_by, r.updated_at FROM chat_rooms r WHERE r.id = ? '); $stmt->execute([$roomId]); $room = $stmt->fetch(\PDO::FETCH_ASSOC); if ($room) { $room['participants'] = $this->getRoomParticipants($roomId); } return $room; } /** * Trouver une conversation privée existante entre deux utilisateurs */ private function findExistingPrivateRoom(int $user1, int $user2): ?array { $stmt = $this->db->prepare(' SELECT r.* FROM chat_rooms r WHERE r.type = "private" AND r.is_active = 1 AND EXISTS ( SELECT 1 FROM chat_participants p1 WHERE p1.room_id = r.id AND p1.user_id = ? AND p1.left_at IS NULL ) AND EXISTS ( SELECT 1 FROM chat_participants p2 WHERE p2.room_id = r.id AND p2.user_id = ? AND p2.left_at IS NULL ) AND ( SELECT COUNT(*) FROM chat_participants p WHERE p.room_id = r.id AND p.left_at IS NULL ) = 2 '); $stmt->execute([$user1, $user2]); $room = $stmt->fetch(\PDO::FETCH_ASSOC); if ($room) { $room['participants'] = $this->getRoomParticipants($room['id']); return $room; } return null; } /** * Mettre à jour la date de dernière lecture */ private function updateLastRead(string $roomId, int $userId): void { $stmt = $this->db->prepare(' UPDATE chat_participants SET last_read_at = NOW() WHERE room_id = ? AND user_id = ? '); $stmt->execute([$roomId, $userId]); } /** * Récupérer les nouveaux messages d'une room depuis une date donnée */ private function getNewMessages(string $roomId, int $userId, string $since): array { $stmt = $this->db->prepare(' SELECT m.id, m.content, m.sender_id, m.sent_at, m.edited_at, m.is_deleted, u.encrypted_name as sender_name, u.first_name as sender_first_name, CASE WHEN m.sender_id = :sender_id THEN 1 ELSE (SELECT COUNT(*) FROM chat_read_receipts r WHERE r.message_id = m.id AND r.user_id = :user_id) > 0 END as is_read, (m.sender_id = :sender_check) as is_mine FROM chat_messages m INNER JOIN users u ON m.sender_id = u.id WHERE m.room_id = :room_id AND m.is_deleted = 0 AND m.sent_at > :since ORDER BY m.sent_at ASC '); $stmt->bindValue('room_id', $roomId); $stmt->bindValue('user_id', $userId, \PDO::PARAM_INT); $stmt->bindValue('sender_id', $userId, \PDO::PARAM_INT); $stmt->bindValue('sender_check', $userId, \PDO::PARAM_INT); $stmt->bindValue('since', $since); $stmt->execute(); $messages = $stmt->fetchAll(\PDO::FETCH_ASSOC); // Déchiffrer les noms et convertir les booléens foreach ($messages as &$message) { $message['sender_name'] = \ApiService::decryptData($message['sender_name']); $message['is_read'] = (bool)$message['is_read']; $message['is_mine'] = (bool)$message['is_mine']; } return $messages; } /** * Récupérer les messages récents d'une room */ private function getRecentMessages(string $roomId, int $userId, int $limit = 5): array { $stmt = $this->db->prepare(' SELECT m.id, m.content, m.sender_id, m.sent_at, m.edited_at, m.is_deleted, u.encrypted_name as sender_name, u.first_name as sender_first_name, CASE WHEN m.sender_id = :sender_id THEN 1 ELSE (SELECT COUNT(*) FROM chat_read_receipts r WHERE r.message_id = m.id AND r.user_id = :user_id) > 0 END as is_read, (m.sender_id = :sender_check) as is_mine FROM chat_messages m INNER JOIN users u ON m.sender_id = u.id WHERE m.room_id = :room_id AND m.is_deleted = 0 ORDER BY m.sent_at DESC LIMIT :limit '); $stmt->bindValue('room_id', $roomId); $stmt->bindValue('user_id', $userId, \PDO::PARAM_INT); $stmt->bindValue('sender_id', $userId, \PDO::PARAM_INT); $stmt->bindValue('sender_check', $userId, \PDO::PARAM_INT); $stmt->bindValue('limit', $limit, \PDO::PARAM_INT); $stmt->execute(); $messages = $stmt->fetchAll(\PDO::FETCH_ASSOC); // Déchiffrer les noms et convertir les booléens foreach ($messages as &$message) { $message['sender_name'] = \ApiService::decryptData($message['sender_name']); $message['is_read'] = (bool)$message['is_read']; $message['is_mine'] = (bool)$message['is_mine']; } // Inverser pour avoir l'ordre chronologique return array_reverse($messages); } /** * Marquer tous les messages d'une room comme lus */ private function markAllMessagesAsRead(string $roomId, int $userId): int { // Compter d'abord les messages non lus $countStmt = $this->db->prepare(' SELECT COUNT(*) as count FROM chat_messages m WHERE m.room_id = :room_id AND m.sender_id != :user_id AND NOT EXISTS ( SELECT 1 FROM chat_read_receipts r WHERE r.message_id = m.id AND r.user_id = :user_id_check ) '); $countStmt->execute([ 'room_id' => $roomId, 'user_id' => $userId, 'user_id_check' => $userId ]); $result = $countStmt->fetch(\PDO::FETCH_ASSOC); $unreadCount = (int)$result['count']; // Marquer tous les messages non lus comme lus if ($unreadCount > 0) { $stmt = $this->db->prepare(' INSERT IGNORE INTO chat_read_receipts (message_id, user_id, read_at) SELECT m.id, :user_id, NOW() FROM chat_messages m WHERE m.room_id = :room_id AND NOT EXISTS ( SELECT 1 FROM chat_read_receipts r WHERE r.message_id = m.id AND r.user_id = :user_id_check ) '); $stmt->execute([ 'user_id' => $userId, 'room_id' => $roomId, 'user_id_check' => $userId ]); } return $unreadCount; } /** * Compter les messages non lus d'une room */ private function getUnreadCount(string $roomId, int $userId): int { $stmt = $this->db->prepare(' SELECT COUNT(*) as count FROM chat_messages m WHERE m.room_id = :room_id AND m.sender_id != :user_id AND NOT EXISTS ( SELECT 1 FROM chat_read_receipts r WHERE r.message_id = m.id AND r.user_id = :user_id_check ) '); $stmt->execute([ 'room_id' => $roomId, 'user_id' => $userId, 'user_id_check' => $userId ]); $result = $stmt->fetch(\PDO::FETCH_ASSOC); return (int)$result['count']; } /** * Générer un UUID v4 */ private function generateUUID(): string { return sprintf( '%04x%04x-%04x-%04x-%04x-%04x%04x%04x', mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0x0fff) | 0x4000, mt_rand(0, 0x3fff) | 0x8000, mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff) ); } /** * Récupérer les infos d'une room */ private function getRoomInfo(string $roomId): ?array { $stmt = $this->db->prepare(' SELECT id, type, created_by, title FROM chat_rooms WHERE id = ? '); $stmt->execute([$roomId]); $result = $stmt->fetch(\PDO::FETCH_ASSOC); return $result ?: null; } /** * Vérifier si un utilisateur peut écrire dans une room */ private function canUserWrite(int $userId, string $roomId): bool { $stmt = $this->db->prepare(' SELECT p.can_write, r.type, r.created_by FROM chat_participants p INNER JOIN chat_rooms r ON p.room_id = r.id WHERE p.room_id = ? AND p.user_id = ? AND p.left_at IS NULL '); $stmt->execute([$roomId, $userId]); $result = $stmt->fetch(\PDO::FETCH_ASSOC); if (!$result) { return false; } // Pour les broadcasts, seul le créateur peut écrire if ($result['type'] === 'broadcast') { return $result['created_by'] == $userId; } // Pour les autres types, vérifier can_write return (bool)$result['can_write']; } }