diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 4fc40f8..f0f5b7d 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -296,8 +296,16 @@ Design note: kept separate from `Prescription`/`LabOrder` so a facility's operat ### 4.8 Notifications ``` Notification - id, user_id, type, payload (jsonb), read_at (nullable), created_at + id, user_id (FK -> User), type (typed catalogue), payload (jsonb), + channel (in_app|email|sms), read_at (nullable), created_at ``` +The in-app inbox is owner-scoped and keyset-paginated by `(created_at, id)`. +Payload schemas contain opaque entity identifiers only; clinical details, +intake, consultation notes, and results must never be copied into a notification +payload. `notify()` persists the in-app row before calling registered channel +adapters, and adapter failures are isolated from that write. Email/SMS providers +remain undecided until issue #17; adapters are registered behind the module's +service interface. ### 4.9 Auth hardening state (Phase 8) ``` diff --git a/ARCHITECTURE_ESSENTIALS.md b/ARCHITECTURE_ESSENTIALS.md index 36475c3..697816a 100644 --- a/ARCHITECTURE_ESSENTIALS.md +++ b/ARCHITECTURE_ESSENTIALS.md @@ -28,6 +28,7 @@ Quick-reference only. Full reasoning lives in `ARCHITECTURE.md`. Full product sc `FulfillmentRecord` (facility-owned) references an order but doesn't mutate it. `User` → `EmailVerificationToken` (Phase 8, issue #34 — hashed/expiring, single-use; `User.emailVerifiedAt` gates booking, consultation/referral mutations and payment mutations, never login/browsing). `User` → `AccessLog` (Phase 8, issue #45 — PHI read-access log: who viewed which clinical record, when; fire-and-forget writes, fail-open). +`User` → `Notification` (Phase 6, issue #93 — typed, owner-scoped in-app inbox; payloads contain identifiers, not clinical detail). `LoginAttempt` (Phase 8, issue #35 — Postgres-backed login throttle buckets, one per normalised email and per client IP; counters move only via a conditional upsert). `PatientProfile` → `PatientExport` → `PatientExportChunk` (Phase 8, issue #40 — right-of-access export jobs + their chunked JSON bundle; each module contributes a section via its own service, registered in `src/export-sections.ts` — see ARCHITECTURE.md §3.1). `FacilityProfile` → `WalletLink` (payout address) ; `Prescription` or `LabOrder` → exactly one typed `PaymentIntent` FK (issue #85) diff --git a/ROADMAP.md b/ROADMAP.md index faa6814..9aa375f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -100,10 +100,13 @@ roadmap. See each phase section below for status and issue links. - [ ] Doc reconciliation: PRD §8.1.6 (facility fulfillment integrity) — not marked resolved; this phase proves the payment *mechanism* works, not the fulfillment-integrity question, which still needs real `FulfillmentRecord`/facility-matching (Phase 5) to actually address. ## Phase 6 — Notifications -**Status: Not started. Provider decision scoped into issue #17; trigger-point build-out blocked by Phases 3 & 4 and deliberately left unscoped until those land.** -- [ ] `Notification` model + dispatch interface (provider-agnostic, per ARCHITECTURE.md §2) — part of the trigger-point work, still blocked. +**Status: In progress. The owner-scoped in-app inbox and provider-agnostic dispatch interface are implemented; booking outbox delivery is tracked in #94. Email/SMS provider work remains blocked on issue #17's provider decision.** +- [x] `Notification` model + dispatch interface (provider-agnostic, per ARCHITECTURE.md §2), plus authenticated in-app inbox API — issue #93. - [ ] Trigger points per PRD FR-15/FR-16 (booking confirmed, referral created/accepted/declined, order ready, results available, new booking, lab results returned) — still blocked by Phases 3/4. +- [ ] Transactional outbox for domain events, starting with booking confirmed — [issue #94](https://github.com/DocForum/docforum-core/issues/94). - [ ] Pick and integrate a real provider (email/SMS) behind the interface — provider choice is currently undecided (ARCHITECTURE.md §8). Tracked as [issue #17](https://github.com/DocForum/docforum-core/issues/17) (Trivial, 100 pts) — a decision task, unblocked now. +- [ ] Email adapter, templates, and signed bounce/complaint webhook — [issue #95](https://github.com/DocForum/docforum-core/issues/95); depends on #17 and #93. +- [ ] SMS adapter, delivery callbacks, consent/opt-out, and send cap — [issue #96](https://github.com/DocForum/docforum-core/issues/96); depends on #17 and #93. ## Phase 7 — Frontend: core flows end-to-end **Status: Superseded.** This phase predates the 3-repo org split (see the org note at the top of this file) — all frontend work described below is actually tracked in `docforum-web`'s own `ROADMAP.md` (Phases W2–W5, issues opened there). Left in place as a historical record rather than deleted, but no issues are or will be opened against it here. diff --git a/backend/prisma/migrations/20261019201307_add_notifications/migration.sql b/backend/prisma/migrations/20261019201307_add_notifications/migration.sql new file mode 100644 index 0000000..e8ab486 --- /dev/null +++ b/backend/prisma/migrations/20261019201307_add_notifications/migration.sql @@ -0,0 +1,16 @@ +-- CreateEnum +CREATE TYPE "NotificationChannel" AS ENUM ('in_app', 'email', 'sms'); + +-- AlterTable +ALTER TABLE "Notification" ADD COLUMN "channel" "NotificationChannel" NOT NULL DEFAULT 'in_app', +ADD COLUMN "userId" TEXT NOT NULL; + +-- CreateIndex +CREATE INDEX "Notification_userId_createdAt_id_idx" ON "Notification"("userId", "createdAt", "id"); + +-- CreateIndex +CREATE INDEX "Notification_userId_readAt_createdAt_id_idx" ON "Notification"("userId", "readAt", "createdAt", "id"); + +-- AddForeignKey +ALTER TABLE "Notification" ADD CONSTRAINT "Notification_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index f19852e..bc2d17a 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -26,6 +26,12 @@ enum Role { admin } +enum NotificationChannel { + in_app + email + sms +} + enum VerificationStatus { pending verified @@ -148,6 +154,7 @@ model User { // Phase 8 (issue #45): PHI read-access log rows this user wrote (as viewer). // Opposite of AccessLog.accessedByUser. accessLogs AccessLog[] + notifications Notification[] @@index([role]) } @@ -724,12 +731,18 @@ model FulfillmentRecord { } model Notification { - id String @id @default(uuid()) + id String @id @default(uuid()) + userId String type String payload Json + channel NotificationChannel @default(in_app) readAt DateTime? - createdAt DateTime @default(now()) - // TODO: userId + createdAt DateTime @default(now()) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId, createdAt, id]) + @@index([userId, readAt, createdAt, id]) } // --- Stellar payments (docforum-org split) ------------------------------- diff --git a/backend/src/app.ts b/backend/src/app.ts index 97926f4..0b11362 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -16,6 +16,7 @@ import { registerPatientExportSections } from './export-sections'; import { auditRouter, adminAuditRouter, patientAuditRouter } from './modules/audit/routes'; import { escrowEventsRouter } from './modules/payments/routes/escrow-events'; import { requestId } from './middleware/request-id'; +import { notificationsRouter } from './modules/notifications/routes'; import { referralsRouter } from './modules/referrals/routes'; export function createApp() { @@ -59,6 +60,7 @@ export function createApp() { // Phase 8, issue #39 — admin account lifecycle (invites, deactivate/ // reactivate) alongside the audit read path. Different paths, no overlap. app.use('/admin', adminRouter); + app.use('/notifications', notificationsRouter); app.use('/admin', auditRouter); app.use('/admin/payments', escrowEventsRouter); diff --git a/backend/src/modules/notifications/controllers/index.ts b/backend/src/modules/notifications/controllers/index.ts index f874ede..4267137 100644 --- a/backend/src/modules/notifications/controllers/index.ts +++ b/backend/src/modules/notifications/controllers/index.ts @@ -1,2 +1,41 @@ -// notifications controllers — placeholder. -export {}; +import type { Request, Response } from 'express'; +import { z } from 'zod'; +import { HttpError } from '../../../http-error'; +import { getParam } from '../../../request-params'; +import * as notificationsService from '../services'; + +const listQuerySchema = z.object({ + unread: z.enum(['true', 'false']).default('false'), + limit: z.coerce.number().int().min(1).max(100).default(50), + cursor: z.string().min(1).max(256).optional(), +}).strict(); + +function getUserId(req: Request): string { + if (!req.user) throw new HttpError(401, 'Authentication required.'); + return req.user.id; +} + +export async function list(req: Request, res: Response) { + const query = listQuerySchema.parse(req.query); + const result = await notificationsService.listMyNotifications(getUserId(req), { + unreadOnly: query.unread === 'true', + limit: query.limit, + cursor: query.cursor, + }); + res.status(200).json(result); +} + +export async function unreadCount(req: Request, res: Response) { + const count = await notificationsService.getUnreadCount(getUserId(req)); + res.status(200).json({ count }); +} + +export async function markRead(req: Request, res: Response) { + const notification = await notificationsService.markMyNotificationRead(getUserId(req), getParam(req, 'id')); + res.status(200).json(notification); +} + +export async function markAllRead(req: Request, res: Response) { + const result = await notificationsService.markAllMyNotificationsRead(getUserId(req)); + res.status(200).json({ updated: result.count }); +} diff --git a/backend/src/modules/notifications/repositories/index.ts b/backend/src/modules/notifications/repositories/index.ts index 30c17f2..89a0405 100644 --- a/backend/src/modules/notifications/repositories/index.ts +++ b/backend/src/modules/notifications/repositories/index.ts @@ -1,10 +1,55 @@ -// notifications repositories — placeholder. Data access only. Not to be -// imported by other modules directly — go through services/ instead. -// -// No persistence yet on purpose: the `Notification` model in schema.prisma -// is still the Phase 0 placeholder (no userId column, ARCHITECTURE.md §4.8), -// and the in-app notification feed is Phase 6 — which is blocked on the -// trigger points in Phases 3/4 (ROADMAP.md). Outbound *delivery* (including -// the password reset link, #33) does not need that model, so it goes -// through services/ without a DB row rather than inventing one here. -export {}; +import type { Prisma } from '@prisma/client'; +import { prisma } from '../../../db/prisma-client'; + +type DbClient = typeof prisma | Prisma.TransactionClient; + +export function createNotification( + userId: string, + type: string, + payload: Prisma.InputJsonValue, + db: DbClient = prisma, +) { + return db.notification.create({ data: { userId, type, payload, channel: 'in_app' } }); +} + +export function listNotifications( + userId: string, + options: { unreadOnly: boolean; limit: number; cursor?: { createdAt: Date; id: string } }, +) { + return prisma.notification.findMany({ + where: { + userId, + ...(options.unreadOnly ? { readAt: null } : {}), + ...(options.cursor + ? { + OR: [ + { createdAt: { lt: options.cursor.createdAt } }, + { createdAt: options.cursor.createdAt, id: { lt: options.cursor.id } }, + ], + } + : {}), + }, + orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], + take: options.limit + 1, + }); +} + +export function countUnread(userId: string) { + return prisma.notification.count({ where: { userId, readAt: null } }); +} + +export async function markNotificationRead(userId: string, id: string) { + const updated = await prisma.notification.updateMany({ + where: { id, userId, readAt: null }, + data: { readAt: new Date() }, + }); + if (updated.count > 0) return prisma.notification.findUniqueOrThrow({ where: { id } }); + return prisma.notification.findFirst({ where: { id, userId } }); +} + +export function markAllNotificationsRead(userId: string) { + return prisma.notification.updateMany({ + where: { userId, readAt: null }, + data: { readAt: new Date() }, + }); +} diff --git a/backend/src/modules/notifications/routes/index.ts b/backend/src/modules/notifications/routes/index.ts index b73122d..482fd06 100644 --- a/backend/src/modules/notifications/routes/index.ts +++ b/backend/src/modules/notifications/routes/index.ts @@ -1,3 +1,11 @@ -// notifications routes — placeholder. See PRD.md / ARCHITECTURE.md §3 for this module's scope. -// TODO(Phase: see ROADMAP.md): define routes for notifications. -export {}; +import { Router } from 'express'; +import { authenticate } from '../../../middleware/auth'; +import * as notificationsController from '../controllers'; + +export const notificationsRouter = Router(); + +notificationsRouter.use(authenticate); +notificationsRouter.get('/', notificationsController.list); +notificationsRouter.get('/unread-count', notificationsController.unreadCount); +notificationsRouter.post('/read-all', notificationsController.markAllRead); +notificationsRouter.post('/:id/read', notificationsController.markRead); diff --git a/backend/src/modules/notifications/services/index.ts b/backend/src/modules/notifications/services/index.ts index 3957781..59d72dd 100644 --- a/backend/src/modules/notifications/services/index.ts +++ b/backend/src/modules/notifications/services/index.ts @@ -1,17 +1,14 @@ // notifications services — the only exported surface other modules may // call. Other modules must only import from here, never from // repositories/ directly. See AGENTS.md "Non-negotiable rules". -// -// Provider-agnostic by design (ARCHITECTURE.md §1.5 / §2, PRD OQ / issue -// #17): outbound delivery goes through a `NotificationChannel` interface, -// and the default implementation is a console adapter. Picking a real -// email/SMS provider is issue #17 and deliberately does NOT block the -// features that need to send a message — only the adapter changes. +import type { Prisma } from '@prisma/client'; +import { z } from 'zod'; import { config } from '../../../config'; +import { HttpError } from '../../../http-error'; import { formatMessage, resolveLocale } from '../../../i18n'; +import * as notificationsRepo from '../repositories'; export interface NotificationMessage { - /** Destination address — an email for now (the only channel interface we need). */ to: string; subject: string; body: string; @@ -21,34 +18,24 @@ export interface NotificationChannel { send(message: NotificationMessage): Promise; } -/** Dev/default adapter. Writes the message to stdout instead of sending - * it anywhere — the only way to read a reset link in local dev until a - * real provider is wired in (#17). Never logs a password, only the - * single-use link (whose token is useless once used/expired). */ export const consoleNotificationChannel: NotificationChannel = { - async send(message: NotificationMessage) { - // eslint-disable-next-line no-console + async send(message) { console.log(`[notifications] → ${message.to}\n${message.subject}\n${message.body}`); }, }; let channel: NotificationChannel = consoleNotificationChannel; -/** Swap the delivery adapter (real provider in prod, a spy in tests). - * Returns the previous channel so callers can restore it. */ export function setNotificationChannel(next: NotificationChannel): NotificationChannel { const previous = channel; channel = next; return previous; } -export async function send(message: NotificationMessage): Promise { - await channel.send(message); +export function send(message: NotificationMessage): Promise { + return channel.send(message); } -/** Single-use, expiring reset link. The raw token is passed through to - * the channel and never stored — only its hash is (auth module's - * repository). */ export async function sendPasswordResetLink(input: { email: string; token: string; @@ -64,3 +51,113 @@ export async function sendPasswordResetLink(input: { body: formatMessage(locale, 'notifications.passwordReset.body', { minutes, link }), }); } + +const uuid = z.string().uuid(); + +export const notificationPayloadSchemas = { + booking_confirmed: z.object({ appointmentId: uuid }).strict(), + new_booking: z.object({ appointmentId: uuid }).strict(), + referral_created: z.object({ referralId: uuid }).strict(), + referral_updated: z.object({ referralId: uuid }).strict(), + order_ready: z.object({ orderId: uuid, orderType: z.enum(['prescription', 'lab_order']) }).strict(), + results_available: z.object({ labOrderId: uuid }).strict(), +} as const; + +export type NotificationType = keyof typeof notificationPayloadSchemas; +export type NotificationPayload = z.infer<(typeof notificationPayloadSchemas)[T]>; +export type NotificationDispatch = { + notificationId: string; + userId: string; + type: NotificationType; + payload: NotificationPayload; +}; + +export interface NotificationChannelAdapter { + channel: 'email' | 'sms'; + send(notification: NotificationDispatch): Promise; +} + +const adapters = new Set(); + +export function registerChannelAdapter(adapter: NotificationChannelAdapter): () => void { + adapters.add(adapter); + return () => adapters.delete(adapter); +} + +export async function notify(userId: string, type: T, payload: unknown) { + const parsed = notificationPayloadSchemas[type].safeParse(payload); + if (!parsed.success) throw new HttpError(400, 'Invalid notification payload.', 'INVALID_NOTIFICATION_PAYLOAD'); + + const notification = await notificationsRepo.createNotification( + userId, + type, + parsed.data as Prisma.InputJsonValue, + ); + const dispatch: NotificationDispatch = { + notificationId: notification.id, + userId, + type, + payload: parsed.data as NotificationPayload, + }; + + const registeredAdapters = [...adapters]; + const results = await Promise.allSettled( + registeredAdapters.map((adapter) => Promise.resolve().then(() => adapter.send(dispatch))), + ); + results.forEach((result, index) => { + if (result.status === 'rejected') { + console.error(`[notifications] ${registeredAdapters[index]?.channel ?? 'unknown'} adapter failed for notification ${notification.id}`); + } + }); + return notification; +} + +export async function listMyNotifications( + userId: string, + options: { unreadOnly: boolean; limit: number; cursor?: string }, +) { + const cursor = options.cursor ? decodeCursor(options.cursor) : undefined; + const rows = await notificationsRepo.listNotifications(userId, { ...options, cursor }); + const hasMore = rows.length > options.limit; + const items = hasMore ? rows.slice(0, options.limit) : rows; + return { items, nextCursor: hasMore ? encodeCursor(items[items.length - 1]) : null }; +} + +export function getUnreadCount(userId: string) { + return notificationsRepo.countUnread(userId); +} + +export async function markMyNotificationRead(userId: string, id: string) { + const notification = await notificationsRepo.markNotificationRead(userId, id); + if (!notification) throw new HttpError(404, 'Notification not found.'); + return notification; +} + +export function markAllMyNotificationsRead(userId: string) { + return notificationsRepo.markAllNotificationsRead(userId); +} + +function encodeCursor(notification: { createdAt: Date; id: string }): string { + return Buffer.from(`${notification.createdAt.toISOString()}|${notification.id}`).toString('base64url'); +} + +function decodeCursor(value: string): { createdAt: Date; id: string } { + try { + const decoded = Buffer.from(value, 'base64url').toString('utf8'); + const separator = decoded.indexOf('|'); + const createdAtText = decoded.slice(0, separator); + const id = decoded.slice(separator + 1); + const createdAt = new Date(createdAtText); + if ( + separator < 1 || + !Number.isFinite(createdAt.getTime()) || + createdAt.toISOString() !== createdAtText || + !uuid.safeParse(id).success + ) { + throw new Error('Invalid cursor'); + } + return { createdAt, id }; + } catch { + throw new HttpError(400, 'Invalid cursor.', 'INVALID_CURSOR'); + } +} diff --git a/backend/tests/integration/notifications.test.mts b/backend/tests/integration/notifications.test.mts new file mode 100644 index 0000000..71767ac --- /dev/null +++ b/backend/tests/integration/notifications.test.mts @@ -0,0 +1,146 @@ +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import request from 'supertest'; +import EmbeddedPostgres from 'embedded-postgres'; + +const BACKEND_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const PORT = 5442; +const runId = Date.now(); +const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'docforum-notifications-pg-')); +const pg = new EmbeddedPostgres({ databaseDir: dataDir, user: 'docforum', password: 'docforum', port: PORT, persistent: false }); +const databaseUrl = `postgresql://docforum:docforum@localhost:${PORT}/docforum_test`; + +type App = ReturnType; +type Prisma = typeof import('../../src/db/prisma-client.js').prisma; + +describe('notification inbox', () => { + let app: App; + let prisma: Prisma; + let notifications: typeof import('../../src/modules/notifications/services/index.js'); + let owner: { id: string; token: string }; + let other: { id: string; token: string }; + + async function signup(label: string) { + const response = await request(app).post('/auth/signup').send({ + role: 'patient', + email: `${label}-${runId}@example.com`, + password: 'longenough123', + fullName: `Notification ${label}`, + }); + expect(response.status).toBe(200); + return { id: response.body.user.id as string, token: response.body.accessToken as string }; + } + + beforeAll(async () => { + await pg.initialise(); + await pg.start(); + await pg.createDatabase('docforum_test'); + execFileSync('npx', ['prisma', 'migrate', 'deploy'], { + cwd: BACKEND_ROOT, + env: { ...process.env, DATABASE_URL: databaseUrl }, + stdio: 'pipe', + }); + + process.env.DATABASE_URL = databaseUrl; + const appModule = await import('../../src/app.js'); + const dbModule = await import('../../src/db/prisma-client.js'); + const authServices = await import('../../src/modules/auth/services/index.js'); + notifications = await import('../../src/modules/notifications/services/index.js'); + app = appModule.createApp(); + prisma = dbModule.prisma; + authServices.setEmailSender(async () => {}); + owner = await signup('owner'); + other = await signup('other'); + }, 180_000); + + afterAll(async () => { + const authServices = await import('../../src/modules/auth/services/index.js'); + authServices.resetEmailSender(); + await prisma?.$disconnect(); + await pg.stop(); + fs.rmSync(dataDir, { recursive: true, force: true }); + }); + + it('requires authentication and prevents reading or marking another user\'s notification', async () => { + const notification = await notifications.notify(owner.id, 'booking_confirmed', { + appointmentId: '00000000-0000-4000-8000-000000000001', + }); + + expect((await request(app).get('/notifications')).status).toBe(401); + const otherList = await request(app).get('/notifications').set('Authorization', `Bearer ${other.token}`); + expect(otherList.status).toBe(200); + expect(otherList.body.items).toHaveLength(0); + + const crossUserRead = await request(app) + .post(`/notifications/${notification.id}/read`) + .set('Authorization', `Bearer ${other.token}`); + expect(crossUserRead.status).toBe(404); + expect((await prisma.notification.findUniqueOrThrow({ where: { id: notification.id } })).readAt).toBeNull(); + }); + + it('validates typed payloads and rejects malformed cursors', async () => { + await expect(notifications.notify(owner.id, 'booking_confirmed', { appointmentId: 'not-a-uuid' })).rejects.toMatchObject({ + status: 400, + code: 'INVALID_NOTIFICATION_PAYLOAD', + }); + + const response = await request(app) + .get('/notifications?cursor=not-a-valid-cursor') + .set('Authorization', `Bearer ${owner.token}`); + expect(response.status).toBe(400); + expect(response.body.code).toBe('INVALID_CURSOR'); + }); + + it('returns unread-only keyset pages without duplicates and supports read-all', async () => { + await notifications.markAllMyNotificationsRead(owner.id); + await Promise.all( + Array.from({ length: 3 }, (_, index) => + notifications.notify(owner.id, 'booking_confirmed', { + appointmentId: `00000000-0000-4000-8000-${String(index + 10).padStart(12, '0')}`, + }), + ), + ); + + const firstPage = await request(app) + .get('/notifications?unread=true&limit=2') + .set('Authorization', `Bearer ${owner.token}`); + expect(firstPage.status).toBe(200); + expect(firstPage.body.items).toHaveLength(2); + expect(firstPage.body.nextCursor).toEqual(expect.any(String)); + + const secondPage = await request(app) + .get('/notifications') + .query({ unread: 'true', limit: 2, cursor: firstPage.body.nextCursor as string }) + .set('Authorization', `Bearer ${owner.token}`); + expect(secondPage.status).toBe(200); + expect(secondPage.body.items).toHaveLength(1); + const ids = [...firstPage.body.items, ...secondPage.body.items].map((item: { id: string }) => item.id); + expect(new Set(ids).size).toBe(3); + + const count = await request(app).get('/notifications/unread-count').set('Authorization', `Bearer ${owner.token}`); + expect(count.body.count).toBe(3); + const marked = await request(app).post('/notifications/read-all').set('Authorization', `Bearer ${owner.token}`); + expect(marked.body.updated).toBe(3); + const after = await request(app).get('/notifications/unread-count').set('Authorization', `Bearer ${owner.token}`); + expect(after.body.count).toBe(0); + }); + + it('keeps the in-app write when a channel adapter fails', async () => { + const unregister = notifications.registerChannelAdapter({ channel: 'email', send: async () => { throw new Error('provider failure'); } }); + try { + const notification = await notifications.notify(owner.id, 'results_available', { + labOrderId: '00000000-0000-4000-8000-000000000099', + }); + expect(await prisma.notification.findUnique({ where: { id: notification.id } })).toMatchObject({ + id: notification.id, + channel: 'in_app', + }); + } finally { + unregister(); + } + }); +}); \ No newline at end of file diff --git a/docs/api/README.md b/docs/api/README.md index ce32fa2..c524917 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -331,12 +331,27 @@ the cleanup job deletes the stored bundle. Other knobs: `PATIENT_EXPORT_PAGE_SIZ `PATIENT_EXPORT_MAX_ATTEMPTS` — see `backend/src/config/index.ts` and `backend/src/jobs/README.md`. +## Notifications (`/notifications`) — `backend/src/modules/notifications` + +All endpoints require an access token. Notification queries and mutations are +scoped to the authenticated user; another user's notification id returns `404`. + +| Method & path | Auth | Query/body | Notes | +|---|---|---|---| +| `GET /notifications` | any authenticated user | `unread=true|false`, `limit` (1–100, default 50), `cursor` | Newest first, keyset-paginated by `(createdAt, id)`. Returns `{ items, nextCursor }`; malformed cursors return `400 INVALID_CURSOR`. | +| `GET /notifications/unread-count` | any authenticated user | — | Returns `{ count }`. | +| `POST /notifications/:id/read` | any authenticated user | — | Marks the caller's notification read; repeating the request is idempotent. | +| `POST /notifications/read-all` | any authenticated user | — | Marks all caller-owned unread notifications read; returns `{ updated }`. | + +Notification payloads are validated against the typed catalogue and contain +opaque entity identifiers only, never clinical details. Registered email/SMS +adapter failures do not fail the persisted in-app notification. Provider +integration and callbacks remain pending the provider decision in issue #17. + ## Not yet implemented -`patients`, `facilities`, `prescriptions`, `lab-orders`, -`pharmacy-orders` and `notifications` have no routes yet — Phase 2 -(intake), 4 (orders), 5 (facilities) and 6 (notifications), see -`ROADMAP.md`. +`facilities`, `prescriptions`, `lab-orders` and `pharmacy-orders` have no +routes yet — see `ROADMAP.md`. ## Escrow chain events (/admin/payments/escrow-events)