5.7.4 `notificationsService` + routes — cursor-paged feed, unread count, mark-read, mark-all-read (4-layer, per-user scoped)
Estimate: 30m · Depends on: 5.7.2
The READ + mark-state API the bell/drawer (5.7.5) calls. Per the 4-layer rule: lib/services/notificationsService.ts owning scoping, paging, transactions, DTO mapping, and typed errors (lib/notifications/errors.ts); HTTP-only routes; the repository from 5.7.2. Every method is scoped to the session user — a notification belongs to exactly one recipient, and a user can only read/mutate their OWN (cross-user access reads as 404, finding #44 — never leak existence).
listNotifications({ cursor?, category? }, ctx) — cursor-paged from the most recent (take 20) with a totalCount + the unreadCount, filtered to the session user, optionally narrowed to direct | watching (the drawer tabs). NEVER a load-all (finding #57). DTOs carry actor (id/name/image), the rendered summary from data, the deep-link target (issue key), category, readAt, createdAt.
getUnreadCount(ctx) — the cheap aggregate (the 5.7.2 partial-index count) the bell badge polls; a single fast query, never a row fetch.
markRead(notificationId, ctx) — sets readAt on ONE notification the caller owns (idempotent — already-read is a no-op), returns the updated row + the new unreadCount (so the caller updates the badge from the RESPONSE, not a re-fetch — the inline-edit-no-tree-refresh contract). markAllRead(ctx) — one bulk updateMany over the caller's unread set (the 5.7.2 method), returns the new unreadCount (zero). NOT a per-row client loop (the JRACLOUD-85017 anti-pattern).
Routes (HTTP-only): GET /api/notifications (list + counts), GET /api/notifications/unread-count (the badge poll), PATCH /api/notifications/[id]/read, POST /api/notifications/mark-all-read — parse → one service call → typed-error→status mapping (404 on a notification the caller doesn't own, per finding #44 — never leak existence).
Acceptance criteria
notificationsServiceships list / unread-count / mark-read / mark-all-read, every method scoped to the session user; reading or marking another user's notification reads as 404 (finding #44).listNotificationsis cursor-paged (take 20) +totalCount+unreadCount, category-filterable (direct/watching); no unbounded read exists on any path.markReadis idempotent and returns the freshunreadCount;markAllReadis a single bulk update returning zero — both return the count so the UI updates from the response (no tree re-fetch).- Routes are HTTP-only (no
db.*/$transaction);pnpm test:coveragekeeps the new service/repo files ≥90% branch/fn/line with direct empty-input guards (the coverage gate).
Context refs
- 5.7.2
notificationRepository(the paged list + partial-index count + bulk mark-all);motir-core/CLAUDE.md— the 4-layer contract, one-method-one-transaction, the finding-#26 workspace gate + the finding-#44 404-not-403 rule lib/services/commentsService.ts(5.1.2) — the cursor-paged read + typed-error service conventions to mirror- The inline-edit-no-whole-tree-refresh memory (the mutation returns the new count; the UI does not re-fetch the tree)
- Story 5.7 description — the badge-poll vs feed-read split + the mark-all-as-one-op decision