§ unswayed-backend · Platform infrastructure (Phase 1)
Notifications (§23)
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 staysGET; we mitigate prefetch withCache-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:
- Persist a
Notificationrow (datastored as a PrismaJsonobject — notJSON.stringify'd, fixing a legacy double-encoding smell; SQLNULLviaPrisma.DbNull). - Broadcast the realtime events (
NotificationReceived,NotifyUser) — best-effort, via the injectedRealtimeBroadcaster(a no-op when realtime is off). - 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.