N) // GET /api/chat/sent?since=N — pull state updates (delivered/seen) for // *my* sent messages with id > N. The // sender uses this to learn read receipts // without needing a backchannel. // POST /api/chat/seen {peer} — mark every message FROM peer TO me as // seen. The sender's next /api/chat/sent // poll picks the new state up. // // Same decrypt-reencrypt hop as messages.php: arrives encrypted with the // senders client secret, stored at-rest under the inter-tenant key, returned // re-encrypted with the recipients client secret. Relay sees plaintext only // during the routing hop. if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; } // ── Session-based chat (relay UI) ── if ($route === '/api/chat/threads' && $method === 'GET' && $sessionTenant) { $interKey = getInterTenantKey($db, $MASTER_KEY); if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set'], 500); $myId = $sessionTenant['id']; // 1:1 threads: my 1:1 messages grouped by peer username. Group sends // (room_uid IS NOT NULL) are excluded so they don't surface here. $stmt = $db->prepare(' SELECT sender_username AS peer, MAX(created_at) AS last_at, COUNT(*) AS count FROM chat_messages WHERE recipient_id = :r AND room_uid IS NULL GROUP BY sender_username UNION SELECT t.username AS peer, MAX(cm.created_at) AS last_at, COUNT(*) AS count FROM chat_messages cm JOIN tenants t ON t.id = cm.recipient_id WHERE cm.sender_id = :s AND cm.recipient_id != :s2 AND cm.room_uid IS NULL GROUP BY t.username '); $stmt->execute(['r' => $myId, 's' => $myId, 's2' => $myId]); $rows = $stmt->fetchAll(); $threads = []; foreach ($rows as $row) { $peer = $row['peer']; if (!isset($threads[$peer])) $threads[$peer] = ['peer' => $peer, 'last_at' => $row['last_at'], 'count' => 0]; $threads[$peer]['count'] += (int)$row['count']; if ($row['last_at'] > $threads[$peer]['last_at']) $threads[$peer]['last_at'] = $row['last_at']; } foreach ($threads as &$t) { $stmt2 = $db->prepare('SELECT COUNT(*) FROM chat_messages WHERE recipient_id = :r AND sender_username = :p AND seen = 0 AND room_uid IS NULL'); $stmt2->execute(['r' => $myId, 'p' => $t['peer']]); $t['unread'] = (int)$stmt2->fetchColumn(); } unset($t); // Group rooms the caller is a member of. The UI renders these as // first-class threads alongside 1:1 conversations; room_uid marks them // so the client can key off the right identifier. $roomStmt = $db->prepare(' SELECT r.uid, r.name, r.owner_username, (SELECT MAX(created_at) FROM chat_messages cm WHERE cm.room_uid = r.uid) AS last_at, (SELECT COUNT(*) FROM chat_messages cm WHERE cm.room_uid = r.uid) AS count FROM chat_rooms r INNER JOIN chat_room_members m ON m.room_uid = r.uid WHERE m.tenant_id = :tid ORDER BY last_at DESC '); $roomStmt->execute(['tid' => $myId]); $roomThreads = []; foreach ($roomStmt->fetchAll() as $r) { $roomThreads[] = [ 'room_uid' => $r['uid'], 'name' => $r['name'], 'owner' => $r['owner_username'], 'last_at' => $r['last_at'] ?? '', 'count' => (int)$r['count'], 'unread' => 0, // rooms have no per-recipient seen state yet ]; } $combined = array_merge(array_values($threads), $roomThreads); usort($combined, fn($a, $b) => strcmp($b['last_at'] ?? '', $a['last_at'] ?? '')); jsonResponse($combined); } if ($route === '/api/chat/history' && $method === 'GET' && $sessionTenant) { $peer = trim($_GET['peer'] ?? ''); $roomUid = trim($_GET['room_uid'] ?? ''); if ($peer === '' && $roomUid === '') jsonResponse(['error' => 'peer or room_uid required'], 400); $interKey = getInterTenantKey($db, $MASTER_KEY); if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set'], 500); $myId = $sessionTenant['id']; if ($roomUid !== '') { // Group room: caller must be a current member. $check = $db->prepare('SELECT 1 FROM chat_room_members WHERE room_uid = :r AND tenant_id = :t'); $check->execute(['r' => $roomUid, 't' => $myId]); if (!$check->fetchColumn()) jsonResponse(['error' => 'not a member of this room'], 403); $stmt = $db->prepare(' SELECT id, sender_id, sender_username, body, created_at FROM chat_messages WHERE room_uid = :r ORDER BY created_at ASC LIMIT 500 '); $stmt->execute(['r' => $roomUid]); $rows = $stmt->fetchAll(); $out = []; foreach ($rows as $row) { $out[] = [ 'id' => (int)$row['id'], 'from' => $row['sender_username'], 'mine' => (int)$row['sender_id'] === $myId, 'body' => clientDecrypt($row['body'] ?? '', $interKey), 'created_at' => $row['created_at'], 'seen' => 1, // group rooms don't track per-recipient seen ]; } jsonResponse($out); } // 1:1 fallback $peerTenant = getTenantByUsername($db, $peer); if (!$peerTenant) jsonResponse(['error' => 'peer not found'], 404); $peerId = $peerTenant['id']; $stmt = $db->prepare(' SELECT id, sender_id, sender_username, body, created_at, seen FROM chat_messages WHERE room_uid IS NULL AND ((recipient_id = :r AND sender_id = :p) OR (recipient_id = :p2 AND sender_id = :s)) ORDER BY created_at ASC LIMIT 500 '); $stmt->execute(['r' => $myId, 'p' => $peerId, 'p2' => $peerId, 's' => $myId]); $rows = $stmt->fetchAll(); $out = []; foreach ($rows as $row) { $out[] = [ 'id' => (int)$row['id'], 'from' => $row['sender_username'], 'mine' => (int)$row['sender_id'] === $myId, 'body' => clientDecrypt($row['body'] ?? '', $interKey), 'created_at' => $row['created_at'], 'seen' => (int)$row['seen'], ]; } // Mark incoming as seen $db->prepare('UPDATE chat_messages SET seen = 1 WHERE recipient_id = :r AND sender_id = :p AND seen = 0') ->execute(['r' => $myId, 'p' => $peerId]); jsonResponse($out); } if ($route === '/api/chat/send' && $method === 'POST' && $sessionTenant) { $input = json_decode(file_get_contents('php://input'), true) ?: []; $body = trim((string)($input['body'] ?? '')); $recipientName = trim((string)($input['recipient'] ?? '')); $roomUid = trim((string)($input['room_uid'] ?? '')); if ($body === '') jsonResponse(['error' => 'body required'], 400); if ($recipientName === '' && $roomUid === '') jsonResponse(['error' => 'recipient or room_uid required'], 400); $interKey = getInterTenantKey($db, $MASTER_KEY); if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set'], 500); $stored = clientEncrypt($body, $interKey); if ($roomUid !== '') { // Group post: verify membership. `recipient_id` NOT NULL because of // the FK, so we park the sender's own id there — GET filters on // room_uid, never recipient_id, for group rows. $check = $db->prepare('SELECT 1 FROM chat_room_members WHERE room_uid = :r AND tenant_id = :t'); $check->execute(['r' => $roomUid, 't' => $sessionTenant['id']]); if (!$check->fetchColumn()) jsonResponse(['error' => 'not a member of this room'], 403); $db->prepare('INSERT INTO chat_messages (recipient_id, sender_id, sender_username, room_uid, body, persistent) VALUES (:me, :s, :sn, :ru, :b, 1)') ->execute([ 'me' => $sessionTenant['id'], 's' => $sessionTenant['id'], 'sn' => $sessionTenant['username'], 'ru' => $roomUid, 'b' => $stored, ]); jsonResponse(['ok' => true, 'id' => (int)$db->lastInsertId(), 'room_uid' => $roomUid]); } $recipient = getTenantByUsername($db, $recipientName); if (!$recipient) jsonResponse(['error' => 'recipient not found'], 404); $db->prepare('INSERT INTO chat_messages (recipient_id, sender_id, sender_username, body) VALUES (:r, :s, :sn, :b)') ->execute(['r' => $recipient['id'], 's' => $sessionTenant['id'], 'sn' => $sessionTenant['username'], 'b' => $stored]); jsonResponse(['ok' => true, 'id' => (int)$db->lastInsertId()]); } // ── Client API ── $clientTenant = $clientTenant ?? authClient($db); // Sync-event notifications. Client API only: a tenant that just synced // a received card POSTs here to announce it to the original sender, and // the sender polls GET to surface each event as a transient toast. // Bodies are short plaintext strings like "alice synced: Card Title" — // no encryption, no per-peer routing through the chat thread, no // clutter in the regular chat history. if ($route === '/api/sync-events' && $method === 'POST') { if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401); $input = json_decode(file_get_contents('php://input'), true) ?: []; $body = trim((string)($input['body'] ?? '')); $recipientName = trim((string)($input['recipient'] ?? '')); if ($body === '') jsonResponse(['error' => 'body required'], 400); if ($recipientName === '') jsonResponse(['error' => 'recipient required'], 400); if ($recipientName === $clientTenant['username']) jsonResponse(['ok' => true, 'id' => 0]); // suppress self-notifications $recipient = getTenantByUsername($db, $recipientName); if (!$recipient) jsonResponse(['error' => 'recipient not found'], 404); $db->prepare('INSERT INTO relay_sync_events (recipient_id, sender_username, body) VALUES (:r, :sn, :b)') ->execute(['r' => $recipient['id'], 'sn' => $clientTenant['username'], 'b' => $body]); jsonResponse(['ok' => true, 'id' => (int)$db->lastInsertId()]); } if ($route === '/api/sync-events' && $method === 'GET') { if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401); $stmt = $db->prepare(' SELECT id, sender_username, body, created_at FROM relay_sync_events WHERE recipient_id = :r AND fetched = 0 ORDER BY id ASC LIMIT 100 '); $stmt->execute(['r' => $clientTenant['id']]); $rows = $stmt->fetchAll(); if ($rows) { $maxId = (int)$rows[count($rows) - 1]['id']; $db->prepare('UPDATE relay_sync_events SET fetched = 1 WHERE recipient_id = :r AND id <= :m AND fetched = 0') ->execute(['r' => $clientTenant['id'], 'm' => $maxId]); } $out = array_map(fn($r) => [ 'id' => (int)$r['id'], 'sender_username' => $r['sender_username'], 'body' => $r['body'], 'created_at' => $r['created_at'], ], $rows); jsonResponse(['events' => $out]); } // ── Chat rooms (group chat) ────────────────────────────────── // Mirrors the collab-bucket pattern: named container + member list. // Group messages live in chat_messages with room_uid set (recipient_id // is left 0 / NULL — fan-out happens via the join on chat_room_members // at GET time instead of duplicating rows per recipient). Rooms are // always persistent — message retention ignores the 7-day disposable // window. if ($route === '/api/chat/rooms' && $method === 'POST') { if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401); $input = json_decode(file_get_contents('php://input'), true) ?: []; $uid = trim((string)($input['uid'] ?? '')); $name = trim((string)($input['name'] ?? '')); $memberUsernames = is_array($input['members'] ?? null) ? $input['members'] : []; if ($uid === '' || $name === '') jsonResponse(['error' => 'uid and name required'], 400); $exists = $db->prepare('SELECT 1 FROM chat_rooms WHERE uid = :u'); $exists->execute(['u' => $uid]); if ($exists->fetchColumn()) jsonResponse(['error' => 'uid already exists'], 409); $resolved = []; foreach ($memberUsernames as $u) { $u = trim((string)$u); if ($u === '' || $u === $clientTenant['username']) continue; $t = getTenantByUsername($db, $u); if ($t) $resolved[(int)$t['id']] = $t['username']; } $resolved[(int)$clientTenant['id']] = $clientTenant['username']; try { $db->beginTransaction(); $db->prepare('INSERT INTO chat_rooms (uid, owner_id, owner_username, name) VALUES (:uid, :oid, :ou, :n)') ->execute(['uid' => $uid, 'oid' => $clientTenant['id'], 'ou' => $clientTenant['username'], 'n' => $name]); $mstmt = $db->prepare('INSERT INTO chat_room_members (room_uid, tenant_id, username) VALUES (:r, :t, :u)'); foreach ($resolved as $tid => $uname) { $mstmt->execute(['r' => $uid, 't' => $tid, 'u' => $uname]); } $db->commit(); } catch (Throwable $e) { if ($db->inTransaction()) $db->rollBack(); @error_log('[hyprcrd-relay] chat room create failed: ' . $e->getMessage()); jsonResponse(['error' => 'create failed'], 500); } jsonResponse(['ok' => true, 'uid' => $uid, 'members' => array_values($resolved)]); } if ($route === '/api/chat/rooms' && $method === 'GET') { if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401); $stmt = $db->prepare(' SELECT r.uid, r.name, r.owner_username, r.created_at FROM chat_rooms r INNER JOIN chat_room_members m ON m.room_uid = r.uid WHERE m.tenant_id = :tid ORDER BY r.created_at DESC '); $stmt->execute(['tid' => $clientTenant['id']]); $rooms = $stmt->fetchAll(); $mstmt = $db->prepare('SELECT username FROM chat_room_members WHERE room_uid = :uid ORDER BY username'); foreach ($rooms as &$r) { $mstmt->execute(['uid' => $r['uid']]); $r['members'] = array_column($mstmt->fetchAll(), 'username'); } jsonResponse(['ok' => true, 'rooms' => $rooms]); } if (preg_match('#^/api/chat/rooms/([a-zA-Z0-9_-]+)$#', $route, $m) && $method === 'PATCH') { if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401); $uid = $m[1]; $row = $db->prepare('SELECT owner_id FROM chat_rooms WHERE uid = :u'); $row->execute(['u' => $uid]); $room = $row->fetch(); if (!$room) jsonResponse(['error' => 'room not found'], 404); if ((int)$room['owner_id'] !== (int)$clientTenant['id']) jsonResponse(['error' => 'only the owner can update'], 403); $input = json_decode(file_get_contents('php://input'), true) ?: []; if (isset($input['name'])) { $name = trim((string)$input['name']); if ($name === '') jsonResponse(['error' => 'name cannot be empty'], 400); $db->prepare('UPDATE chat_rooms SET name = :n WHERE uid = :u')->execute(['n' => $name, 'u' => $uid]); } if (isset($input['members']) && is_array($input['members'])) { $resolved = []; foreach ($input['members'] as $u) { $u = trim((string)$u); if ($u === '') continue; $t = getTenantByUsername($db, $u); if ($t) $resolved[(int)$t['id']] = $t['username']; } // Owner always stays a member. $resolved[(int)$clientTenant['id']] = $clientTenant['username']; $db->prepare('DELETE FROM chat_room_members WHERE room_uid = :u')->execute(['u' => $uid]); $ins = $db->prepare('INSERT INTO chat_room_members (room_uid, tenant_id, username) VALUES (:r, :t, :u)'); foreach ($resolved as $tid => $uname) { $ins->execute(['r' => $uid, 't' => $tid, 'u' => $uname]); } } jsonResponse(['ok' => true]); } if (preg_match('#^/api/chat/rooms/([a-zA-Z0-9_-]+)$#', $route, $m) && $method === 'DELETE') { if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401); $uid = $m[1]; $row = $db->prepare('SELECT owner_id FROM chat_rooms WHERE uid = :u'); $row->execute(['u' => $uid]); $room = $row->fetch(); if (!$room) jsonResponse(['error' => 'room not found'], 404); if ((int)$room['owner_id'] === (int)$clientTenant['id']) { $db->prepare('DELETE FROM chat_messages WHERE room_uid = :u')->execute(['u' => $uid]); $db->prepare('DELETE FROM chat_room_members WHERE room_uid = :u')->execute(['u' => $uid]); $db->prepare('DELETE FROM chat_rooms WHERE uid = :u')->execute(['u' => $uid]); jsonResponse(['ok' => true, 'action' => 'deleted']); } else { $del = $db->prepare('DELETE FROM chat_room_members WHERE room_uid = :u AND tenant_id = :t'); $del->execute(['u' => $uid, 't' => $clientTenant['id']]); jsonResponse(['ok' => true, 'action' => 'left']); } } if ($route === '/api/chat' && $method === 'POST') { if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401); $input = json_decode(file_get_contents('php://input'), true) ?: []; $encryptedBody = (string)($input['body'] ?? ''); $recipientName = trim((string)($input['recipient'] ?? '')); $roomUid = trim((string)($input['room_uid'] ?? '')); $disposable = !empty($input['disposable']); if ($encryptedBody === '') jsonResponse(['error' => 'body required'], 400); if ($recipientName === '' && $roomUid === '') jsonResponse(['error' => 'recipient or room_uid required'], 400); if ($recipientName !== '' && $roomUid !== '') jsonResponse(['error' => 'pick either recipient or room_uid, not both'], 400); if (empty($clientTenant['secret_encrypted'])) { jsonResponse(['error' => 'sender has no client secret set — log into the relay UI and configure one in your account'], 400); } $senderSecret = masterDecrypt($clientTenant['secret_encrypted'], $MASTER_KEY); if (!$senderSecret) jsonResponse(['error' => 'sender secret unavailable'], 500); $interKey = getInterTenantKey($db, $MASTER_KEY); if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set — admin must complete setup'], 500); $plain = clientDecrypt($encryptedBody, $senderSecret); if ($plain === '') jsonResponse(['error' => 'body decrypt failed (sender secret mismatch?)'], 400); $stored = clientEncrypt($plain, $interKey); if ($roomUid !== '') { // Group message: verify membership. `recipient_id` is NOT NULL + // has a tenants-FK, so we set it to the sender's own id as a // placeholder — GET queries key off room_uid, never recipient_id, // for group rows so this doesn't create a self-message artefact. $check = $db->prepare('SELECT 1 FROM chat_room_members WHERE room_uid = :r AND tenant_id = :t'); $check->execute(['r' => $roomUid, 't' => $clientTenant['id']]); if (!$check->fetchColumn()) jsonResponse(['error' => 'not a member of this room'], 403); $db->prepare('INSERT INTO chat_messages (recipient_id, sender_id, sender_username, room_uid, body, persistent) VALUES (:me, :s, :sn, :ru, :b, 1)') ->execute(['me' => $clientTenant['id'], 's' => $clientTenant['id'], 'sn' => $clientTenant['username'], 'ru' => $roomUid, 'b' => $stored]); } else { $recipient = getTenantByUsername($db, $recipientName); if (!$recipient) jsonResponse(['error' => 'recipient not found'], 404); $db->prepare('INSERT INTO chat_messages (recipient_id, sender_id, sender_username, body, persistent) VALUES (:r, :s, :sn, :b, :p)') ->execute([ 'r' => $recipient['id'], 's' => $clientTenant['id'], 'sn' => $clientTenant['username'], 'b' => $stored, 'p' => $disposable ? 0 : 1, ]); } jsonResponse(['ok' => true, 'id' => (int)$db->lastInsertId()]); } if ($route === '/api/chat' && $method === 'GET') { if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401); if (empty($clientTenant['secret_encrypted'])) { jsonResponse(['error' => 'no client secret set — log into the relay UI and configure one in your account'], 400); } $since = (int)($_GET['since'] ?? 0); // Return 1:1 messages addressed to me + group messages for rooms I'm // in (including my own group sends, so multi-device picks them up — // the client dedups on relay_id). Exclude my own 1:1 sends — the // sender has them locally already and the /api/chat/sent endpoint // covers cross-device state for 1:1. $stmt = $db->prepare(' SELECT id, sender_id, sender_username, recipient_id, room_uid, body, created_at FROM chat_messages WHERE id > :s AND ( (recipient_id = :me AND room_uid IS NULL) OR (room_uid IS NOT NULL AND room_uid IN ( SELECT room_uid FROM chat_room_members WHERE tenant_id = :me2 )) ) ORDER BY id ASC LIMIT 500 '); $stmt->execute(['me' => $clientTenant['id'], 'me2' => $clientTenant['id'], 's' => $since]); $rows = $stmt->fetchAll(); $interKey = getInterTenantKey($db, $MASTER_KEY); if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set'], 500); $recipientSecret = masterDecrypt($clientTenant['secret_encrypted'], $MASTER_KEY); if (!$recipientSecret) jsonResponse(['error' => 'recipient secret unavailable'], 500); $out = []; $maxId = $since; foreach ($rows as $row) { $plain = clientDecrypt($row['body'] ?? '', $interKey); $out[] = [ 'id' => (int)$row['id'], 'sender_id' => (int)$row['sender_id'], 'sender_username' => $row['sender_username'], 'room_uid' => $row['room_uid'], 'body' => clientEncrypt($plain, $recipientSecret), 'created_at' => $row['created_at'], 'mine' => (int)$row['sender_id'] === (int)$clientTenant['id'], ]; if ((int)$row['id'] > $maxId) $maxId = (int)$row['id']; } // Mark fetched on 1:1 messages so disposable ones can be pruned // sooner. Group messages stay persistent regardless — no per-poll // fan-out state, so nothing to flag there. if ($out) { $db->prepare('UPDATE chat_messages SET fetched = 1 WHERE recipient_id = :r AND room_uid IS NULL AND id <= :m AND fetched = 0') ->execute(['r' => $clientTenant['id'], 'm' => $maxId]); } jsonResponse(['messages' => $out, 'max_id' => $maxId]); } // ── GET /api/chat/backfill ───────────────────────────────────── // Scrollback / cold-start backfill for a single thread. Pulls older // messages before a given id, re-encrypted for the caller. Used when // the client opens a room it's never seen, or when the local cache // was wiped on a second device. // ?room_uid=X — group scrollback (membership checked) // ?peer=X — 1:1 scrollback with that peer // &before=N — only messages with id < N (default: latest) // &limit=N — page size (default 100, max 500) if ($route === '/api/chat/backfill' && $method === 'GET') { if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401); if (empty($clientTenant['secret_encrypted'])) { jsonResponse(['error' => 'no client secret set'], 400); } $roomUid = trim((string)($_GET['room_uid'] ?? '')); $peer = trim((string)($_GET['peer'] ?? '')); $before = (int)($_GET['before'] ?? 0); $limit = max(1, min(500, (int)($_GET['limit'] ?? 100))); if ($roomUid === '' && $peer === '') jsonResponse(['error' => 'room_uid or peer required'], 400); $interKey = getInterTenantKey($db, $MASTER_KEY); if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set'], 500); $recipientSecret = masterDecrypt($clientTenant['secret_encrypted'], $MASTER_KEY); if (!$recipientSecret) jsonResponse(['error' => 'recipient secret unavailable'], 500); $beforeClause = $before > 0 ? 'AND id < :before' : ''; if ($roomUid !== '') { $check = $db->prepare('SELECT 1 FROM chat_room_members WHERE room_uid = :r AND tenant_id = :t'); $check->execute(['r' => $roomUid, 't' => $clientTenant['id']]); if (!$check->fetchColumn()) jsonResponse(['error' => 'not a member of this room'], 403); $sql = "SELECT id, sender_id, sender_username, room_uid, body, created_at FROM chat_messages WHERE room_uid = :ru $beforeClause ORDER BY id DESC LIMIT :lim"; $stmt = $db->prepare($sql); $stmt->bindValue(':ru', $roomUid); if ($before > 0) $stmt->bindValue(':before', $before, PDO::PARAM_INT); $stmt->bindValue(':lim', $limit, PDO::PARAM_INT); $stmt->execute(); } else { $peerTenant = getTenantByUsername($db, $peer); if (!$peerTenant) jsonResponse(['error' => 'peer not found'], 404); $sql = "SELECT id, sender_id, sender_username, room_uid, body, created_at FROM chat_messages WHERE room_uid IS NULL AND ( (recipient_id = :me AND sender_id = :peerid) OR (recipient_id = :peerid2 AND sender_id = :me2) ) $beforeClause ORDER BY id DESC LIMIT :lim"; $stmt = $db->prepare($sql); $stmt->bindValue(':me', $clientTenant['id'], PDO::PARAM_INT); $stmt->bindValue(':me2', $clientTenant['id'], PDO::PARAM_INT); $stmt->bindValue(':peerid', $peerTenant['id'], PDO::PARAM_INT); $stmt->bindValue(':peerid2', $peerTenant['id'], PDO::PARAM_INT); if ($before > 0) $stmt->bindValue(':before', $before, PDO::PARAM_INT); $stmt->bindValue(':lim', $limit, PDO::PARAM_INT); $stmt->execute(); } $rows = $stmt->fetchAll(); // Flip back to ASC for a natural render order on the client. $rows = array_reverse($rows); $out = []; foreach ($rows as $row) { $plain = clientDecrypt($row['body'] ?? '', $interKey); $out[] = [ 'id' => (int)$row['id'], 'sender_id' => (int)$row['sender_id'], 'sender_username' => $row['sender_username'], 'room_uid' => $row['room_uid'], 'body' => clientEncrypt($plain, $recipientSecret), 'created_at' => $row['created_at'], 'mine' => (int)$row['sender_id'] === (int)$clientTenant['id'], ]; } jsonResponse(['messages' => $out]); } // ── GET /api/chat/sent ───────────────────────────────────────── // Returns the receipt state (fetched/seen) for messages the caller has // sent. Body is intentionally NOT included — the sender already has the // plaintext locally; the relay only echoes the state flags so the sender // can render check / double-check on the right messages. if ($route === '/api/chat/sent' && $method === 'GET') { if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401); $since = (int)($_GET['since'] ?? 0); // 1:1 only — group messages use the same sender_id but have no // per-recipient receipt state, so including them would stall the // cursor on rows that never reach seen=1. $stmt = $db->prepare(' SELECT id, recipient_id, fetched, seen, created_at FROM chat_messages WHERE sender_id = :s AND room_uid IS NULL AND id > :since ORDER BY id ASC LIMIT 1000 '); $stmt->execute(['s' => $clientTenant['id'], 'since' => $since]); $rows = $stmt->fetchAll(); $maxId = $since; foreach ($rows as $r) { if ((int)$r['id'] > $maxId) $maxId = (int)$r['id']; } jsonResponse(['receipts' => $rows, 'max_id' => $maxId]); } // ── POST /api/chat/seen ──────────────────────────────────────── // Body: { peer: "" }. Marks every message that the // peer sent to the caller as seen=1. Idempotent — safe to call on // every thread open. if ($route === '/api/chat/seen' && $method === 'POST') { if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401); $input = json_decode(file_get_contents('php://input'), true) ?: []; $peer = trim((string)($input['peer'] ?? '')); if ($peer === '') jsonResponse(['error' => 'peer required'], 400); $db->prepare(' UPDATE chat_messages SET seen = 1 WHERE recipient_id = :r AND sender_username = :p AND seen = 0 ')->execute(['r' => $clientTenant['id'], 'p' => $peer]); jsonResponse(['ok' => true]); }