Init Git Prod Mars 2026
This commit is contained in:
commit
d81efe93b1
362 files changed
+86627
No files matched your search
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
// /messages/_bootstrap.php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Bootstrap commun de la messagerie.
|
||||
* - Mode CLI (cron/outils) : bypass login/CSRF, mais DB chargée.
|
||||
* - Mode Web : session requise (role=praticien|client + reference_id), CSRF.
|
||||
* - Charge DB (../includes/db.php ou ../public/includes/db.php) + i18n.
|
||||
* - Expose helpers h(), json_response(), assert_conversation_access().
|
||||
* - Expose $GLOBALS['MESSAGES_CTX'] = [role, me_id, csrf_token].
|
||||
*/
|
||||
|
||||
if (session_status() !== PHP_SESSION_ACTIVE) {
|
||||
session_start();
|
||||
}
|
||||
// Charge le bootstrap global du site (env + session + DB + lang + helpers)
|
||||
require_once realpath(__DIR__ . '/../public/includes/init_pr_cl.php');
|
||||
if (!isset($pdo) || !($pdo instanceof PDO)) {
|
||||
json_response(['ok'=>false,'error'=>'pdo_not_in_scope']);
|
||||
}
|
||||
|
||||
// ===== Helpers communs =====
|
||||
if (!function_exists('h')) {
|
||||
function h($v): string {
|
||||
return htmlspecialchars((string)$v, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||
}
|
||||
}
|
||||
if (!function_exists('json_response')) {
|
||||
function json_response(array $data, int $code = 200): void {
|
||||
http_response_code($code);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
if (!function_exists('assert_conversation_access')) {
|
||||
/**
|
||||
* Vérifie l'accès du user courant à la conversation.
|
||||
* @return array Conversation row (id, praticien_id, client_id, status)
|
||||
*/
|
||||
function assert_conversation_access(PDO $pdo, int $conversation_id, string $role, int $me_id): array {
|
||||
$stmt = $pdo->prepare("SELECT id, praticien_id, client_id, status FROM conversations WHERE id = ? LIMIT 1");
|
||||
$stmt->execute([$conversation_id]);
|
||||
$c = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$c) json_response(['error' => 'conversation_not_found'], 404);
|
||||
|
||||
$ok = ($role === 'praticien' && (int)$c['praticien_id'] === $me_id)
|
||||
|| ($role === 'client' && (int)$c['client_id'] === $me_id);
|
||||
|
||||
// En CLI (outils), on ne bloque pas; en Web on bloque
|
||||
if (!$ok && PHP_SAPI !== 'cli') {
|
||||
json_response(['error' => 'forbidden'], 403);
|
||||
}
|
||||
return $c;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Contexte selon SAPI =====
|
||||
if (PHP_SAPI === 'cli') {
|
||||
// --- MODE CLI (ex: /messages/tools/gc.php) ---
|
||||
// Pas d'exigence de session/login/CSRF ici.
|
||||
$GLOBALS['MESSAGES_CTX'] = [
|
||||
'role' => 'praticien', // valeur neutre; non utilisée par gc.php
|
||||
'me_id' => 0,
|
||||
'csrf_token' => ''
|
||||
];
|
||||
} else {
|
||||
// --- MODE WEB ---
|
||||
// Sécurité (require_login) depuis /public/includes/securite.php si présent.
|
||||
$sec = APP_HTDOCS . '/public/includes/securite.php';
|
||||
if (is_file($sec)) {
|
||||
require_once $sec;
|
||||
if (function_exists('require_login_or_redirect')) {
|
||||
require_login_or_redirect(['praticien', 'client','admin']);
|
||||
}
|
||||
}
|
||||
|
||||
// Variables de session attendues
|
||||
$role = $_SESSION['role'] ?? null; // 'praticien' | 'client'
|
||||
$me_id = $_SESSION['reference_id'] ?? null; // id de la personne (table praticien|client)
|
||||
|
||||
if (!in_array($role, ['praticien', 'client','admin'], true) || empty($me_id)) {
|
||||
http_response_code(403);
|
||||
echo "Accès refusé (session invalide).";
|
||||
exit;
|
||||
}
|
||||
|
||||
// CSRF token
|
||||
if (empty($_SESSION['_csrf'])) {
|
||||
$_SESSION['_csrf'] = bin2hex(random_bytes(16));
|
||||
}
|
||||
$csrf_token = $_SESSION['_csrf'];
|
||||
|
||||
$GLOBALS['MESSAGES_CTX'] = [
|
||||
'role' => $role,
|
||||
'me_id' => (int)$me_id,
|
||||
'csrf_token' => $csrf_token
|
||||
];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
// /messages/api/archive_conversation.php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/../_bootstrap.php';
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
if (!hash_equals($_SESSION['_csrf'] ?? '', $_POST['_csrf'] ?? '')) {
|
||||
json_response(['error' => 'csrf'], 419);
|
||||
}
|
||||
|
||||
$role = $GLOBALS['MESSAGES_CTX']['role'];
|
||||
$meId = $GLOBALS['MESSAGES_CTX']['me_id'];
|
||||
$convId = (int)($_POST['conversation_id'] ?? 0);
|
||||
$archive= (int)($_POST['archive'] ?? 1);
|
||||
|
||||
if ($convId <= 0) json_response(['error' => 'bad_params'], 400);
|
||||
|
||||
assert_conversation_access($pdo, $convId, $role, $meId);
|
||||
|
||||
$col = ($role === 'praticien') ? 'archived_by_praticien' : 'archived_by_client';
|
||||
$pdo->prepare("UPDATE conversations SET $col = ? WHERE id = ?")->execute([$archive ? 1 : 0, $convId]);
|
||||
|
||||
json_response(['ok' => true, 'archived' => (bool)$archive]);
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
// /messages/api/close_conversation.php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/../_bootstrap.php';
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
if (!hash_equals($_SESSION['_csrf'] ?? '', $_POST['_csrf'] ?? '')) {
|
||||
json_response(['error' => 'csrf'], 419);
|
||||
}
|
||||
|
||||
$role = $GLOBALS['MESSAGES_CTX']['role'];
|
||||
$meId = $GLOBALS['MESSAGES_CTX']['me_id'];
|
||||
$convId = (int)($_POST['conversation_id'] ?? 0);
|
||||
$close = (int)($_POST['close'] ?? 1);
|
||||
|
||||
if ($convId <= 0) json_response(['error' => 'bad_params'], 400);
|
||||
|
||||
// Vérifie l'accès (participant à la conversation)
|
||||
assert_conversation_access($pdo, $convId, $role, $meId);
|
||||
|
||||
// Met à jour le statut
|
||||
if ($close) {
|
||||
$pdo->prepare("UPDATE conversations SET status='closed', closed_at=NOW() WHERE id=?")->execute([$convId]);
|
||||
} else {
|
||||
$pdo->prepare("UPDATE conversations SET status='open', closed_at=NULL WHERE id=?")->execute([$convId]);
|
||||
}
|
||||
|
||||
json_response(['ok' => true, 'status' => $close ? 'closed' : 'open']);
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
// /messages/api/delete_conversation.php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../_bootstrap.php';
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
if (!hash_equals($_SESSION['_csrf'] ?? '', $_POST['_csrf'] ?? '')) {
|
||||
json_response(['error' => 'csrf'], 419);
|
||||
}
|
||||
|
||||
$role = $GLOBALS['MESSAGES_CTX']['role']; // 'praticien' | 'client'
|
||||
$meId = $GLOBALS['MESSAGES_CTX']['me_id'];
|
||||
$convId = (int)($_POST['conversation_id'] ?? 0);
|
||||
|
||||
if ($convId <= 0) {
|
||||
json_response(['error' => 'bad_params'], 400);
|
||||
}
|
||||
|
||||
// Vérifie l'accès (participant)
|
||||
$conv = assert_conversation_access($pdo, $convId, $role, $meId);
|
||||
|
||||
// Colonnes dépendant du rôle
|
||||
$colDeleted = ($role === 'praticien') ? 'deleted_for_praticien' : 'deleted_for_client';
|
||||
$colUnread = ($role === 'praticien') ? 'p_unread' : 'c_unread';
|
||||
|
||||
try {
|
||||
$pdo->beginTransaction();
|
||||
|
||||
// 1) Marquer la conversation supprimée "pour moi"
|
||||
$pdo->prepare("UPDATE conversations SET $colDeleted = 1 WHERE id = ?")
|
||||
->execute([$convId]);
|
||||
|
||||
// 2) Masquer tous ses messages "pour moi"
|
||||
$pdo->prepare("UPDATE messages SET $colDeleted = 1 WHERE conversation_id = ?")
|
||||
->execute([$convId]);
|
||||
|
||||
// 3) Réinitialiser mes non-lus sur cette conversation
|
||||
$pdo->prepare("UPDATE conversation_counters SET $colUnread = 0 WHERE conversation_id = ?")
|
||||
->execute([$convId]);
|
||||
|
||||
$pdo->commit();
|
||||
} catch (Throwable $e) {
|
||||
if ($pdo->inTransaction()) { $pdo->rollBack(); }
|
||||
json_response(['error' => 'server_error'], 500);
|
||||
}
|
||||
|
||||
json_response(['ok' => true]);
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
// /messages/api/delete_message.php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/../_bootstrap.php';
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
if (!hash_equals($_SESSION['_csrf'] ?? '', $_POST['_csrf'] ?? '')) {
|
||||
json_response(['error' => 'csrf'], 419);
|
||||
}
|
||||
|
||||
$role = $GLOBALS['MESSAGES_CTX']['role']; // 'praticien' | 'client'
|
||||
$meId = $GLOBALS['MESSAGES_CTX']['me_id'];
|
||||
$msgId = (int)($_POST['message_id'] ?? 0);
|
||||
|
||||
if ($msgId <= 0) json_response(['error' => 'bad_params'], 400);
|
||||
|
||||
// Récupère message + conversation et vérifie l'accès
|
||||
$sql = "SELECT m.id, m.conversation_id
|
||||
FROM messages m
|
||||
JOIN conversations c ON c.id = m.conversation_id
|
||||
WHERE m.id = ?
|
||||
LIMIT 1";
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute([$msgId]);
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$row) json_response(['error' => 'not_found'], 404);
|
||||
|
||||
// Autorisation (est-ce que je participe à cette conversation ?)
|
||||
assert_conversation_access($pdo, (int)$row['conversation_id'], $role, $meId);
|
||||
|
||||
// Marque “supprimé pour moi” selon le rôle
|
||||
$col = ($role === 'praticien') ? 'deleted_for_praticien' : 'deleted_for_client';
|
||||
$upd = $pdo->prepare("UPDATE messages SET $col = 1 WHERE id = ?");
|
||||
$upd->execute([$msgId]);
|
||||
|
||||
json_response(['ok' => true]);
|
||||
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
// /messages/api/download_attachment.php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../_bootstrap.php';
|
||||
|
||||
// Important: aucune sortie HTML avant les headers !
|
||||
/**
|
||||
* Règles d’accès:
|
||||
* - On reçoit ?message_id=ID (GET).
|
||||
* - Vérifie que l’utilisateur courant participe à la conversation du message.
|
||||
* - Vérifie que le message n’est pas rétracté et pas "supprimé pour moi".
|
||||
* - Sert le fichier avec le bon Content-Type et Content-Disposition.
|
||||
*/
|
||||
|
||||
$messageId = (int)($_GET['message_id'] ?? 0);
|
||||
if ($messageId <= 0) {
|
||||
http_response_code(400);
|
||||
echo "Bad params";
|
||||
exit;
|
||||
}
|
||||
|
||||
$role = $GLOBALS['MESSAGES_CTX']['role']; // 'praticien'|'client' (en CLI, inutilisé)
|
||||
$meId = $GLOBALS['MESSAGES_CTX']['me_id'];
|
||||
|
||||
$hideCol = ($role === 'praticien') ? 'deleted_for_praticien' : 'deleted_for_client';
|
||||
|
||||
// Récupère message + conversation
|
||||
$sql = "SELECT m.id, m.conversation_id, m.attachment_path, m.attachment_name,
|
||||
m.retracted_for_all, m.$hideCol AS hidden_for_me,
|
||||
c.praticien_id, c.client_id
|
||||
FROM messages m
|
||||
JOIN conversations c ON c.id = m.conversation_id
|
||||
WHERE m.id = ?
|
||||
LIMIT 1";
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute([$messageId]);
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$row) {
|
||||
http_response_code(404);
|
||||
echo "Message not found";
|
||||
exit;
|
||||
}
|
||||
|
||||
// Accès: dois être participant de la conversation
|
||||
assert_conversation_access($pdo, (int)$row['conversation_id'], $role, (int)$meId);
|
||||
|
||||
// Visibilité: pas rétracté, pas supprimé pour moi, et PJ existante
|
||||
if (!empty($row['retracted_for_all']) || !empty($row['hidden_for_me'])) {
|
||||
http_response_code(403);
|
||||
echo "Forbidden";
|
||||
exit;
|
||||
}
|
||||
$webRel = (string)($row['attachment_path'] ?? '');
|
||||
if ($webRel === '') {
|
||||
http_response_code(404);
|
||||
echo "No attachment";
|
||||
exit;
|
||||
}
|
||||
|
||||
// Sécurité chemin: doit commencer par messages/uploads/
|
||||
if (strpos($webRel, 'messages/uploads/') !== 0) {
|
||||
http_response_code(400);
|
||||
echo "Invalid path";
|
||||
exit;
|
||||
}
|
||||
|
||||
// Résolution chemin disque
|
||||
$siteRoot = realpath(__DIR__ . '/../../'); // racine site
|
||||
$uploadsAbs = $siteRoot . DIRECTORY_SEPARATOR . str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $webRel);
|
||||
$real = realpath($uploadsAbs);
|
||||
$uploadsRoot = realpath(__DIR__ . '/../uploads/');
|
||||
|
||||
if (!$real || !$uploadsRoot || strpos($real, $uploadsRoot) !== 0 || !is_file($real)) {
|
||||
http_response_code(404);
|
||||
echo "File not found";
|
||||
exit;
|
||||
}
|
||||
|
||||
// Détection MIME + nom fichier
|
||||
$finfo = function_exists('finfo_open') ? finfo_open(FILEINFO_MIME_TYPE) : false;
|
||||
$mime = ($finfo && is_file($real)) ? finfo_file($finfo, $real) : null;
|
||||
if ($finfo) { @finfo_close($finfo); }
|
||||
if (!$mime) $mime = 'application/octet-stream';
|
||||
|
||||
$name = (string)($row['attachment_name'] ?? basename($real));
|
||||
$size = filesize($real);
|
||||
|
||||
// En-têtes (évite le sniffing, force téléchargement)
|
||||
header('Content-Type: ' . $mime);
|
||||
header('Content-Length: ' . (string)$size);
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
header('Content-Security-Policy: default-src \'none\'; frame-ancestors \'none\'; sandbox');
|
||||
header('Content-Disposition: attachment; filename*=UTF-8\'\'' . rawurlencode($name));
|
||||
|
||||
// Lecture par chunks (robuste)
|
||||
$fp = @fopen($real, 'rb');
|
||||
if (!$fp) {
|
||||
http_response_code(500);
|
||||
echo "Cannot open file";
|
||||
exit;
|
||||
}
|
||||
|
||||
while (!feof($fp)) {
|
||||
$buf = fread($fp, 8192);
|
||||
if ($buf === false) break;
|
||||
echo $buf;
|
||||
// facultatif: flush pour gros fichiers
|
||||
if (function_exists('fastcgi_finish_request')) { /* noop here */ }
|
||||
}
|
||||
fclose($fp);
|
||||
exit;
|
||||
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
// /messages/api/fetch_messages.php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/../_bootstrap.php';
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
$role = $GLOBALS['MESSAGES_CTX']['role'];
|
||||
$meId = $GLOBALS['MESSAGES_CTX']['me_id'];
|
||||
|
||||
$convId = (int)($_GET['conversation_id'] ?? 0);
|
||||
$sinceId = (int)($_GET['since_id'] ?? 0); // pour récupérer les NOUVEAUX messages (id > since_id)
|
||||
$beforeId = (int)($_GET['before_id'] ?? 0); // pour paginer vers le HAUT (id < before_id)
|
||||
$limit = (int)($_GET['limit'] ?? 50); // taille de page (max 100)
|
||||
|
||||
if ($convId <= 0) json_response(['error' => 'bad_params'], 400);
|
||||
if ($limit <= 0) $limit = 50;
|
||||
if ($limit > 100) $limit = 100;
|
||||
|
||||
// Vérifie droits et récupère la conversation
|
||||
$conv = assert_conversation_access($pdo, $convId, $role, $meId);
|
||||
|
||||
// Filtre de visibilité selon le rôle (suppression “pour moi”)
|
||||
$hideCol = ($role === 'praticien') ? 'deleted_for_praticien' : 'deleted_for_client';
|
||||
|
||||
// Stratégie de tri:
|
||||
// - On interroge en DESC (plus récents en premier) pour limiter efficacement,
|
||||
// - puis on reverse en PHP pour renvoyer ASC (du plus ancien au plus récent) côté client.
|
||||
$params = [$convId];
|
||||
$where = "conversation_id = ? AND $hideCol = 0";
|
||||
$order = "ORDER BY id DESC";
|
||||
$limitSql = "LIMIT $limit";
|
||||
|
||||
if ($sinceId > 0 && $beforeId > 0) {
|
||||
// Cas ambigu — on privilégie since_id (nouveaux messages)
|
||||
$beforeId = 0;
|
||||
}
|
||||
|
||||
if ($sinceId > 0) {
|
||||
$where .= " AND id > ?";
|
||||
$params[] = $sinceId;
|
||||
} elseif ($beforeId > 0) {
|
||||
$where .= " AND id < ?";
|
||||
$params[] = $beforeId;
|
||||
} else {
|
||||
// premier chargement (pas de since/before) → rien à ajouter
|
||||
}
|
||||
|
||||
$sql = "SELECT id, conversation_id, sender_role, sender_id, body,
|
||||
attachment_path, attachment_name, retracted_for_all, created_at
|
||||
FROM messages
|
||||
WHERE $where
|
||||
$order
|
||||
$limitSql";
|
||||
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
$rowsDesc = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// Transformations + placeholder “(Message supprimé)”
|
||||
$rows = array_reverse($rowsDesc); // renvoyer ASC
|
||||
foreach ($rows as &$m) {
|
||||
$m['id'] = (int)$m['id'];
|
||||
$m['conversation_id'] = (int)$m['conversation_id'];
|
||||
$m['sender_id'] = (int)$m['sender_id'];
|
||||
if (!empty($m['retracted_for_all'])) {
|
||||
$m['body'] = '(' . ($translations['msg_message_supprime'] ?? 'Message supprimé') . ')';
|
||||
$m['attachment_path'] = null;
|
||||
$m['attachment_name'] = null;
|
||||
}
|
||||
unset($m['retracted_for_all']);
|
||||
}
|
||||
unset($m);
|
||||
|
||||
// Calcul du “has_more” pour la pagination vers le haut (before_id)
|
||||
$hasMore = false;
|
||||
$nextBeforeId = null;
|
||||
if (!empty($rows)) {
|
||||
// Si on a utilisé beforeId (ou premier load), vérifier s'il reste des messages plus anciens
|
||||
// On prend le plus ancien renvoyé (premier en ASC) et teste s'il existe plus ancien
|
||||
$oldestId = $rows[0]['id'];
|
||||
$stmt2 = $pdo->prepare("SELECT 1 FROM messages WHERE conversation_id = ? AND $hideCol = 0 AND id < ? LIMIT 1");
|
||||
$stmt2->execute([$convId, $oldestId]);
|
||||
$hasMore = (bool)$stmt2->fetchColumn();
|
||||
$nextBeforeId = $oldestId; // pour l'appel suivant: before_id = oldestId
|
||||
}
|
||||
|
||||
json_response([
|
||||
'items' => $rows,
|
||||
'has_more' => $hasMore,
|
||||
'next_before_id'=> $hasMore ? $nextBeforeId : null,
|
||||
]);
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
// /messages/api/get_conversation.php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../_bootstrap.php';
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
$role = $GLOBALS['MESSAGES_CTX']['role']; // 'praticien' | 'client'
|
||||
$meId = (int)$GLOBALS['MESSAGES_CTX']['me_id'];
|
||||
|
||||
$convId = (int)($_GET['conversation_id'] ?? 0);
|
||||
if ($convId <= 0) {
|
||||
json_response(['ok'=>false, 'error'=>'bad_params'], 400);
|
||||
}
|
||||
|
||||
// Vérifie l'accès et récupère la conversation de base
|
||||
$conv = assert_conversation_access($pdo, $convId, $role, $meId);
|
||||
|
||||
// Récupère détails + noms/prénoms + états d’archivage
|
||||
$sql = "
|
||||
SELECT
|
||||
c.id, c.status, c.praticien_id, c.client_id,
|
||||
c.archived_by_praticien, c.archived_by_client,
|
||||
p.nom AS p_nom, p.prenom AS p_prenom,
|
||||
cl.nom AS c_nom, cl.prenom AS c_prenom
|
||||
FROM conversations c
|
||||
JOIN praticien p ON p.id = c.praticien_id
|
||||
JOIN client cl ON cl.id = c.client_id
|
||||
WHERE c.id = ?
|
||||
LIMIT 1";
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute([$convId]);
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$row) {
|
||||
json_response(['ok'=>false, 'error'=>'conversation_not_found'], 404);
|
||||
}
|
||||
|
||||
// Nom de l’interlocuteur (peer)
|
||||
if ($role === 'praticien') {
|
||||
$peerName = trim(sprintf('%s %s', $row['c_prenom'] ?? '', $row['c_nom'] ?? ''));
|
||||
$archForMe = (int)$row['archived_by_praticien'];
|
||||
} else { // client
|
||||
$peerName = trim(sprintf('%s %s', $row['p_prenom'] ?? '', $row['p_nom'] ?? ''));
|
||||
$archForMe = (int)$row['archived_by_client'];
|
||||
}
|
||||
if ($peerName === '') $peerName = 'Conversation #' . $convId;
|
||||
|
||||
// Réponse
|
||||
json_response([
|
||||
'ok' => true,
|
||||
'id' => (int)$row['id'],
|
||||
'status' => (string)$row['status'], // 'open' | 'closed'
|
||||
'archived_for_me' => $archForMe, // 0|1
|
||||
'peer_name' => $peerName,
|
||||
// (optionnel utile côté UI)
|
||||
'praticien_id' => (int)$row['praticien_id'],
|
||||
'client_id' => (int)$row['client_id'],
|
||||
]);
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
// /messages/api/list_conversations.php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../_bootstrap.php';
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
$role = $GLOBALS['MESSAGES_CTX']['role'] ?? '';
|
||||
$meId = (int)($GLOBALS['MESSAGES_CTX']['me_id'] ?? 0);
|
||||
|
||||
// Sécurité: on ne gère ici que praticien/client
|
||||
if (!in_array($role, ['praticien','client'], true)) {
|
||||
json_response(['items' => []]);
|
||||
}
|
||||
if ($meId <= 0) {
|
||||
json_response(['items' => []]);
|
||||
}
|
||||
|
||||
// colonnes de visibilité/suppression selon le rôle
|
||||
$hideCol = ($role === 'praticien') ? 'deleted_for_praticien' : 'deleted_for_client';
|
||||
$archCol = ($role === 'praticien') ? 'archived_by_praticien' : 'archived_by_client'; // (pas utilisé ici, mais OK)
|
||||
|
||||
// Condition WHERE selon le rôle (évite :role répété => HY093)
|
||||
$where = ($role === 'praticien')
|
||||
? 'c.praticien_id = :me'
|
||||
: 'c.client_id = :me';
|
||||
|
||||
// Récupère les conversations qui me concernent (non supprimées pour moi)
|
||||
$sql = "
|
||||
SELECT
|
||||
c.id,
|
||||
c.praticien_id,
|
||||
c.client_id,
|
||||
c.status,
|
||||
cc.p_unread,
|
||||
cc.c_unread,
|
||||
-- dernier message (pour l’aperçu)
|
||||
(SELECT m.body FROM messages m
|
||||
WHERE m.conversation_id = c.id
|
||||
AND m.$hideCol = 0
|
||||
ORDER BY m.id DESC LIMIT 1) AS last_body,
|
||||
|
||||
-- jointures pour noms
|
||||
p.nom AS p_nom, p.prenom AS p_prenom,
|
||||
cl.nom AS c_nom, cl.prenom AS c_prenom
|
||||
|
||||
FROM conversations c
|
||||
JOIN conversation_counters cc ON cc.conversation_id = c.id
|
||||
JOIN praticien p ON p.id = c.praticien_id
|
||||
JOIN client cl ON cl.id = c.client_id
|
||||
|
||||
WHERE
|
||||
$where
|
||||
AND c.$hideCol = 0
|
||||
ORDER BY c.id DESC
|
||||
";
|
||||
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute([':me' => $meId]);
|
||||
|
||||
$items = [];
|
||||
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
|
||||
$id = (int)$row['id'];
|
||||
|
||||
// nom affiché = l’autre participant
|
||||
if ($role === 'praticien') {
|
||||
$peer = trim(($row['c_prenom'] ?? '').' '.($row['c_nom'] ?? ''));
|
||||
} else {
|
||||
$peer = trim(($row['p_prenom'] ?? '').' '.($row['p_nom'] ?? ''));
|
||||
}
|
||||
if ($peer === '') $peer = "Conversation #$id"; // fallback
|
||||
|
||||
// compteur non lus côté moi
|
||||
$unread = ($role === 'praticien') ? (int)$row['p_unread'] : (int)$row['c_unread'];
|
||||
|
||||
$items[] = [
|
||||
'id' => $id,
|
||||
'peer_name' => $peer,
|
||||
'last_body' => (string)($row['last_body'] ?? ''),
|
||||
'unread' => $unread,
|
||||
'status' => (string)$row['status'],
|
||||
];
|
||||
}
|
||||
|
||||
json_response(['items' => $items]);
|
||||
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../_bootstrap.php';
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
$role = $GLOBALS['MESSAGES_CTX']['role']; // 'client' | 'praticien'
|
||||
$meId = (int)$GLOBALS['MESSAGES_CTX']['me_id'];
|
||||
$convId = (int)($_POST['conversation_id'] ?? 0);
|
||||
|
||||
if ($convId <= 0) {
|
||||
json_response(['error' => 'bad_params'], 400);
|
||||
}
|
||||
|
||||
// Autorisation d’accès à la conversation
|
||||
assert_conversation_access($pdo, $convId, $role, $meId);
|
||||
|
||||
try {
|
||||
$pdo->beginTransaction();
|
||||
|
||||
// ==================================================
|
||||
// 1) Marquer les messages comme lus
|
||||
// ==================================================
|
||||
$sql = "
|
||||
INSERT IGNORE INTO message_reads (message_id, user_role, user_id, read_at)
|
||||
SELECT m.id, ?, ?, NOW()
|
||||
FROM messages m
|
||||
WHERE m.conversation_id = ?
|
||||
";
|
||||
$pdo->prepare($sql)->execute([$role, $meId, $convId]);
|
||||
|
||||
// ==================================================
|
||||
// 2) Reset compteur de la conversation
|
||||
// ==================================================
|
||||
if ($role === 'praticien') {
|
||||
$pdo->prepare("
|
||||
UPDATE conversation_counters
|
||||
SET p_unread = 0
|
||||
WHERE conversation_id = ?
|
||||
")->execute([$convId]);
|
||||
} else {
|
||||
$pdo->prepare("
|
||||
UPDATE conversation_counters
|
||||
SET c_unread = 0
|
||||
WHERE conversation_id = ?
|
||||
")->execute([$convId]);
|
||||
}
|
||||
|
||||
// ==================================================
|
||||
// 3) Recalcul msg_unread GLOBAL pour le compte
|
||||
// (source de vérité = conversation_counters)
|
||||
// ==================================================
|
||||
$compteId = (int)($_SESSION['compte_id'] ?? 0);
|
||||
$msgUnread = 0;
|
||||
$rdvUnread = 0;
|
||||
|
||||
if ($compteId > 0) {
|
||||
|
||||
if ($role === 'praticien') {
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT COALESCE(SUM(cc.p_unread), 0)
|
||||
FROM conversations c
|
||||
JOIN conversation_counters cc ON cc.conversation_id = c.id
|
||||
WHERE c.praticien_id = ?
|
||||
");
|
||||
$stmt->execute([$meId]);
|
||||
} else {
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT COALESCE(SUM(cc.c_unread), 0)
|
||||
FROM conversations c
|
||||
JOIN conversation_counters cc ON cc.conversation_id = c.id
|
||||
WHERE c.client_id = ?
|
||||
");
|
||||
$stmt->execute([$meId]);
|
||||
}
|
||||
|
||||
$msgUnread = (int)$stmt->fetchColumn();
|
||||
|
||||
// ==================================================
|
||||
// 4) Mise à jour push_badge_counters
|
||||
// ⚠️ msg_unread uniquement (rdv_unread préservé)
|
||||
// ==================================================
|
||||
$pdo->prepare("
|
||||
INSERT INTO push_badge_counters (compte_id, msg_unread, rdv_unread, updated_at)
|
||||
VALUES (:cid, :msg, 0, NOW())
|
||||
ON DUPLICATE KEY UPDATE
|
||||
msg_unread = VALUES(msg_unread),
|
||||
updated_at = NOW()
|
||||
")->execute([
|
||||
':cid' => $compteId,
|
||||
':msg' => $msgUnread,
|
||||
]);
|
||||
|
||||
// Relire rdv_unread sans le modifier
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT rdv_unread
|
||||
FROM push_badge_counters
|
||||
WHERE compte_id = :cid
|
||||
");
|
||||
$stmt->execute([':cid' => $compteId]);
|
||||
$rdvUnread = (int)($stmt->fetchColumn() ?? 0);
|
||||
}
|
||||
|
||||
$pdo->commit();
|
||||
|
||||
// ==================================================
|
||||
// 5) Réponse JSON pour synchro UI + badge PWA
|
||||
// ==================================================
|
||||
json_response([
|
||||
'ok' => true,
|
||||
'msg_unread' => $msgUnread,
|
||||
'rdv_unread' => $rdvUnread,
|
||||
'app_badge' => $msgUnread + $rdvUnread,
|
||||
]);
|
||||
|
||||
} catch (Throwable $e) {
|
||||
if ($pdo->inTransaction()) {
|
||||
$pdo->rollBack();
|
||||
}
|
||||
json_response(['error' => 'server_error'], 500);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
// /messages/api/open_or_create_conversation.php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/../_bootstrap.php';
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
$role = $GLOBALS['MESSAGES_CTX']['role']; // 'praticien' | 'client'
|
||||
$meId = (int)$GLOBALS['MESSAGES_CTX']['me_id'];
|
||||
|
||||
$peerRole = $_POST['peer_role'] ?? $_GET['peer_role'] ?? '';
|
||||
$peerId = (int)($_POST['peer_id'] ?? $_GET['peer_id'] ?? 0);
|
||||
|
||||
// sécurité minimale + cohérence rôles
|
||||
if (!in_array($peerRole, ['praticien','client'], true) || $peerId<=0) {
|
||||
json_response(['ok'=>false,'error'=>'bad_params'],400);
|
||||
}
|
||||
if ($role === $peerRole) { // on attend l’autre rôle
|
||||
json_response(['ok'=>false,'error'=>'target_must_be_other_role'],400);
|
||||
}
|
||||
|
||||
// Déterminer les 2 ids
|
||||
if ($role === 'client') {
|
||||
$clientId = $meId;
|
||||
$praticienId = $peerId;
|
||||
} else { // role=praticien
|
||||
$praticienId = $meId;
|
||||
$clientId = $peerId;
|
||||
}
|
||||
|
||||
// Chercher une conversation existante entre ces 2
|
||||
$stmt = $pdo->prepare("SELECT * FROM conversations WHERE praticien_id=? AND client_id=? LIMIT 1");
|
||||
$stmt->execute([$praticienId,$clientId]);
|
||||
$conv = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if ($conv) {
|
||||
// Restaurer pour moi si supprimée/archivée et rouvrir si besoin
|
||||
if ($role === 'client') {
|
||||
$pdo->prepare("UPDATE conversations
|
||||
SET deleted_for_client=0, archived_by_client=0, status='open', updated_at=NOW()
|
||||
WHERE id=?")->execute([(int)$conv['id']]);
|
||||
} else {
|
||||
$pdo->prepare("UPDATE conversations
|
||||
SET deleted_for_praticien=0, archived_by_praticien=0, status='open', updated_at=NOW()
|
||||
WHERE id=?")->execute([(int)$conv['id']]);
|
||||
}
|
||||
json_response(['ok'=>true,'conversation_id'=>(int)$conv['id']]);
|
||||
}
|
||||
|
||||
// Sinon, créer la conversation + counters
|
||||
$pdo->beginTransaction();
|
||||
try{
|
||||
$pdo->prepare("INSERT INTO conversations
|
||||
(praticien_id, client_id, status,
|
||||
archived_by_praticien, archived_by_client,
|
||||
deleted_for_praticien, deleted_for_client,
|
||||
created_at, updated_at)
|
||||
VALUES (?, ?, 'open', 0, 0, 0, 0, NOW(), NOW())
|
||||
")->execute([$praticienId,$clientId]);
|
||||
|
||||
$newId = (int)$pdo->lastInsertId();
|
||||
|
||||
// counters (si table présente)
|
||||
if ($pdo->query("SHOW TABLES LIKE 'conversation_counters'")->rowCount()){
|
||||
$pdo->prepare("INSERT INTO conversation_counters (conversation_id, p_unread, c_unread) VALUES (?,0,0)")
|
||||
->execute([$newId]);
|
||||
}
|
||||
|
||||
$pdo->commit();
|
||||
json_response(['ok'=>true,'conversation_id'=>$newId]);
|
||||
}catch(Throwable $e){
|
||||
$pdo->rollBack();
|
||||
json_response(['ok'=>false,'error'=>'create_failed'],500);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
// /messages/api/retract_message.php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../_bootstrap.php';
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
/**
|
||||
* Règles:
|
||||
* - Seul l'expéditeur peut rétracter.
|
||||
* - Fenêtre max (min) depuis l'envoi (par défaut 15 min).
|
||||
* - Refus si déjà lu par l'autre partie.
|
||||
* Effets:
|
||||
* - messages.retracted_for_all = 1
|
||||
* - body = NULL, attachment_* = NULL (et suppression fichier si présent)
|
||||
* - Décrément du compteur non-lus de l'autre si le message était encore non lu.
|
||||
*/
|
||||
|
||||
const RETRACT_WINDOW_MIN = 15;
|
||||
|
||||
if (!hash_equals($_SESSION['_csrf'] ?? '', $_POST['_csrf'] ?? '')) {
|
||||
json_response(['error' => 'csrf'], 419);
|
||||
}
|
||||
|
||||
$role = $GLOBALS['MESSAGES_CTX']['role']; // 'praticien' | 'client'
|
||||
$meId = $GLOBALS['MESSAGES_CTX']['me_id'];
|
||||
$msgId = (int)($_POST['message_id'] ?? 0);
|
||||
|
||||
if ($msgId <= 0) json_response(['error' => 'bad_params'], 400);
|
||||
|
||||
// Récupère le message + conversation
|
||||
$sql = "SELECT m.id, m.conversation_id, m.sender_role, m.sender_id, m.created_at,
|
||||
m.attachment_path, m.attachment_name, m.retracted_for_all,
|
||||
c.praticien_id, c.client_id
|
||||
FROM messages m
|
||||
JOIN conversations c ON c.id = m.conversation_id
|
||||
WHERE m.id = ?
|
||||
LIMIT 1";
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute([$msgId]);
|
||||
$msg = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$msg) json_response(['error' => 'not_found'], 404);
|
||||
|
||||
// Vérifie que je participe bien à la conversation (sécurité)
|
||||
assert_conversation_access($pdo, (int)$msg['conversation_id'], $role, $meId);
|
||||
|
||||
// Vérifie que je suis bien l'expéditeur
|
||||
if (!($msg['sender_role'] === $role && (int)$msg['sender_id'] === $meId)) {
|
||||
json_response(['error' => 'not_sender'], 403);
|
||||
}
|
||||
|
||||
// Déjà rétracté ?
|
||||
if (!empty($msg['retracted_for_all'])) {
|
||||
json_response(['error' => 'already_retracted'], 400);
|
||||
}
|
||||
|
||||
// Vérifie fenêtre temps
|
||||
$win = (int)RETRACT_WINDOW_MIN;
|
||||
$ageSql = $pdo->prepare("SELECT TIMESTAMPDIFF(MINUTE, ?, NOW()) AS age_min");
|
||||
$ageSql->execute([$msg['created_at']]);
|
||||
$ageMin = (int)$ageSql->fetchColumn();
|
||||
if ($ageMin > $win) {
|
||||
json_response(['error' => 'too_late', 'window_min' => $win], 400);
|
||||
}
|
||||
|
||||
// Vérifie lecture par l'autre (si lu → refus)
|
||||
$otherRole = ($role === 'praticien') ? 'client' : 'praticien';
|
||||
$readChk = $pdo->prepare("SELECT 1 FROM message_reads WHERE message_id = ? AND user_role = ? LIMIT 1");
|
||||
$readChk->execute([$msgId, $otherRole]);
|
||||
$alreadyRead = (bool)$readChk->fetchColumn();
|
||||
if ($alreadyRead) {
|
||||
json_response(['error' => 'already_read'], 400);
|
||||
}
|
||||
|
||||
// Si non lu par l'autre, il compte encore dans ses non-lus → on décrémentera
|
||||
$counterCol = ($role === 'praticien') ? 'c_unread' : 'p_unread';
|
||||
|
||||
// Prépare suppression de l'éventuelle pièce jointe
|
||||
$attPath = $msg['attachment_path'] ?? null;
|
||||
$fullPath = null;
|
||||
if (!empty($attPath) && is_string($attPath)) {
|
||||
// Par convention: messages/uploads/FILENAME
|
||||
if (strpos($attPath, 'messages/uploads/') === 0) {
|
||||
// filesystem: /path/to/site/ + messages/uploads/...
|
||||
$siteRoot = realpath(__DIR__ . '/../../'); // .../ (racine du site)
|
||||
if ($siteRoot) {
|
||||
$fullPath = $siteRoot . DIRECTORY_SEPARATOR . str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $attPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$pdo->beginTransaction();
|
||||
|
||||
// 1) Marquer rétracté (+ effacer contenu & PJ)
|
||||
$upd = $pdo->prepare("UPDATE messages
|
||||
SET retracted_for_all = 1,
|
||||
body = NULL,
|
||||
attachment_path = NULL,
|
||||
attachment_name = NULL
|
||||
WHERE id = ?");
|
||||
$upd->execute([$msgId]);
|
||||
|
||||
// 2) Corriger le compteur de non-lus de l'autre si nécessaire (message encore non lu)
|
||||
// On vérifie à nouveau, mais on l'a déjà en mémoire (alreadyRead=false) :
|
||||
if (!$alreadyRead) {
|
||||
$pdo->prepare("UPDATE conversation_counters
|
||||
SET $counterCol = GREATEST($counterCol - 1, 0)
|
||||
WHERE conversation_id = ?")
|
||||
->execute([(int)$msg['conversation_id']]);
|
||||
}
|
||||
|
||||
$pdo->commit();
|
||||
} catch (Throwable $e) {
|
||||
if ($pdo->inTransaction()) { $pdo->rollBack(); }
|
||||
json_response(['error' => 'server_error'], 500);
|
||||
}
|
||||
|
||||
// 3) Supprime physiquement la PJ (hors transaction DB)
|
||||
if ($fullPath && is_file($fullPath)) {
|
||||
// sécurité simple: s'assure que le chemin reste dans /messages/uploads/
|
||||
$uploadsRoot = realpath(__DIR__ . '/../uploads/');
|
||||
$fpReal = realpath($fullPath);
|
||||
if ($uploadsRoot && $fpReal && strpos($fpReal, $uploadsRoot) === 0) {
|
||||
@unlink($fpReal);
|
||||
}
|
||||
}
|
||||
|
||||
json_response(['ok' => true, 'message_id' => $msgId]);
|
||||
@@ -0,0 +1,236 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/../_bootstrap.php';
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
$role = $GLOBALS['MESSAGES_CTX']['role']; // 'praticien' ou 'client'
|
||||
$meId = $GLOBALS['MESSAGES_CTX']['me_id']; // id praticien ou client
|
||||
|
||||
if (!hash_equals($_SESSION['_csrf'] ?? '', $_POST['_csrf'] ?? '')) {
|
||||
json_response(['error' => 'csrf'], 419);
|
||||
}
|
||||
|
||||
$convId = (int)($_POST['conversation_id'] ?? 0);
|
||||
$body = isset($_POST['body']) ? trim((string)$_POST['body']) : '';
|
||||
|
||||
if ($convId <= 0) {
|
||||
json_response(['error' => 'bad_params'], 400);
|
||||
}
|
||||
|
||||
// Autorisation + statut
|
||||
$conv = assert_conversation_access($pdo, $convId, $role, $meId);
|
||||
if (($conv['status'] ?? 'open') !== 'open') {
|
||||
json_response(['error' => 'closed'], 403);
|
||||
}
|
||||
|
||||
// Upload (optionnel)
|
||||
$attPath = null;
|
||||
$attName = null;
|
||||
|
||||
if (!empty($_FILES['file']['name'] ?? '')) {
|
||||
$okExt = ['jpg','jpeg','png','pdf','doc','docx'];
|
||||
$name = (string)$_FILES['file']['name'];
|
||||
$size = (int)$_FILES['file']['size'];
|
||||
$tmp = (string)$_FILES['file']['tmp_name'];
|
||||
|
||||
$ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
|
||||
if (!in_array($ext, $okExt, true)) {
|
||||
json_response(['error' => 'invalid_file_type'], 400);
|
||||
}
|
||||
if ($size > 10 * 1024 * 1024) { // 10 Mo
|
||||
json_response(['error' => 'file_too_large'], 400);
|
||||
}
|
||||
|
||||
$dir = realpath(__DIR__ . '/..') . '/uploads/';
|
||||
if (!is_dir($dir)) { @mkdir($dir, 0775, true); }
|
||||
$safe = $meId . '_' . time() . '_' . bin2hex(random_bytes(4)) . '.' . $ext;
|
||||
$dest = $dir . $safe;
|
||||
|
||||
if (!move_uploaded_file($tmp, $dest)) {
|
||||
json_response(['error' => 'upload_failed'], 500);
|
||||
}
|
||||
|
||||
// Chemin web relatif (sans leading slash pour constituer une URL /messages/uploads/..)
|
||||
$attPath = 'messages/uploads/' . $safe;
|
||||
$attName = $name;
|
||||
}
|
||||
|
||||
// Flag : doit-on envoyer une notification mail ?
|
||||
$shouldNotify = false;
|
||||
|
||||
try {
|
||||
$pdo->beginTransaction();
|
||||
|
||||
// Insérer le message
|
||||
$ins = $pdo->prepare("
|
||||
INSERT INTO messages
|
||||
(conversation_id, sender_role, sender_id, body, attachment_path, attachment_name, created_at)
|
||||
VALUES
|
||||
(?, ?, ?, ?, ?, ?, NOW())
|
||||
");
|
||||
$ins->execute([
|
||||
$convId,
|
||||
$role,
|
||||
$meId,
|
||||
$body !== '' ? $body : null,
|
||||
$attPath,
|
||||
$attName
|
||||
]);
|
||||
$msgId = (int)$pdo->lastInsertId();
|
||||
|
||||
// Mettre à jour les compteurs de non-lus (chez l'autre)
|
||||
if ($role === 'praticien') {
|
||||
// Le praticien écrit → c'est le client qui a des messages non lus (c_unread)
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT c_unread
|
||||
FROM conversation_counters
|
||||
WHERE conversation_id = ?
|
||||
FOR UPDATE
|
||||
");
|
||||
$stmt->execute([$convId]);
|
||||
$prev = $stmt->fetchColumn();
|
||||
$prevUnread = ($prev === false) ? 0 : (int)$prev;
|
||||
|
||||
$pdo->prepare("
|
||||
UPDATE conversation_counters
|
||||
SET c_unread = c_unread + 1
|
||||
WHERE conversation_id = ?
|
||||
")->execute([$convId]);
|
||||
|
||||
if ($prevUnread === 0) {
|
||||
$shouldNotify = true;
|
||||
}
|
||||
|
||||
} else {
|
||||
// Le client écrit → c'est le praticien qui a des messages non lus (p_unread)
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT p_unread
|
||||
FROM conversation_counters
|
||||
WHERE conversation_id = ?
|
||||
FOR UPDATE
|
||||
");
|
||||
$stmt->execute([$convId]);
|
||||
$prev = $stmt->fetchColumn();
|
||||
$prevUnread = ($prev === false) ? 0 : (int)$prev;
|
||||
|
||||
$pdo->prepare("
|
||||
UPDATE conversation_counters
|
||||
SET p_unread = p_unread + 1
|
||||
WHERE conversation_id = ?
|
||||
")->execute([$convId]);
|
||||
|
||||
if ($prevUnread === 0) {
|
||||
$shouldNotify = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Bump updated_at
|
||||
$pdo->prepare("
|
||||
UPDATE conversations
|
||||
SET updated_at = NOW()
|
||||
WHERE id = ?
|
||||
")->execute([$convId]);
|
||||
|
||||
$pdo->commit();
|
||||
|
||||
} catch (Throwable $e) {
|
||||
if ($pdo->inTransaction()) { $pdo->rollBack(); }
|
||||
error_log('send_message error: '.$e->getMessage());
|
||||
json_response(['error' => 'server_error'], 500);
|
||||
}
|
||||
|
||||
// 🔔 Notification par e-mail (ne doit jamais casser la réponse JSON)
|
||||
if ($shouldNotify) {
|
||||
try {
|
||||
require_once __DIR__ . '/../../public/message_par_mail.php';
|
||||
notifier_nouveau_message_par_email($pdo, $convId, $role);
|
||||
} catch (Throwable $e) {
|
||||
error_log('Notif mail error: '.$e->getMessage());
|
||||
}
|
||||
try {
|
||||
// Déterminer le destinataire (l'autre)
|
||||
$targetRole = ($role === 'praticien') ? 'client' : 'praticien';
|
||||
|
||||
// Normalement assert_conversation_access() te renvoie ces champs,
|
||||
// sinon fallback query (au cas où)
|
||||
$clientId = (int)($conv['client_id'] ?? 0);
|
||||
$praticienId = (int)($conv['praticien_id'] ?? 0);
|
||||
|
||||
if ($clientId <= 0 || $praticienId <= 0) {
|
||||
$st = $pdo->prepare("SELECT client_id, praticien_id FROM conversations WHERE id=? LIMIT 1");
|
||||
$st->execute([$convId]);
|
||||
$row = $st->fetch(PDO::FETCH_ASSOC) ?: [];
|
||||
$clientId = (int)($row['client_id'] ?? 0);
|
||||
$praticienId = (int)($row['praticien_id'] ?? 0);
|
||||
}
|
||||
|
||||
$targetRef = ($targetRole === 'client') ? $clientId : $praticienId;
|
||||
|
||||
// ✅ Trouver le compte_id du destinataire (obligatoire pour worker + badge global)
|
||||
$stC = $pdo->prepare("
|
||||
SELECT id
|
||||
FROM compte
|
||||
WHERE role = :role AND reference_id = :ref
|
||||
LIMIT 1
|
||||
");
|
||||
$stC->execute([
|
||||
':role' => $targetRole,
|
||||
':ref' => $targetRef,
|
||||
]);
|
||||
$targetCompteId = (int)($stC->fetchColumn() ?: 0);
|
||||
|
||||
|
||||
if ($targetRef > 0) {
|
||||
// Texte push (simple)
|
||||
$title = 'Новое сообщение c uslugi.fr';
|
||||
$text = 'У вас новое сообщение с uslugi.fr.';
|
||||
if ($body !== '') {
|
||||
$snippet = mb_substr($body, 0, 120);
|
||||
$text = $snippet;
|
||||
}
|
||||
|
||||
$url = ($targetRole === 'client')
|
||||
? '/messages/?conv=' . $convId
|
||||
: '/messages/?conv=' . $convId;
|
||||
|
||||
$data = json_encode([
|
||||
'message_id' => $msgId,
|
||||
'sender_role' => $role,
|
||||
'conversation_id' => $convId,
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
|
||||
if ($targetCompteId > 0) {
|
||||
$pdo->prepare("
|
||||
INSERT INTO push_badge_counters (compte_id, msg_unread, rdv_unread)
|
||||
VALUES (:cid, 1, 0)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
msg_unread = LEAST(msg_unread + 1, 99),
|
||||
updated_at = NOW()
|
||||
")->execute([':cid' => $targetCompteId]);
|
||||
}
|
||||
|
||||
$insPush = $pdo->prepare("
|
||||
INSERT INTO push_notifications
|
||||
(target_role, target_reference_id, target_compte_id, type, title, body, url, data_json, status, attempts, created_at)
|
||||
VALUES
|
||||
(:role, :ref, :cid, 'message_new', :title, :body, :url, :data, 'pending', 0, NOW())
|
||||
");
|
||||
$insPush->execute([
|
||||
':role' => $targetRole,
|
||||
':ref' => $targetRef,
|
||||
':cid' => ($targetCompteId > 0 ? $targetCompteId : null),
|
||||
':title' => $title,
|
||||
':body' => $text,
|
||||
':url' => $url,
|
||||
':data' => $data,
|
||||
]);
|
||||
|
||||
}
|
||||
|
||||
} catch (Throwable $e) {
|
||||
error_log('Notif push error: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
json_response(['ok' => true, 'message_id' => $msgId]);
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/../_bootstrap.php';
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
$role = $GLOBALS['MESSAGES_CTX']['role'];
|
||||
$meId = $GLOBALS['MESSAGES_CTX']['me_id'];
|
||||
|
||||
if ($role === 'praticien') {
|
||||
$sql = "SELECT COALESCE(SUM(cc.p_unread),0)
|
||||
FROM conversations c
|
||||
LEFT JOIN conversation_counters cc ON cc.conversation_id = c.id
|
||||
WHERE c.praticien_id = ?
|
||||
AND c.deleted_for_praticien = 0";
|
||||
} else {
|
||||
$sql = "SELECT COALESCE(SUM(cc.c_unread),0)
|
||||
FROM conversations c
|
||||
LEFT JOIN conversation_counters cc ON cc.conversation_id = c.id
|
||||
WHERE c.client_id = ?
|
||||
AND c.deleted_for_client = 0";
|
||||
}
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute([$meId]);
|
||||
$total = (int)$stmt->fetchColumn();
|
||||
|
||||
json_response(['total' => $total]);
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
// /messages/api/delete_conversation.php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../_bootstrap.php';
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
if (!hash_equals($_SESSION['_csrf'] ?? '', $_POST['_csrf'] ?? '')) {
|
||||
json_response(['error' => 'csrf'], 419);
|
||||
}
|
||||
|
||||
$role = $GLOBALS['MESSAGES_CTX']['role']; // 'praticien' | 'client'
|
||||
$meId = $GLOBALS['MESSAGES_CTX']['me_id'];
|
||||
$convId = (int)($_POST['conversation_id'] ?? 0);
|
||||
|
||||
if ($convId <= 0) {
|
||||
json_response(['error' => 'bad_params'], 400);
|
||||
}
|
||||
|
||||
// Vérifie l'accès (participant)
|
||||
$conv = assert_conversation_access($pdo, $convId, $role, $meId);
|
||||
|
||||
// Colonnes dépendant du rôle
|
||||
$colDeleted = ($role === 'praticien') ? 'deleted_for_praticien' : 'deleted_for_client';
|
||||
$colUnread = ($role === 'praticien') ? 'p_unread' : 'c_unread';
|
||||
|
||||
try {
|
||||
$pdo->beginTransaction();
|
||||
|
||||
// 1) Marquer la conversation supprimée "pour moi"
|
||||
$pdo->prepare("UPDATE conversations SET $colDeleted = 1 WHERE id = ?")
|
||||
->execute([$convId]);
|
||||
|
||||
// 2) Masquer tous ses messages "pour moi"
|
||||
$pdo->prepare("UPDATE messages SET $colDeleted = 1 WHERE conversation_id = ?")
|
||||
->execute([$convId]);
|
||||
|
||||
// 3) Réinitialiser mes non-lus sur cette conversation
|
||||
$pdo->prepare("UPDATE conversation_counters SET $colUnread = 0 WHERE conversation_id = ?")
|
||||
->execute([$convId]);
|
||||
|
||||
$pdo->commit();
|
||||
} catch (Throwable $e) {
|
||||
if ($pdo->inTransaction()) { $pdo->rollBack(); }
|
||||
json_response(['error' => 'server_error'], 500);
|
||||
}
|
||||
|
||||
json_response(['ok' => true]);
|
||||
@@ -0,0 +1,133 @@
|
||||
<?php
|
||||
// /messages/index.php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/_bootstrap.php';
|
||||
|
||||
// Récupère conv depuis l'URL si présent
|
||||
$currentConv = isset($_GET['conv']) ? (int)$_GET['conv'] : 0;
|
||||
|
||||
// Traductions (fallback FR)
|
||||
// après avoir chargé les traductions (lang) :
|
||||
$t = function(string $k, string $fallback = '') use (&$translations): string {
|
||||
$val = $translations[$k] ?? null;
|
||||
return ($val === null || $val === '') ? $fallback : (string)$val;
|
||||
};
|
||||
|
||||
|
||||
require_once APP_HTDOCS . '/public/includes/header.php';
|
||||
?>
|
||||
<script>
|
||||
(() => {
|
||||
function setHeaderVar() {
|
||||
const header = document.querySelector('.site-header');
|
||||
if (!header) return;
|
||||
const h = Math.round(header.getBoundingClientRect().height || 64);
|
||||
document.documentElement.style.setProperty('--app-header-h', h + 'px');
|
||||
}
|
||||
|
||||
window.addEventListener('load', setHeaderVar);
|
||||
window.addEventListener('resize', setHeaderVar);
|
||||
})();
|
||||
</script>
|
||||
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" href="/messages/messages.css">
|
||||
<div class="main1">
|
||||
<div class="container py-4">
|
||||
<div class="shadow p-3 p-md-4 rounded bg-light chat-frame">
|
||||
<div class="msg-app">
|
||||
<!-- COLONNE LISTE (mobile: visible, desktop: visible) -->
|
||||
<aside class="msg-list-panel" id="msg-list-panel" aria-label="<?= $t('msg_conversations','Conversations') ?>">
|
||||
<header class="list-header">
|
||||
<h1 class="list-title"><?= h($t('msg_conversations','Conversations')) ?></h1>
|
||||
<a class="list-new" href="/messages/"><?= h($t('msg_titre','Messagerie')) ?></a>
|
||||
</header>
|
||||
<div class="list-scroll" id="conv-list" role="list">
|
||||
<!-- Conversations injectées en JS -->
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- COLONNE FIL (mobile: plein écran si conv ouverte) -->
|
||||
<main class="msg-thread-panel" id="msg-thread-panel" data-conv-id="<?= (int)$currentConv ?>">
|
||||
<header class="thread-header">
|
||||
<button class="btn-back" id="btn-back" aria-label="Retour">←</button>
|
||||
<div class="thread-title" id="thread-title">
|
||||
<?= $currentConv ? ('#'.(int)$currentConv) : $t('msg_aucune_conversation','Sélectionner une conversation') ?>
|
||||
</div>
|
||||
<div class="thread-actions">
|
||||
<!--<button type="button" class="th-btn" id="btn-archive"></button>-->
|
||||
<button type="button" class="th-btn" id="btn-close"></button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="thread-body" id="messages" aria-live="polite">
|
||||
<!-- Messages injectés en JS -->
|
||||
</section>
|
||||
|
||||
<footer class="thread-footer">
|
||||
<form id="send-form" class="send-form" enctype="multipart/form-data" autocomplete="off">
|
||||
<input type="hidden" name="_csrf" value="<?= h($_SESSION['_csrf']) ?>">
|
||||
|
||||
<label class="file-btn">
|
||||
<input type="file" name="file" id="msg-file" class="hidden-file" accept=".jpg,.jpeg,.png,.pdf,.doc,.docx">
|
||||
<!-- Paperclip — Outline -->
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Pièce jointe">
|
||||
<path d="M8.2 12.4l5.9-5.9a3.6 3.6 0 115.1 5.1l-7.2 7.2a5.2 5.2 0 11-7.4-7.4l7.4-7.4"
|
||||
stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</label>
|
||||
<textarea name="body" id="msg-input" class="msg-input"
|
||||
placeholder="<?= $t('msg_taper','Écrire un message…') ?>"
|
||||
rows="2"></textarea>
|
||||
<button type="submit" class="send-btn" aria-label="<?= $t('msg_envoyer','Envoyer') ?>">
|
||||
<!-- Avion en papier -->
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Envoyer">
|
||||
<path d="M2 21l19-9L2 3v7l13 2-13 2v7z"
|
||||
fill="currentColor"/>
|
||||
</svg>
|
||||
</button>
|
||||
</form>
|
||||
<div class="send-hint" id="send-hint"></div>
|
||||
</footer>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
window.MESSAGING_CFG = {
|
||||
role: <?= json_encode($GLOBALS['MESSAGES_CTX']['role']) ?>, // 'praticien' | 'client'
|
||||
meId: <?= (int)$GLOBALS['MESSAGES_CTX']['me_id'] ?>,
|
||||
csrf: <?= json_encode($GLOBALS['MESSAGES_CTX']['csrf_token']) ?>,
|
||||
initialConv: <?= (int)$currentConv ?>,
|
||||
|
||||
i18n: {
|
||||
conversation: <?= json_encode($t('msg_conversation', 'Conversation')) ?>,
|
||||
noConversations: <?= json_encode($t('msg_aucune_conversation', 'Sélectionner une conversation')) ?>,
|
||||
send: <?= json_encode($t('msg_envoyer', 'Envoyer')) ?>,
|
||||
placeholder: <?= json_encode($t('msg_taper', 'Écrire un message…')) ?>,
|
||||
|
||||
hide: <?= json_encode($t('msg_action_hide', 'Masquer')) ?>,
|
||||
hideConfirm: <?= json_encode($t('msg_action_hide_confirm', 'Masquer ce message pour vous ?')) ?>,
|
||||
hideOk: <?= json_encode($t('msg_action_hidden_ok', 'Message masqué.')) ?>,
|
||||
hideFail: <?= json_encode($t('msg_action_hidden_fail', 'Suppression impossible pour le moment.')) ?>,
|
||||
|
||||
archive: <?= json_encode($t('msg_archiver', 'Archiver')) ?>,
|
||||
desarchiver: <?= json_encode($t('msg_desarchiver', 'Désarchiver')) ?>,
|
||||
unarchive: <?= json_encode($t('msg_desarchiver', 'Désarchiver')) ?>, // alias utilisé par le JS
|
||||
|
||||
close: <?= json_encode($t('msg_clore', 'Clôturer')) ?>,
|
||||
reopen: <?= json_encode($t('msg_rouvrir', 'Rouvrir')) ?>,
|
||||
closedBanner: <?= json_encode($t('msg_conv_cloturee', 'Cette conversation est clôturée.')) ?>
|
||||
|
||||
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
</div>
|
||||
<script src="/messages/messages.js"></script>
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
/* === Base === */
|
||||
:root{
|
||||
--bg:#f6f7fb; --card:#fff; --ink:#111; --muted:#6b7280; --brand:#38378B;
|
||||
--line:#e5e7eb; --bubble:#f1f3f5; --bubble-me:#38378B; --bubble-me-ink:#fff;
|
||||
--fond:#8CA9FF;
|
||||
}
|
||||
*{ box-sizing:border-box; }
|
||||
html, body { height:100%; overflow-x:hidden; }
|
||||
body{ background:var(--bg); color:var(--ink); min-width:0; }
|
||||
|
||||
/* Conteneur app */
|
||||
.msg-app{
|
||||
display:grid;
|
||||
grid-template-columns: 1fr; /* mobile: une seule colonne */
|
||||
min-width:0; overflow-x:hidden;
|
||||
}
|
||||
|
||||
/* === Liste (mobile: plein écran par défaut) === */
|
||||
.msg-list-panel{
|
||||
display:block;
|
||||
border-right:1px solid var(--line);
|
||||
background:var(--card);
|
||||
min-width:0; min-height:0;
|
||||
}
|
||||
.list-header{
|
||||
display:flex; align-items:center; justify-content:space-between;
|
||||
padding:12px; border-bottom:1px solid var(--line);
|
||||
}
|
||||
.list-title{ margin:0; font-size:16px; color:var(--ink); }
|
||||
.list-new{ font-size:13px; color:var(--brand); text-decoration:none; }
|
||||
.list-scroll{ max-height:calc(100vh - 180px); overflow:auto; } /* garde un scroll si la page est courte */
|
||||
.conv-item{
|
||||
display:flex; align-items:center; gap:8px;
|
||||
padding:12px; border-bottom:1px solid var(--line); cursor:pointer; width:100%;
|
||||
background:#fff;
|
||||
}
|
||||
.conv-item:hover{ background:#fafafa; }
|
||||
.conv-title{ font-weight:600; color:var(--ink); }
|
||||
.conv-last{ font-size:13px; color:var(--muted); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||
.conv-badge{ margin-left:auto; background:#e11d48; color:#fff; font-size:12px; min-width:20px; height:20px; line-height:20px; text-align:center; border-radius:999px; padding:0 6px; }
|
||||
|
||||
/* === Fil (mobile: caché par défaut) === */
|
||||
.msg-thread-panel{
|
||||
display:none; /* mobile: on le montre seulement quand .show-thread */
|
||||
flex-direction:column;
|
||||
background:var(--card);
|
||||
min-width:0; min-height:0;
|
||||
}
|
||||
.thread-header{
|
||||
position:sticky; top:0; z-index:10;
|
||||
display:flex; align-items:center; gap:8px;
|
||||
padding:10px 12px; border-bottom:1px solid var(--line); background:var(--card);
|
||||
}
|
||||
.btn-back{
|
||||
appearance:none; border:none; background:transparent; font-size:18px; line-height:1; cursor:pointer;
|
||||
}
|
||||
.thread-title{ font-weight:600; color:var(--ink); flex:1 1 auto; min-width:0; }
|
||||
.thread-actions{ display:flex; gap:8px; flex-wrap:wrap; margin-left:auto; }
|
||||
.th-btn{
|
||||
border:1px solid var(--line); background:#fff; padding:6px 10px; border-radius:10px; cursor:pointer; font-size:13px;
|
||||
}
|
||||
.th-btn + .th-btn{ margin-left:0; }
|
||||
|
||||
/* Corps défilant */
|
||||
.thread-body{
|
||||
flex:1 1 auto;
|
||||
min-height:0; /* CRUCIAL pour permettre le scroll interne */
|
||||
overflow:auto;
|
||||
padding:12px; background:var(--bg);
|
||||
padding-bottom:84px; /* espace pour la barre fixe du bas */
|
||||
}
|
||||
|
||||
/* Messages */
|
||||
.msg{ margin-bottom:12px; display:flex; flex-direction:column; align-items:flex-start; }
|
||||
.msg.me{ align-items:flex-end; }
|
||||
.bubble{
|
||||
max-width:90%; padding:10px 14px; border-radius:10px; background:var(--bubble); color:var(--ink);
|
||||
box-shadow:0 1px 2px rgba(0,0,0,.05); overflow-wrap:anywhere; word-break:break-word;
|
||||
}
|
||||
.msg.me .bubble{ background:var(--bubble-me); color:var(--bubble-me-ink); }
|
||||
.meta{ margin-top:6px; font-size:12px; color:var(--muted); }
|
||||
.bubble a{ color:inherit; text-decoration:underline; }
|
||||
|
||||
/* Actions message */
|
||||
.msg .msg-actions{ margin-top:6px; display:flex; gap:6px; opacity:.7; }
|
||||
.msg .msg-actions button{
|
||||
border:1px solid var(--line); background:#fff; font-size:12px; padding:2px 8px; border-radius:8px; cursor:pointer;
|
||||
}
|
||||
.msg .msg-actions button:hover{ background:#f8f8f8; }
|
||||
|
||||
/* Bandeau info (clôturée) */
|
||||
.thread-banner{
|
||||
background:#fff3cd; color:#7a5c00; border:1px solid #ffe69c;
|
||||
padding:8px 12px; margin:8px 12px 0 12px; border-radius:8px; font-size:14px;
|
||||
}
|
||||
|
||||
/* Barre d'envoi (fixe en bas) */
|
||||
.thread-footer{
|
||||
position:sticky; bottom:0; z-index:10;
|
||||
padding:10px 12px; border-top:1px solid var(--line); background:var(--card);
|
||||
}
|
||||
.send-form{ display:flex; align-items:center; gap:8px; }
|
||||
.msg-input{
|
||||
flex:1; border:1px solid var(--line); border-radius:10px; padding:10px 12px; font-size:16px; outline:none; min-width:0;
|
||||
}
|
||||
.file-btn{
|
||||
display:inline-flex; align-items:center; justify-content:center;
|
||||
width:40px; height:40px; border-radius:10px; border:1px solid var(--line); cursor:pointer; user-select:none;
|
||||
}
|
||||
.hidden-file{ display:none; }
|
||||
.send-btn{ border:none; background:var(--brand); color:#fff; border-radius:10px; padding:8px 8px; font-weight:600; cursor:pointer; }
|
||||
.send-btn:disabled{ opacity:.6; cursor:not-allowed; }
|
||||
.send-hint{ margin-top:6px; font-size:12px; color:var(--muted); }
|
||||
|
||||
/* === Logique d’affichage mobile (<= 899px) === */
|
||||
@media (max-width: 899px){
|
||||
/* Par défaut: liste visible, fil caché */
|
||||
.msg-app { grid-template-columns:1fr; }
|
||||
.msg-list-panel{ display:block; }
|
||||
.msg-thread-panel{ display:none; }
|
||||
|
||||
/* Quand une conv est ouverte (.show-thread posé par JS) */
|
||||
.msg-app.show-thread .msg-list-panel{ display:none !important; }
|
||||
.msg-app.show-thread .msg-thread-panel{ display:flex !important; }
|
||||
|
||||
/* Actions compactes */
|
||||
.th-btn{ padding:6px 8px; font-size:12px; }
|
||||
}
|
||||
|
||||
/* === Desktop (>= 900px): split view stable === */
|
||||
@media (min-width: 900px){
|
||||
.msg-app{ grid-template-columns: 360px 1fr; }
|
||||
.btn-back{ display:none; }
|
||||
.msg-list-panel{ display:block !important; }
|
||||
.msg-thread-panel{ display:flex !important; }
|
||||
.thread-body{ padding-bottom:72px; } /* barre plus petite en desktop */
|
||||
}
|
||||
|
||||
/* === Intégration dans un card Bootstrap (optionnel) === */
|
||||
.chat-frame{ overflow:hidden; }
|
||||
.chat-frame .msg-app{ height:auto; max-height:none; }
|
||||
@media (min-width:900px){
|
||||
.chat-frame .msg-app{ height:70vh; }
|
||||
}
|
||||
|
||||
/* Style modernisé pour la liste des conversations */
|
||||
.conv-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 90%;
|
||||
|
||||
background: #fff;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
|
||||
padding: 10px 14px;
|
||||
margin: 8px 12px;
|
||||
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: var(--ink);
|
||||
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
.conv-item:hover {
|
||||
background: #f1f3f9;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,0.08);
|
||||
}
|
||||
|
||||
/* Le nom */
|
||||
.conv-title {
|
||||
font-weight: 600;
|
||||
color: var(--brand);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Aperçu du dernier message */
|
||||
.conv-last {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
margin-top: 2px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Badge non lus */
|
||||
.conv-badge {
|
||||
margin-left: auto;
|
||||
background: #e11d48; /* rouge */
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
line-height: 20px;
|
||||
text-align: center;
|
||||
border-radius: 999px;
|
||||
padding: 0 6px;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,.15);
|
||||
}
|
||||
.conv-delete {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #aaa;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
margin-left: 8px;
|
||||
border-radius: 6px;
|
||||
transition: background 0.2s, color 0.2s;
|
||||
}
|
||||
.conv-delete:hover {
|
||||
background: #f8d7da;
|
||||
color: #d6336c;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* ===== HOTFIX Sélecteurs (compat HTML existant) ===== */
|
||||
|
||||
/* 1) Aligner les panneaux "liste" et "fil" sur tes IDs historiques */
|
||||
.msg-list-panel, #conv-list { display:block; }
|
||||
.msg-thread-panel, #msg-thread-panel { display:none; } /* caché par défaut en mobile */
|
||||
|
||||
/* 2) En mobile: logique d’affichage avec ou sans show-thread */
|
||||
@media (max-width: 899px){
|
||||
/* Par défaut: on montre la liste, on cache le fil */
|
||||
.msg-app { grid-template-columns:1fr; }
|
||||
.msg-list-panel, #conv-list { display:block !important; }
|
||||
.msg-thread-panel, #msg-thread-panel { display:none !important; }
|
||||
|
||||
/* Quand une conv est ouverte (peut être posé sur .msg-app OU un parent générique) */
|
||||
.msg-app.show-thread .msg-list-panel,
|
||||
.show-thread .msg-list-panel,
|
||||
.msg-app.show-thread #conv-list,
|
||||
.show-thread #conv-list { display:none !important; }
|
||||
|
||||
.msg-app.show-thread .msg-thread-panel,
|
||||
.show-thread .msg-thread-panel,
|
||||
.msg-app.show-thread #msg-thread-panel,
|
||||
.show-thread #msg-thread-panel { display:flex !important; flex-direction:column; }
|
||||
}
|
||||
|
||||
/* 3) Desktop: split view stable pour les deux variantes (classes et IDs) */
|
||||
@media (min-width: 900px){
|
||||
.msg-app{ grid-template-columns: 360px 1fr; }
|
||||
|
||||
.msg-list-panel, #conv-list { display:block !important; }
|
||||
.msg-thread-panel, #msg-thread-panel { display:flex !important; flex-direction:column; }
|
||||
}
|
||||
|
||||
/* 4) Bouton Archiver désactivé visuellement mais présent (aucun JS requis) */
|
||||
.th-btn.is-disabled{
|
||||
opacity:.5;
|
||||
pointer-events:none;
|
||||
filter: grayscale(1);
|
||||
}
|
||||
|
||||
/* =================================================
|
||||
Barre d'envoi FIXE en mobile + PWA
|
||||
================================================= */
|
||||
|
||||
/* Mobile */
|
||||
@media (max-width: 899px) {
|
||||
.thread-footer {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
|
||||
z-index: 1000;
|
||||
background: #fff;
|
||||
|
||||
border-top: 1px solid #e5e7eb;
|
||||
padding: 10px 12px;
|
||||
|
||||
/* iOS notch / home indicator */
|
||||
padding-bottom: calc(10px + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
/* Important : laisser de la place au scroll au-dessus */
|
||||
.thread-body {
|
||||
padding-bottom: 90px; /* hauteur approx de la barre */
|
||||
}
|
||||
}
|
||||
|
||||
/* PWA explicite (au cas où la largeur est > 900px) */
|
||||
@media (display-mode: standalone) {
|
||||
.thread-footer {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
|
||||
z-index: 1000;
|
||||
background: #fff;
|
||||
|
||||
border-top: 1px solid #e5e7eb;
|
||||
padding: 10px 12px;
|
||||
padding-bottom: calc(10px + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.thread-body {
|
||||
padding-bottom: 90px;
|
||||
}
|
||||
}
|
||||
|
||||
/* =================================================
|
||||
Thread header FIXE (mobile + PWA)
|
||||
================================================= */
|
||||
|
||||
/* Mobile */
|
||||
@media (max-width: 899px) {
|
||||
.thread-header {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
|
||||
/* sera positionné via JS juste sous le header global */
|
||||
top: var(--app-header-h, 64px);
|
||||
|
||||
z-index: 1000;
|
||||
background: #fff;
|
||||
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
/* laisser de la place au contenu */
|
||||
.thread-body {
|
||||
padding-top: 56px; /* hauteur approx du thread-header */
|
||||
}
|
||||
}
|
||||
|
||||
/* PWA explicite */
|
||||
@media (display-mode: standalone) {
|
||||
.thread-header {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: var(--app-header-h, 64px);
|
||||
|
||||
z-index: 1000;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.thread-body {
|
||||
padding-top: 56px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,424 @@
|
||||
// /messages/messages.js
|
||||
|
||||
(function () {
|
||||
const cfg = window.MESSAGING_CFG || {};
|
||||
const app = document.querySelector('.msg-app');
|
||||
const convList = document.getElementById('conv-list');
|
||||
const threadPanel = document.getElementById('msg-thread-panel');
|
||||
const titleEl = document.getElementById('thread-title');
|
||||
const msgsEl = document.getElementById('messages');
|
||||
const btnBack = document.getElementById('btn-back');
|
||||
const form = document.getElementById('send-form');
|
||||
const input = document.getElementById('msg-input');
|
||||
const fileInput = document.getElementById('msg-file');
|
||||
const hint = document.getElementById('send-hint');
|
||||
const btnArchive = document.getElementById('btn-archive');
|
||||
const btnClose = document.getElementById('btn-close');
|
||||
|
||||
// État
|
||||
let currentConv = Number(threadPanel?.dataset?.convId || cfg.initialConv || 0) || 0;
|
||||
let lastMsgId = 0;
|
||||
let pollTimer = null;
|
||||
let isArchived = false;
|
||||
let isClosed = false;
|
||||
|
||||
// Utils
|
||||
function qs(obj) { return new URLSearchParams(obj).toString(); }
|
||||
function h(s) { const d = document.createElement('div'); d.textContent = (s ?? ''); return d.innerHTML; }
|
||||
function atBottom(el) { return Math.abs(el.scrollHeight - el.scrollTop - el.clientHeight) < 6; }
|
||||
function scrollToBottom() { msgsEl.scrollTop = msgsEl.scrollHeight; }
|
||||
function setShowThread(on) { if (on) app.classList.add('show-thread'); else app.classList.remove('show-thread'); }
|
||||
|
||||
// ===== API helpers =====
|
||||
async function apiListConversations() {
|
||||
const r = await fetch('/messages/api/list_conversations.php', { credentials: 'same-origin' });
|
||||
const j = await r.json();
|
||||
if (!r.ok) throw new Error('list_failed');
|
||||
return j.items || [];
|
||||
}
|
||||
|
||||
async function apiFetchMessages(onlyNew) {
|
||||
if (!currentConv) return [];
|
||||
const url = new URL('/messages/api/fetch_messages.php', location.origin);
|
||||
url.searchParams.set('conversation_id', String(currentConv));
|
||||
if (onlyNew && lastMsgId) url.searchParams.set('since_id', String(lastMsgId));
|
||||
const r = await fetch(url.toString(), { credentials: 'same-origin' });
|
||||
const j = await r.json();
|
||||
if (!r.ok) throw new Error('fetch_failed');
|
||||
return j.items || [];
|
||||
}
|
||||
|
||||
async function apiSendMessage(fd) {
|
||||
const r = await fetch('/messages/api/send_message.php', { method: 'POST', body: fd, credentials: 'same-origin' });
|
||||
const j = await r.json().catch(() => ({}));
|
||||
if (!r.ok || !j.ok) throw new Error(j.error || 'send_failed');
|
||||
return j;
|
||||
}
|
||||
|
||||
async function apiMarkRead() {
|
||||
if (!currentConv) return;
|
||||
|
||||
try {
|
||||
const res = await fetch('/messages/api/mark_read.php', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
credentials: 'same-origin',
|
||||
body: qs({ conversation_id: String(currentConv) })
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (data.ok && window.applyBadges) {
|
||||
window.applyBadges(data);
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
// silencieux volontairement
|
||||
}
|
||||
}
|
||||
|
||||
async function apiDeleteMessage(id) {
|
||||
const fd = new FormData();
|
||||
fd.set('_csrf', cfg.csrf);
|
||||
fd.set('message_id', String(id));
|
||||
const r = await fetch('/messages/api/delete_message.php', { method: 'POST', body: fd, credentials: 'same-origin' });
|
||||
const j = await r.json();
|
||||
if (!r.ok || !j.ok) throw new Error(j.error || 'delete_failed');
|
||||
return j;
|
||||
}
|
||||
|
||||
async function apiArchiveConversation(convId, archive) {
|
||||
const fd = new FormData();
|
||||
fd.set('_csrf', cfg.csrf);
|
||||
fd.set('conversation_id', String(convId));
|
||||
fd.set('archive', String(archive ? 1 : 0));
|
||||
const r = await fetch('/messages/api/archive_conversation.php', { method: 'POST', body: fd, credentials: 'same-origin' });
|
||||
const j = await r.json();
|
||||
if (!r.ok || !j.ok) throw new Error(j.error || 'archive_failed');
|
||||
return j;
|
||||
}
|
||||
|
||||
async function apiCloseConversation(convId, close) {
|
||||
const fd = new FormData();
|
||||
fd.set('_csrf', cfg.csrf);
|
||||
fd.set('conversation_id', String(convId));
|
||||
fd.set('close', String(close ? 1 : 0));
|
||||
const r = await fetch('/messages/api/close_conversation.php', { method: 'POST', body: fd, credentials: 'same-origin' });
|
||||
const j = await r.json();
|
||||
if (!r.ok || !j.ok) throw new Error(j.error || 'close_failed');
|
||||
return j;
|
||||
}
|
||||
|
||||
async function apiGetConversation(convId) {
|
||||
const url = new URL('/messages/api/get_conversation.php', location.origin);
|
||||
url.searchParams.set('conversation_id', String(convId));
|
||||
const r = await fetch(url.toString(), { credentials: 'same-origin' });
|
||||
const j = await r.json();
|
||||
if (!r.ok || !j.ok) throw new Error(j.error || 'meta_failed');
|
||||
return j; // {status, archived_for_me, ...}
|
||||
}
|
||||
|
||||
// ===== Rendu UI =====
|
||||
async function loadConversations() {
|
||||
try {
|
||||
const items = await apiListConversations();
|
||||
renderConversations(items);
|
||||
} catch (e) {
|
||||
convList.innerHTML = `<div class="conv-item"><div class="conv-title">API indisponible</div></div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderConversations(items){
|
||||
convList.innerHTML = '';
|
||||
|
||||
if (!items.length){
|
||||
const el = document.createElement('div');
|
||||
el.className = 'conv-item';
|
||||
el.innerHTML = `
|
||||
<div style="flex:1; text-align:left; overflow:hidden">
|
||||
<div class="conv-title">${h(cfg.i18n?.noConversations || 'Sélectionner une conversation')}</div>
|
||||
<div class="conv-last"></div>
|
||||
</div>
|
||||
`;
|
||||
convList.appendChild(el);
|
||||
return;
|
||||
}
|
||||
|
||||
items.forEach(it => {
|
||||
const btn = document.createElement('div'); // div pour pouvoir avoir 2 zones cliquables
|
||||
btn.className = 'conv-item';
|
||||
|
||||
btn.innerHTML = `
|
||||
<div class="conv-main" style="flex:1; text-align:left; overflow:hidden; cursor:pointer;">
|
||||
<div class="conv-title">${h(it.peer_name || ('#'+it.id))}</div>
|
||||
<div class="conv-last">${h(it.last_body || '')}</div>
|
||||
</div>
|
||||
${Number(it.unread||0) ? `<span class="conv-badge">${it.unread}</span>` : ''}
|
||||
<button type="button" class="conv-delete" title="${h(cfg.i18n?.deleteConv || 'Supprimer')}">🗑️</button>
|
||||
`;
|
||||
|
||||
// ouvrir la conversation si on clique sur le bloc texte
|
||||
btn.querySelector('.conv-main').onclick = () => openConversation(Number(it.id));
|
||||
|
||||
// suppression via la corbeille
|
||||
btn.querySelector('.conv-delete').onclick = async (e) => {
|
||||
e.stopPropagation();
|
||||
if (!confirm(cfg.i18n?.deleteConvConfirm || 'Supprimer cette conversation ?')) return;
|
||||
try{
|
||||
await apiDeleteConversation(it.id);
|
||||
// Si on est en train de regarder cette conv, on ferme le thread
|
||||
if (Number(threadPanel.dataset.convId||0) === Number(it.id)) {
|
||||
threadPanel.dataset.convId = '0';
|
||||
titleEl.textContent = cfg.i18n?.noConversations || 'Sélectionner une conversation';
|
||||
msgsEl.innerHTML = '';
|
||||
setShowThread(false);
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete('conv');
|
||||
window.history.replaceState(null, '', url.toString());
|
||||
}
|
||||
await loadConversations();
|
||||
}catch(err){
|
||||
alert(cfg.i18n?.deleteConvFail || 'Suppression impossible.');
|
||||
}
|
||||
};
|
||||
|
||||
convList.appendChild(btn);
|
||||
});
|
||||
}
|
||||
async function apiDeleteConversation(convId){
|
||||
const fd = new FormData();
|
||||
fd.set('_csrf', cfg.csrf);
|
||||
fd.set('conversation_id', String(convId));
|
||||
const r = await fetch('/messages/api/delete_conversation.php', { method:'POST', body: fd, credentials: 'same-origin' });
|
||||
const j = await r.json();
|
||||
if (!r.ok || !j.ok) throw new Error(j.error || 'delete_conv_failed');
|
||||
return j;
|
||||
}
|
||||
|
||||
|
||||
async function fetchAndRender(onlyNew) {
|
||||
if (!currentConv) return;
|
||||
try {
|
||||
const items = await apiFetchMessages(onlyNew);
|
||||
renderMessages(items, !!onlyNew);
|
||||
} catch (e) {
|
||||
// silencieux
|
||||
}
|
||||
}
|
||||
|
||||
function renderMessages(items, append) {
|
||||
if (!append) msgsEl.innerHTML = '';
|
||||
const stick = atBottom(msgsEl);
|
||||
|
||||
items.forEach(m => {
|
||||
const mine = (m.sender_role === cfg.role && Number(m.sender_id) === Number(cfg.meId));
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'msg' + (mine ? ' me' : '');
|
||||
wrap.dataset.id = String(m.id);
|
||||
|
||||
const parts = [];
|
||||
if (m.body) parts.push(`<div>${h(m.body)}</div>`);
|
||||
if (m.attachment_path) {
|
||||
const name = m.attachment_name || 'fichier';
|
||||
parts.push(`<div><a href="/messages/api/download_attachment.php?message_id=${Number(m.id)}" rel="noopener">${h(name)}</a></div>`);
|
||||
}
|
||||
|
||||
const actions = `
|
||||
<div class="msg-actions">
|
||||
<button type="button" data-action="delete-for-me">🗑️ ${h(cfg.i18n?.hide || 'Masquer')}</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
wrap.innerHTML = `
|
||||
<div class="bubble">${parts.join('')}</div>
|
||||
<div class="meta">${h(m.created_at || '')}</div>
|
||||
${actions}
|
||||
`;
|
||||
msgsEl.appendChild(wrap);
|
||||
lastMsgId = Math.max(lastMsgId, Number(m.id || 0));
|
||||
});
|
||||
|
||||
if (stick) scrollToBottom();
|
||||
}
|
||||
|
||||
function applyClosedUI(on) {
|
||||
isClosed = !!on;
|
||||
if (btnClose) btnClose.textContent = on ? (cfg.i18n?.reopen || 'Rouvrir') : (cfg.i18n?.close || 'Clôturer');
|
||||
if (input) input.disabled = on;
|
||||
if (fileInput) fileInput.disabled = on;
|
||||
const sendBtn = form?.querySelector('.send-btn');
|
||||
if (sendBtn) sendBtn.disabled = on;
|
||||
|
||||
// bannière info
|
||||
let banner = document.getElementById('thread-closed-banner');
|
||||
if (on) {
|
||||
if (!banner) {
|
||||
banner = document.createElement('div');
|
||||
banner.id = 'thread-closed-banner';
|
||||
banner.className = 'thread-banner';
|
||||
banner.textContent = cfg.i18n?.closedBanner || 'Cette conversation est clôturée.';
|
||||
const body = threadPanel.querySelector('.thread-body');
|
||||
threadPanel.insertBefore(banner, body || threadPanel.firstChild);
|
||||
}
|
||||
} else if (banner) {
|
||||
banner.remove();
|
||||
}
|
||||
}
|
||||
|
||||
async function openConversation(id) {
|
||||
currentConv = Number(id) || 0;
|
||||
threadPanel.dataset.convId = String(currentConv);
|
||||
titleEl.textContent = `…`;
|
||||
setShowThread(true);
|
||||
lastMsgId = 0;
|
||||
|
||||
// labels par défaut
|
||||
if (btnArchive) btnArchive.textContent = cfg.i18n?.archive || 'Archiver';
|
||||
if (btnClose) btnClose.textContent = cfg.i18n?.close || 'Clôturer';
|
||||
applyClosedUI(false);
|
||||
isArchived = false;
|
||||
|
||||
// synchro état réel
|
||||
try {
|
||||
const meta = await apiGetConversation(currentConv);
|
||||
titleEl.textContent = meta.peer_name || `${(cfg.i18n?.conversation || 'Conversation')} #${currentConv}`;
|
||||
applyClosedUI(meta.status === 'closed');
|
||||
isArchived = !!meta.archived_for_me;
|
||||
if (btnArchive) btnArchive.textContent = isArchived
|
||||
? (cfg.i18n?.unarchive || cfg.i18n?.desarchiver || 'Désarchiver')
|
||||
: (cfg.i18n?.archive || 'Archiver');
|
||||
} catch (e) { /* ok si indispo */ }
|
||||
|
||||
await fetchAndRender(false);
|
||||
await fetchAndRender(false);
|
||||
// force descendre tout en bas à l’ouverture
|
||||
msgsEl.scrollTop = msgsEl.scrollHeight;
|
||||
await apiMarkRead();
|
||||
startPolling();
|
||||
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('conv', String(currentConv));
|
||||
window.history.replaceState(null, '', url.toString());
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
if (pollTimer) clearInterval(pollTimer);
|
||||
pollTimer = setInterval(async () => {
|
||||
if (!currentConv) return;
|
||||
await fetchAndRender(true);
|
||||
// Optionnel: rafraîchir la liste pour les badges
|
||||
loadConversations();
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// ===== Handlers =====
|
||||
form?.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
if (!currentConv) return;
|
||||
const fd = new FormData(form);
|
||||
fd.set('conversation_id', String(currentConv));
|
||||
fd.set('_csrf', cfg.csrf);
|
||||
|
||||
const btn = form.querySelector('.send-btn');
|
||||
if (btn) btn.disabled = true;
|
||||
hint.textContent = '';
|
||||
try {
|
||||
await apiSendMessage(fd);
|
||||
form.reset();
|
||||
await fetchAndRender(true);
|
||||
await apiMarkRead();
|
||||
loadConversations();
|
||||
input?.focus();
|
||||
} catch (e2) {
|
||||
hint.textContent = cfg.i18n?.sendError || 'Erreur lors de l’envoi.';
|
||||
} finally {
|
||||
if (btn) btn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Délégation: suppression “pour moi”
|
||||
msgsEl.addEventListener('click', async (e) => {
|
||||
const delBtn = e.target.closest?.('[data-action="delete-for-me"]');
|
||||
if (!delBtn) return;
|
||||
const wrap = delBtn.closest('.msg');
|
||||
const id = Number(wrap?.dataset.id || 0);
|
||||
if (!id) return;
|
||||
|
||||
if (!confirm(cfg.i18n?.hideConfirm || 'Masquer ce message pour vous ?')) return;
|
||||
|
||||
try {
|
||||
await apiDeleteMessage(id);
|
||||
wrap.remove();
|
||||
hint.textContent = cfg.i18n?.hideOk || 'Message masqué.';
|
||||
setTimeout(() => { hint.textContent = ''; }, 2000);
|
||||
} catch (err) {
|
||||
alert(cfg.i18n?.hideFail || 'Suppression impossible pour le moment.');
|
||||
}
|
||||
});
|
||||
|
||||
// Archiver / Désarchiver (toggle)
|
||||
btnArchive?.addEventListener('click', async () => {
|
||||
if (!currentConv) return;
|
||||
try {
|
||||
btnArchive.disabled = true;
|
||||
const target = isArchived ? 0 : 1;
|
||||
await apiArchiveConversation(currentConv, target);
|
||||
isArchived = !!target;
|
||||
|
||||
// Label
|
||||
btnArchive.textContent = isArchived
|
||||
? (cfg.i18n?.unarchive || cfg.i18n?.desarchiver || 'Désarchiver')
|
||||
: (cfg.i18n?.archive || 'Archiver');
|
||||
|
||||
await loadConversations();
|
||||
|
||||
if (isArchived) {
|
||||
// fermer le fil, nettoyer l’URL
|
||||
currentConv = 0;
|
||||
threadPanel.dataset.convId = '0';
|
||||
titleEl.textContent = cfg.i18n?.noConversations || 'Sélectionner une conversation';
|
||||
msgsEl.innerHTML = '';
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete('conv');
|
||||
window.history.replaceState(null, '', url.toString());
|
||||
setShowThread(false);
|
||||
}
|
||||
} catch (e) {
|
||||
alert('Archive: erreur.');
|
||||
} finally {
|
||||
btnArchive.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Clôturer / Rouvrir (toggle)
|
||||
btnClose?.addEventListener('click', async () => {
|
||||
if (!currentConv) return;
|
||||
try {
|
||||
btnClose.disabled = true;
|
||||
if (!isClosed) {
|
||||
await apiCloseConversation(currentConv, 1);
|
||||
applyClosedUI(true);
|
||||
} else {
|
||||
await apiCloseConversation(currentConv, 0);
|
||||
applyClosedUI(false);
|
||||
}
|
||||
} catch (e) {
|
||||
alert('Clôture: ' + (e.message || 'erreur.'));
|
||||
} finally {
|
||||
btnClose.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Retour mobile
|
||||
btnBack?.addEventListener('click', () => {
|
||||
setShowThread(false);
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete('conv');
|
||||
window.history.replaceState(null, '', url.toString());
|
||||
});
|
||||
|
||||
// ===== INIT =====
|
||||
loadConversations();
|
||||
if (currentConv) { setShowThread(true); openConversation(currentConv); }
|
||||
else { setShowThread(false); }
|
||||
})();
|
||||
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
// /messages/open.php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/_bootstrap.php'; // charge $pdo, $role ('praticien'|'client'), $me_id
|
||||
|
||||
// Accepte GET ou POST (on privilégie GET pour les liens)
|
||||
$toRole = isset($_REQUEST['to']) ? strtolower(trim((string)$_REQUEST['to'])) : '';
|
||||
$otherId = isset($_REQUEST['id']) ? (int)$_REQUEST['id'] : 0;
|
||||
|
||||
// Règles de base
|
||||
if (!in_array($toRole, ['praticien','client'], true) || $otherId <= 0) {
|
||||
http_response_code(400);
|
||||
echo "Paramètres invalides. Exemple: /messages/open.php?to=praticien&id=123";
|
||||
exit;
|
||||
}
|
||||
|
||||
// Empêche d'ouvrir avec soi-même ou même rôle
|
||||
if ($toRole === $role) {
|
||||
http_response_code(400);
|
||||
echo "Cible invalide : le paramètre 'to' doit être l'autre rôle.";
|
||||
exit;
|
||||
}
|
||||
|
||||
// Détermine les deux IDs (praticien / client) en fonction du rôle courant
|
||||
if ($role === 'praticien') {
|
||||
$praticien_id = $me_id;
|
||||
$client_id = $otherId;
|
||||
} else { // role === 'client'
|
||||
$praticien_id = $otherId;
|
||||
$client_id = $me_id;
|
||||
}
|
||||
|
||||
// (Option recommandé) Vérifier que l'autre existe — sans FK, on contrôle à l'amiable
|
||||
try {
|
||||
if ($role === 'praticien') {
|
||||
// On va écrire au client
|
||||
$stmt = $pdo->prepare("SELECT 1 FROM client WHERE id = ? LIMIT 1");
|
||||
$stmt->execute([$client_id]);
|
||||
if (!$stmt->fetchColumn()) {
|
||||
http_response_code(404);
|
||||
echo "Client introuvable (#$client_id).";
|
||||
exit;
|
||||
}
|
||||
} else {
|
||||
// role client -> on va écrire au praticien
|
||||
$stmt = $pdo->prepare("SELECT 1 FROM praticien WHERE id = ? LIMIT 1");
|
||||
$stmt->execute([$praticien_id]);
|
||||
if (!$stmt->fetchColumn()) {
|
||||
http_response_code(404);
|
||||
echo "Praticien introuvable (#$praticien_id).";
|
||||
exit;
|
||||
}
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
// Si ces tables n'existent pas ou autre souci, on ne bloque pas l'ouverture (sécurité déjà gérée ailleurs)
|
||||
// Mais on loggue minimalement si besoin (à activer plus tard)
|
||||
// error_log("messages/open existence check: " . $e->getMessage());
|
||||
}
|
||||
|
||||
// Tente de retrouver une conversation existante
|
||||
$stmt = $pdo->prepare("SELECT id FROM conversations WHERE praticien_id = ? AND client_id = ? LIMIT 1");
|
||||
$stmt->execute([$praticien_id, $client_id]);
|
||||
$convId = (int)$stmt->fetchColumn();
|
||||
|
||||
if (!$convId) {
|
||||
// Création atomique avec UNIQUE( praticien_id, client_id )
|
||||
try {
|
||||
$pdo->beginTransaction();
|
||||
|
||||
$ins = $pdo->prepare("INSERT INTO conversations (praticien_id, client_id, status, created_at, updated_at)
|
||||
VALUES (?, ?, 'open', NOW(), NOW())");
|
||||
$ins->execute([$praticien_id, $client_id]);
|
||||
$convId = (int)$pdo->lastInsertId();
|
||||
|
||||
// Prépare aussi les compteurs
|
||||
$pdo->prepare("INSERT INTO conversation_counters (conversation_id, p_unread, c_unread)
|
||||
VALUES (?, 0, 0)")
|
||||
->execute([$convId]);
|
||||
|
||||
$pdo->commit();
|
||||
} catch (Throwable $e) {
|
||||
// Conflit de concurrence (unique key déjà prise) → on relit
|
||||
if ($pdo->inTransaction()) { $pdo->rollBack(); }
|
||||
$stmt = $pdo->prepare("SELECT id FROM conversations WHERE praticien_id = ? AND client_id = ? LIMIT 1");
|
||||
$stmt->execute([$praticien_id, $client_id]);
|
||||
$convId = (int)$stmt->fetchColumn();
|
||||
if (!$convId) {
|
||||
http_response_code(500);
|
||||
echo "Impossible de créer ou d'ouvrir la conversation.";
|
||||
exit;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Si la conversation est close, on peut choisir de la rouvrir (V1: on rouvre pour simplifier)
|
||||
$pdo->prepare("UPDATE conversations SET status='open', updated_at=NOW() WHERE id = ? AND status='closed'")
|
||||
->execute([$convId]);
|
||||
|
||||
// Redirection vers l'UI principale
|
||||
header('Location: /messages/index.php?conv=' . $convId);
|
||||
exit;
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
// /messages/start.php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/_bootstrap.php';
|
||||
|
||||
$role = $GLOBALS['MESSAGES_CTX']['role']; // 'praticien' | 'client'
|
||||
$meId = (int)$GLOBALS['MESSAGES_CTX']['me_id'];
|
||||
|
||||
$pid = isset($_GET['praticien_id']) ? (int)$_GET['praticien_id'] : 0;
|
||||
$cid = isset($_GET['client_id']) ? (int)$_GET['client_id'] : 0;
|
||||
|
||||
// Déterminer l’interlocuteur selon mon rôle
|
||||
if ($role === 'client' && $pid > 0) {
|
||||
$praticienId = $pid;
|
||||
$clientId = $meId;
|
||||
} elseif ($role === 'praticien' && $cid > 0) {
|
||||
$praticienId = $meId;
|
||||
$clientId = $cid;
|
||||
} else {
|
||||
http_response_code(400);
|
||||
echo "Bad params";
|
||||
exit;
|
||||
}
|
||||
|
||||
// Chercher une conversation existante
|
||||
$stmt = $pdo->prepare("SELECT * FROM conversations WHERE praticien_id = ? AND client_id = ? LIMIT 1");
|
||||
$stmt->execute([$praticienId, $clientId]);
|
||||
$conv = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if ($conv) {
|
||||
// Restaurer côté moi + rouvrir
|
||||
if ($role === 'client') {
|
||||
$pdo->prepare("
|
||||
UPDATE conversations
|
||||
SET deleted_for_client = 0,
|
||||
deleted_for_praticien = 0,
|
||||
archived_by_client = 0,
|
||||
archived_by_praticien = 0,
|
||||
status = 'open',
|
||||
updated_at = NOW()
|
||||
WHERE id = ?
|
||||
")->execute([(int)$conv['id']]);
|
||||
} else { // praticien
|
||||
$pdo->prepare("
|
||||
UPDATE conversations
|
||||
SET deleted_for_praticien = 0,
|
||||
deleted_for_client = 0,
|
||||
archived_by_praticien = 0,
|
||||
archived_by_client = 0,
|
||||
status = 'open',
|
||||
updated_at = NOW()
|
||||
WHERE id = ?
|
||||
")->execute([(int)$conv['id']]);
|
||||
}
|
||||
$convId = (int)$conv['id'];
|
||||
} else {
|
||||
// Créer la conversation
|
||||
$pdo->beginTransaction();
|
||||
try {
|
||||
$pdo->prepare("
|
||||
INSERT INTO conversations
|
||||
(praticien_id, client_id, status,
|
||||
archived_by_praticien, archived_by_client,
|
||||
deleted_for_praticien, deleted_for_client,
|
||||
created_at, updated_at)
|
||||
VALUES (?, ?, 'open', 0, 0, 0, 0, NOW(), NOW())
|
||||
")->execute([$praticienId, $clientId]);
|
||||
|
||||
$convId = (int)$pdo->lastInsertId();
|
||||
|
||||
// counters optionnels
|
||||
if ($pdo->query("SHOW TABLES LIKE 'conversation_counters'")->rowCount()) {
|
||||
$pdo->prepare("INSERT INTO conversation_counters (conversation_id, p_unread, c_unread)
|
||||
VALUES (?, 0, 0)")->execute([$convId]);
|
||||
}
|
||||
|
||||
$pdo->commit();
|
||||
} catch (Throwable $e) {
|
||||
$pdo->rollBack();
|
||||
http_response_code(500);
|
||||
echo "Create failed";
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
// Redirection vers l’UI
|
||||
header('Location: /messages/?conv=' . $convId);
|
||||
exit;
|
||||
@@ -0,0 +1,9 @@
|
||||
Options -Indexes
|
||||
|
||||
<FilesMatch "\.(php|phar|phtml|cgi|pl|py|sh|exe|bin|js)$">
|
||||
Deny from all
|
||||
</FilesMatch>
|
||||
|
||||
<IfModule mod_headers.c>
|
||||
Header set X-Content-Type-Options "nosniff"
|
||||
</IfModule>
|
||||
Reference in new issue
Block a user