- Status: done
- Date: 2026-05-29
- Specs touched:
NOTIFICATIONS.md(read-state implementation note — uniformnotification_readsjoin; no decision flipped)
The read half of the bell. health-notifications.md landed the write seam (the notifications table + the health raise/clear emitter) but nothing read it back over the wire. This slice adds the /api/v1/notifications API family, the notification_reads join that makes read/dismiss state per-recipient, and the SSE notification.created / notification.updated kinds so the dashboard bell can update live. Backend only — the Vue bell is still deferred.
migrate() gains a notification_reads(notification_id, user_id, read_at, dismissed_at, PRIMARY KEY (notification_id, user_id)) table, both foreign keys ON DELETE CASCADE. Read/dismiss state for every recipient lives here uniformly — including audience: user rows, which take the same join rather than a per-row fast path. The decision (over the spec's sketched row-column shortcut for audience: user) is one code path instead of two; the read query joins regardless, so the shortcut bought nothing. The row-level read_at / dismissed_at columns on notifications (created in 0025) stay reserved/unused; row dismissed_at remains only the coalescing-index marker. NOTIFICATIONS.md # Read / unread / dismiss is updated to describe the uniform join.
NotificationFilter{UserID, IsAdmin, AfterID, Limit, IncludeDismissed}andnotificationVisibilityClause(isAdmin)— the SQL predicate that scopes rows to a recipient (NOTIFICATIONS.md# Routing): an admin seesaudience='admins'plus their ownaudience='user'rows; a member sees only their own. Each branch binds exactly one parameter (the caller's user id), mirroringlistAudit's member-vs-admin pattern.ListNotificationsForRecipient(f)— newest-first, audience-scoped, with this caller'sread_at/dismissed_atLEFT JOINed in; excludes dismissed rows unlessIncludeDismissed; cursor byid < AfterID.scanNotificationreads the 17-column row shape (notification columns + the joined per-recipient state).CountUnreadNotifications(userID, isAdmin)— the bell badge: visible rows where the join'sread_at IS NULL AND dismissed_at IS NULL.GetNotification(id)— single-row fetch (→ErrNotFound), used by the per-id mutating handlers to confirm visibility before recording state.MarkNotificationRead/DismissNotification— UPSERT intonotification_readswithON CONFLICT … DO UPDATE SET … = COALESCE(existing, excluded), so a repeat call preserves the first-read / first-dismiss timestamp (idempotent). Per-recipient: one admin dismissing a box-wide notice doesn't dismiss it for another.MarkAllNotificationsRead(userID, isAdmin, at)— oneINSERT … SELECT … ON CONFLICT DO UPDATEover every still-unread visible row.- Re-raise re-surfaces unread.
RaiseNotification's coalesce branch nowDELETEsnotification_readsfor the active row, so a genuine cleared→active flap clears per-recipient read/dismiss state and the badge re-lights (NOTIFICATIONS.md# One notification per raise: "while unread").
registerNotifications (wired into Handler() after registerHealth):
GET /api/v1/notifications— the caller's inbox, newest-first, cursor (limitdefault 50 / capmaxNotificationLimit=100,after_id).GET /api/v1/notifications/unread-count— the badge.POST /api/v1/notifications/{id}/read— mark one read (204).POST /api/v1/notifications/read-all— mark all visible read (204).POST /api/v1/notifications/{id}/dismiss— drop one from the active inbox (204).
Not admin-gated like /health — every authenticated user sees the notifications addressed to them (admins also see box-wide ones). NotificationDTO folds this caller's read state into a read bool and exposes only what the client renders — routing fields (audience, variant, user_id) and source identifiers stay server-side. The shared notificationRecipient guard answers 404 (never 403) for both a missing id and a row the caller can't see, so the inbox leaks nothing about which ids exist or who else they address. Mutations publish events.NotificationUpdated onto the bus.
- New
events.Kindsnotification.created/notification.updated(BRAIN_UI_PROTOCOL.md). notifygains a consumer-sidePublisherinterface (Publish(kind, data));events.Busimplements it.New(store, pub)takes it (nil disables emission — the bell is a floor, not a gate).HealthRaisedpublishesnotification.created(advisory payload:dedup_key,category,severity) after a successful raise;HealthClearedpublishesnotification.updated(dedup_key) after resolve.cmd/brainpasses the existing bus:notify.New(st, bus).- The SSE payload is an advisory refetch trigger, not a data channel — the global
/api/v1/eventsbus is unfiltered, so the client re-reads its own audience-scoped list on the nudge rather than receiving notification bodies over a shared bus (WEB_UI.md: SSE is a cache-invalidation channel). This is what keeps per-recipient scoping correct without per-subscriber bus filtering.
internal/store(extendsnotify_test.go): audience scoping (admin sees admins + own, not another member's; member sees only own; unrelated sees none), excludes-dismissed, cursor, unread count, mark-read preserves first-read timestamp, per-recipient dismiss, mark-all-read, re-raise clears read state,GetNotificationnot-found.internal/api(newnotifications_test.go): the full surface behind the auth fence returns 401 unauthenticated; audience scoping over the wire (admins-audience reaches every admin but no member; user-audience reaches only its owner, not other members, not admins); mark-read drops the badge and flipsreadwhile keeping the row; dismiss removes it from the active inbox; mark-all-read zeroes the count; 404 (not 403) for a missing id, a foreign user row, and a member reaching an admins-audience row;limitquery binding honored.internal/notify(extendsnotify_test.go): afakePublisherassertsnotification.createdon raise andnotification.updatedon clear, and no publish when the store errors or the issue isn't allowlisted.
NOTIFICATIONS.md# Read / unread / dismiss: per-recipient read state via thenotification_readsjoin; unread badge; dismiss ≠ resolve (the underlying condition stays).NOTIFICATIONS.md# Routing: the list/count visibility predicate is audience + ownership — box-wide → admins, personal → owner.NOTIFICATIONS.md# Surfaces: live updates over the existing global SSE channel; the newnotification.created/notification.updatedkinds.NOTIFICATIONS.md# One notification per raise: a re-raise re-surfaces unread (read state cleared on coalesce).BRAIN_UI_PROTOCOL.md: the/api/v1/notificationsendpoint family and the two SSE kinds.- CLAUDE.md # Go code discipline: consumer-side
Publisherinterface (innotify, notevents); the layer-boundary rule (api→store);log/slogonly; tests in-package. - CLAUDE.md # Elevation-class mutations audit: mark-read / dismiss are per-user view-state toggles, not principal/app mutations, so they are deliberately not audited (pure-read-class, like the bell badge itself).
- Backend only — no Web UI. No bell, dropdown inbox, Pinia store, or
useNotifications()composable yet (WEB_UI.md). The API + SSE this slice ships is what that UI will consume. - No per-category mute.
GET/mark/dismiss exist; the per-user, per-category mute (NOTIFICATIONS.md# Configuration) is deferred — it's a preferences surface, orthogonal to read state. - Member transparency variant still deferred. Box-wide criticals notify admins only; the info-only member
transparencycopy (NOTIFICATIONS.md# Member transparency variant) hasn't landed (carried over from 0025).AudienceUseris now exercised by tests but no producer emits it yet. - No "all clear" resolved notification. Clear still marks the original resolved without the brief info "Data drive reconnected" follow-up (
NOTIFICATIONS.md# Clears) — carried over from 0025. - SSE payload is advisory, not a stream of bodies. Clients refetch on the nudge. There's no replay buffer and no per-stream cap on the bus (
BRAIN_UI_PROTOCOL.mddefers both); a client connecting after an event missed the nudge re-syncs on its next poll / reconnect. - No retention/pruning. Carried over from 0025 — the capped-count / age policy (
NEXT.md) is still unimplemented;notificationsandnotification_readsrows accumulate.
- Web UI. Bell + dropdown inbox in the chrome, Pinia store, SSE subscription on the two new kinds,
useNotifications()(WEB_UI.md). - Member transparency variant + "all clear." Emit the info-only member copy for box-blocking criticals and the resolved follow-up on clear (
NOTIFICATIONS.md). - Per-category mute. Per-user mute preferences; everything on by default (
NOTIFICATIONS.md# Configuration). - Retention/pruning. Capped-count / age policy for both tables (
NEXT.md).