Init Git Prod Mars 2026

This commit is contained in:
Oleg Barykin committed 2026-03-05 10:25:39 +01:00
commit d81efe93b1
362 files changed
+86627

No files matched your search

+101
View File
@@ -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
];
}
+24
View File
@@ -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]);
+29
View File
@@ -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']);
+48
View File
@@ -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]);
+38
View File
@@ -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]);
+113
View File
@@ -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;
+92
View File
@@ -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,
]);
+60
View File
@@ -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'],
]);
+85
View File
@@ -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]);
+122
View File
@@ -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);
}
+129
View File
@@ -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]);
+236
View File
@@ -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]);
+27
View File
@@ -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]);
+48
View File
@@ -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]);
+133
View File
@@ -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>
+354
View File
@@ -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;
}
}
+424
View File
@@ -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ésarchiv­er')
: (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); }
})();
+102
View File
@@ -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;
+88
View File
@@ -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;
+9
View File
@@ -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>