sync-event toasts, image pull ordering, stale banner field diff

- replace chat-bot sync notifications with a dedicated relay_sync_events
  queue: POST/GET /api/sync-events, drained by /api/relay/poll and
  surfaced as stacking toasts via caldav.js. Old /api/chat/bot removed,
  bot=1 rows purged on bootstrap, orange bot rendering dropped from
  relay UI. Chat thread stays clean.
- collab pull: fetch attachments BEFORE applying the body so the moment
  state.cards reflects the new reference the file is on disk. Fixes the
  "preview shows broken/missing image, editor shows it" race where the
  browser cached the 404 between attachment landing and user re-render.
- detail stale banner now compares title/body/color/tags in addition to
  updated_at — robust against second-granularity timestamp collisions
  and any other case where updated_at doesn't tick but content differs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
thandClaude Opus 4.6 committed 2026-04-14 23:00:30 +02:00
1 parent 26f95ba079
commit 1966f8f4c0
10 files changed
+170 -75

No files matched your search

+37 -13
View File
@@ -62,7 +62,7 @@ if ($route === '/api/chat/history' && $method === 'GET' && $sessionTenant) {
if (!$peerTenant) jsonResponse(['error' => 'unknown peer'], 404);
$peerId = $peerTenant['id'];
$stmt = $db->prepare('
SELECT id, sender_id, sender_username, body, created_at, seen, bot
SELECT id, sender_id, sender_username, body, created_at, seen
FROM chat_messages
WHERE (recipient_id = :r AND sender_id = :p) OR (recipient_id = :p2 AND sender_id = :s)
ORDER BY created_at ASC LIMIT 500
@@ -78,7 +78,6 @@ if ($route === '/api/chat/history' && $method === 'GET' && $sessionTenant) {
'body' => clientDecrypt($row['body'] ?? '', $interKey),
'created_at' => $row['created_at'],
'seen' => (int)$row['seen'],
'bot' => (int)($row['bot'] ?? 0),
];
}
// Mark incoming as seen
@@ -107,28 +106,53 @@ if ($route === '/api/chat/send' && $method === 'POST' && $sessionTenant) {
$clientTenant = $clientTenant ?? authClient($db);
// Bot notification: client announces "I synced a card" to the sender.
// No encryption — body is a short system-generated string like
// "alice synced: Card Title". bot=1 flags it so the UI renders in
// the orange accent color.
if ($route === '/api/chat/bot' && $method === 'POST') {
// 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]); // no self-notifications
if ($recipientName === $clientTenant['username']) jsonResponse(['ok' => true, 'id' => 0]); // suppress self-notifications
$recipient = getTenantByUsername($db, $recipientName);
if (!$recipient) jsonResponse(['error' => "no such user: $recipientName"], 404);
$interKey = getInterTenantKey($db, $MASTER_KEY);
if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set'], 500);
$stored = clientEncrypt($body, $interKey);
$db->prepare('INSERT INTO chat_messages (recipient_id, sender_id, sender_username, body, bot) VALUES (:r, :s, :sn, :b, 1)')
->execute(['r' => $recipient['id'], 's' => $clientTenant['id'], 'sn' => $clientTenant['username'], 'b' => $stored]);
$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]);
}
if ($route === '/api/chat' && $method === 'POST') {
if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401);