diff --git a/public/plugins/chat/backend.php b/public/plugins/chat/backend.php index 9965465..9153e50 100644 --- a/public/plugins/chat/backend.php +++ b/public/plugins/chat/backend.php @@ -530,7 +530,8 @@ PluginManager::instance()->onApiRoute(function (string $route, string $method) { // Client-generated UID, same convention as collab buckets. $uid = bin2hex(random_bytes(8)); - $ch = curl_init(rtrim($r['url'], '/') . '/api/chat/rooms'); + $relayUrl = rtrim($r['url'], '/') . '/api/chat/rooms'; + $ch = curl_init($relayUrl); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, @@ -547,7 +548,14 @@ PluginManager::instance()->onApiRoute(function (string $route, string $method) { curl_close($ch); if ($status !== 200) { $err = json_decode($response, true); - chatJson(['ok' => false, 'error' => $err['error'] ?? "relay HTTP $status"]); + $detail = $err['error'] ?? "relay HTTP $status"; + // 404 on this path almost always means the relay is running a + // pre-group-chat build. Surface that explicitly so the user + // doesn't go hunting for data issues. + if ($status === 404) { + $detail = "relay does not know /api/chat/rooms — it's likely running old code. Restart the relay after pulling the latest commits."; + } + chatJson(['ok' => false, 'error' => $detail, 'url' => $relayUrl, 'relay_status' => $status]); } $data = json_decode($response, true) ?: []; // Mirror locally right away so the next conversations list shows diff --git a/relay/inc/routes/chat.php b/relay/inc/routes/chat.php index 3f11c29..2728ea3 100644 --- a/relay/inc/routes/chat.php +++ b/relay/inc/routes/chat.php @@ -295,14 +295,15 @@ if ($route === '/api/chat' && $method === 'POST') { $stored = clientEncrypt($plain, $interKey); if ($roomUid !== '') { - // Group message: verify membership, single row with room_uid set - // and recipient_id=0 (sentinel — fan-out happens on GET via the - // chat_room_members join). + // 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 (0, :s, :sn, :ru, :b, 1)') - ->execute(['s' => $clientTenant['id'], 'sn' => $clientTenant['username'], 'ru' => $roomUid, 'b' => $stored]); + $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' => "no such user: $recipientName"], 404); @@ -463,10 +464,13 @@ if ($route === '/api/chat/backfill' && $method === 'GET') { 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 id > :since + WHERE sender_id = :s AND room_uid IS NULL AND id > :since ORDER BY id ASC LIMIT 1000 ');