Files
geo/api/src/Controllers/ChatController.php

1559 lines
58 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Controllers;
require_once __DIR__ . '/../Services/LogService.php';
require_once __DIR__ . '/../Services/ApiService.php';
// Les classes sont déjà incluses via require_once, pas besoin de 'use' statements
class ChatController {
private \PDO $db;
public function __construct() {
$this->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'];
}
}