Skip to content

moooon

Motir

Vibe your whole project. Bring an idea — Motir's three AI layers plan it, track it, and ship it, end to end. You're looking at Motir, built in Motir.

  • Vibe Project
  • Open Source
  • AI Agent
  • AI Loop
1
requests
0
upvotes
145
planned
1,361
shipped

Motir · Work items

MOTIR-316Done

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

  • notificationsService ships 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).
  • listNotifications is cursor-paged (take 20) + totalCount + unreadCount, category-filterable (direct/watching); no unbounded read exists on any path.
  • markRead is idempotent and returns the fresh unreadCount; markAllRead is 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:coverage keeps 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