Skip to content

Commit bf3c0fd

Browse files
baozhoutaoclaude
andauthored
fix(app-shell): mark notifications read via the REST surface, not direct receipt writes (#2743)
ADR-0103 made sys_notification_receipt engine-owned (enable.apiMethods = get/list), so the bell's direct dataSource create/update of receipts is rejected by the generic data API. The failure was swallowed (best-effort catch) and the 10s inbox poll flipped rows straight back to unread — notifications could never be marked read. Mark-read now goes through the framework's dedicated endpoints (POST /api/v1/notifications/read with the notification EVENT ids, and /read/all for mark-all), which upsert the receipt server-side. Optimistic UI update unchanged; rows without a notification_id still update optimistically only. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 7a5750e commit bf3c0fd

1 file changed

Lines changed: 24 additions & 26 deletions

File tree

packages/app-shell/src/layout/AppHeader.tsx

Lines changed: 24 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -469,41 +469,39 @@ export function AppHeader({
469469
const unreadCount = notifications.reduce((n, x) => n + (x.is_read ? 0 : 1), 0);
470470

471471
// Read-state lives in `sys_notification_receipt`, keyed
472-
// (notification_id, user_id, channel) — ADR-0030. Marking read UPDATEs the
473-
// existing `delivered` receipt to `read` (the inbox channel always writes one
474-
// on materialization); we INSERT only as a fallback for the rare row whose
475-
// receipt is missing. Rows without a `notification_id` (legacy/synthetic)
476-
// can't be keyed, so they update optimistically but don't persist.
477-
const writeReadReceipt = useCallback(async (n: { notification_id?: string | null; receipt_id?: string | null }, now: string) => {
478-
if (!dataSource || !n.notification_id) return;
479-
if (n.receipt_id) {
480-
await dataSource.update('sys_notification_receipt', n.receipt_id, { state: 'read', at: now });
481-
} else {
482-
await dataSource.create('sys_notification_receipt', {
483-
notification_id: n.notification_id,
484-
user_id: user?.id,
485-
channel: 'inbox',
486-
state: 'read',
487-
at: now,
488-
created_at: now,
489-
});
490-
}
491-
}, [dataSource, user?.id]);
472+
// (notification_id, user_id, channel) — ADR-0030. That object is
473+
// engine-owned (ADR-0103: `enable.apiMethods` = get/list), so the generic
474+
// data API REJECTS receipt writes — the previous direct create/update here
475+
// silently failed and the next poll flipped rows back to unread. Mark-read
476+
// goes through the framework's dedicated REST surface instead
477+
// (`POST /api/v1/notifications/read[/all]`), which upserts the receipt
478+
// server-side keyed by the notification EVENT id. Rows without a
479+
// `notification_id` (legacy/synthetic) can't be keyed, so they update
480+
// optimistically but don't persist.
481+
const postMarkRead = useCallback(async (subPath: 'read' | 'read/all', ids?: string[]) => {
482+
const serverUrl = (import.meta.env?.VITE_SERVER_URL || '').replace(/\/$/, '');
483+
await fetch(`${serverUrl}/api/v1/notifications/${subPath}`, {
484+
method: 'POST',
485+
credentials: 'include',
486+
// Bearer too — see utils/authToken (#2548 split-origin fix).
487+
headers: { 'Content-Type': 'application/json', ...bearerAuthHeaders() },
488+
body: JSON.stringify(ids ? { ids } : {}),
489+
});
490+
}, []);
492491

493492
const markNotificationRead = useCallback(async (id: string) => {
494493
const target = notifications.find(n => n.id === id);
495494
setNotifications(prev => prev.map(n => n.id === id ? { ...n, is_read: true } : n));
496-
if (!target) return;
497-
try { await writeReadReceipt(target, new Date().toISOString()); } catch { /* best-effort */ }
498-
}, [notifications, writeReadReceipt]);
495+
if (!target?.notification_id) return;
496+
try { await postMarkRead('read', [target.notification_id]); } catch { /* best-effort */ }
497+
}, [notifications, postMarkRead]);
499498

500499
const markAllRead = useCallback(async () => {
501500
const unread = notifications.filter(n => !n.is_read);
502501
if (!unread.length) return;
503502
setNotifications(prev => prev.map(n => ({ ...n, is_read: true })));
504-
const now = new Date().toISOString();
505-
await Promise.all(unread.map(n => writeReadReceipt(n, now).catch(() => {})));
506-
}, [notifications, writeReadReceipt]);
503+
try { await postMarkRead('read/all'); } catch { /* best-effort */ }
504+
}, [notifications, postMarkRead]);
507505

508506
const tenantPresence = useTenantPresence();
509507
const activeUsers = presenceUsers ?? (tenantPresence.length > 0 ? tenantPresence : EMPTY_PRESENCE_USERS);

0 commit comments

Comments
 (0)