CODEX // oaoisme wiki

§ unswayed-backend · Platform infrastructure (Phase 1)

Notifications (§23)

updated 2026-06-04

Notifications are the only product feature Phase 1 ships. The surface is frozen by endpoint_documentation.md §23, so the bytes on the wire match the legacy system exactly; only the internals improve.

The read API

All routes are user-scoped and bearer-authenticated.

  • GET /api/notifications?page=1&limit=10{ notifications: [NotificationResource], unread_count, pagination: { current_page, last_page, per_page, total } }, ordered newest-first. The legacy doc showed a bare array; we keep the array and add the pagination block (the legacy query paginated, and every other list endpoint carries one). Paginated lists always include it.
  • GET /api/notifications/count{ count } (unread).
  • GET /api/notification/{id}/read → marks read, returns { notification }; 404 "Notification not found!" for a missing or cross-user id. This is a state-mutating GET — the verb is frozen by the contract, so it stays GET; we mitigate prefetch with Cache-Control: no-store.

NotificationResource is { id, user_id, type, title, description, data (a decoded object, never a string), is_read, updated_at, created_at } — and never emits a message field (a subtle legacy fact the resource mapper enforces).

The write side — one send() everywhere

NotificationsService.send({ userId, type, title, description, data }) is the single use-case every domain calls to raise a notification:

  1. Persist a Notification row (data stored as a Prisma Json object — not JSON.stringify'd, fixing a legacy double-encoding smell; SQL NULL via Prisma.DbNull).
  2. Broadcast the realtime events (NotificationReceived, NotifyUser) — best-effort, via the injected RealtimeBroadcaster (a no-op when realtime is off).
  3. Conditionally push via FCM — gated by NotificationPreference.isEnable (absent row ⇒ on) and the user having at least one device token.

Broadcast and push are best-effort (logged, never thrown) so a transport hiccup never loses the persisted row; the persist itself must succeed. The send-job-alert queue handler is the first consumer: it bridges a job event into send(... 'job_alert', 'Job Alert', '…', { job_id }) with no email.

Preferences

NotificationPreference (per user: isEnable, jobRelatedEmails, both default true) replaces the legacy json-blob UserSetting(type='notification'). It gates push (here) and the job-match email (in the mailer). Absence of a row means "enabled" — matching legacy behavior.

Why it's split read/write

The read service is pure query logic; the write service owns the fan-out and the side-effects. Keeping them apart keeps each trivially testable (the write side is exercised against fake PushPort / RealtimeBroadcaster / Prisma), and lets later domains reuse send() without dragging in the read controller.