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

+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]);