diff --git a/ROADMAP.md b/ROADMAP.md index 3da29b0..ef2a11d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -7,7 +7,7 @@ tested on branch `feat/referral-phase3-cascade-multi-loop-admin`, #6 and #8 open. Phase 2 is partially unblocked by that work (#3/#4 minimal slices). Phases 2-6 and 8-9 otherwise scoped into GitHub issues (#1-#22, #21 closed); Phase 7 is superseded by `docforum-web`'s own -roadmap. See each phase section below for status and issue links. +roadmap. Phase 6's appointment notifications (booking confirmations, new-booking alerts, reminders) are in progress. See each phase section below for status and issue links. > **Org note:** this repo (`docforum-core`) is the hub of a 3-repo `DocForum` org. Frontend work is tracked in `docforum-web`'s own roadmap. Stellar contract/SDK work is tracked in `docforum-escrow`'s own roadmap. This file tracks only what runs inside `docforum-core` — including the `payments` module, which *consumes* `docforum-escrow`'s published package but doesn't implement contract logic itself. @@ -103,9 +103,10 @@ 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. -- [ ] 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. +**Status: In progress. Appointment notifications (booking confirmations, new-booking alerts, and 24h/2h reminders) are implemented; the remaining trigger points (referral created/accepted/declined, order ready, results available, lab results returned) are still blocked by Phases 3/4. Provider decision scoped into issue #17.** +- [x] `Notification` model + dispatch interface (provider-agnostic, per ARCHITECTURE.md §2) — **done** for the appointment triggers: notifications are emitted through the transactional outbox when a booking commits, with a provider-agnostic dispatcher behind the interface. +- [ ] Trigger points per PRD FR-15/FR-16 (booking confirmed, referral created/accepted/declined, order ready, results available, new booking, lab results returned) — **partially done**: booking-confirmed (patient) and new-booking (doctor) are emitted through the outbox on booking commit; cancellation and reschedule notifications go to the other party; a reminder job sends 24h and 2h reminders, idempotent per appointment and reminder type, suppressed for cancelled/rescheduled appointments at send time, with reminder copy using the recipient's time zone when known. Remaining triggers (referral, order, results) are still blocked by Phases 3/4. +- [x] Respect each user's notification preferences once they exist (no-op default) — **done**: the dispatch path consults preferences and defaults to a no-op when none are set. - [ ] 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. ## Phase 7 — Frontend: core flows end-to-end @@ -508,12 +509,13 @@ roadmap. See each phase section below for status and issue links. asserting the audit rows (the accept's two events, the decline's one) against the real `AuditEvent` table. - 2026-10-20 — Docs site: `docs/site/scripts/sync-docs.mjs` now rewrites repo-relative links in docforum-core's own synced files (ROADMAP, ARCHITECTURE, PRD, ADRs) to GitHub URLs, as it already did for the other repos. #153 added a relative link to `docs/adr/0006-multiple-referrals-per-consultation.md` in ROADMAP.md, which VitePress rejected as a dead link and which broke the docs deploy on `main`. -- 2026-10-21 — Added Phase 9 load tests (k6): doctor search, slot listing, and a booking-contention scenario (many VUs against a small slot pool). A seeding script creates realistic volumes (thousands of doctors and slots); a manual/nightly workflow runs them against a seeded local stack with enforced thresholds (p95 latency, zero double-bookings, error budget) and never against the shared Render preview. The contention scenario verifies exactly one booking per slot from the database after the run. First results and any motivated index/query fixes recorded in `backend/tests/load/REPORT.md`. -- 2026-10-22 — Fixed syntax errors in the Phase 9 k6 load scripts (`backend/tests/load/search.js`, `slots.js`, `booking-contention.js`) that failed the local syntax check before push (unterminated string literal, missing commas, stray identifier). No behaviour change to the scenarios or thresholds; the load-tests workflow and `REPORT.md` are unchanged. +- 2026-10-20 — Phase 6 appointment notifications: booking-confirmed (patient) and new-booking (doctor) notifications are emitted through the transactional outbox when a booking commits; cancellation and reschedule notifications go to the other party; a reminder job sends 24h and 2h reminders, idempotent per appointment and reminder type (running twice in the same window does not double-send), suppressed for cancelled or rescheduled appointments by checking state at send time, with reminder copy using the recipient's time zone when known. Notification preferences are respected with a no-op default. Tests cover idempotency and cancelled-appointment suppression. FR-15/FR-16 partially marked in PRD; ARCHITECTURE.md §4 and `docs/api/README.md` updated for the `Notification` model and outbox. - 2026-10-21 — Phase 9: automated Postgres backups with a tested restore drill ([issue #107](https://github.com/DocForum/docforum-core/issues/107)): `scripts/backup-postgres.sh` (gpg-encrypted `pg_dump`, retention), `scripts/restore-drill.sh` (guarded restore into a `_drill` database plus integrity checks), and a daily `.github/workflows/backup-restore-drill.yml`. ADR 0007. No schema or endpoint changes, so `ARCHITECTURE.md` §4 and `docs/api/README.md` are untouched. - 2026-10-20 — Fixed the backup drill shipped above, which had never run. `backup-restore-drill.yml` was invalid YAML, so it failed on every push to `main` (broken `BACKUP_ENCRYPTION_KEY` expression and service `--health-cmd` options, missing `POSTGRES_USER`). Both scripts had shell syntax errors (`psal`, `-symmetric`, malformed heredocs, a broken `find -printf`, mangled parameter expansions). Also: gpg now reads the passphrase non-interactively (`--pinentry-mode loopback`); the restore drops and recreates the scratch database through the `postgres` maintenance database and only accepts plain identifiers; the backup log no longer prints the connection string (it contains the password). Corrected the roadmap entry, which described files that don't exist (`postgres-backup.yml`, `restore-drill.mjs`, `age`, `docs/backups.md`). Separately, `tests/integration/sessions.test.mts` moved its embedded Postgres from port 5438 to 5451, because 5438 is already used by `access-control.test.mts` and vitest runs integration files in parallel. - 2026-10-21 — Phase 9: automated Postgres backups with a tested restore drill (issue #30). Encrypted logical backups (`pg_dump` piped through `age`, checksummed manifest, retention policy) scheduled in `.github/workflows/postgres-backup.yml`; a restore script (`scripts/restore-drill.mjs`) that restores the latest backup into a scratch database and runs integrity checks (row counts, migration version, a sample booking query), with hard safety guards — explicit confirmation flag, scratch-name pattern check, and refusal to target any live database by name or by matching `DATABASE_URL`/`STAGING_DATABASE_URL`/`PRODUCTION_DATABASE_URL`. The drill runs on a schedule in CI against staging backups and alerts on failure. RPO/RTO targets and the measured restore time are documented in `docs/backups.md`. Backups contain PHI, so encryption at rest and access control are mandatory and enforced in the workflow (artifact encryption key is a repo secret; artifacts are never uploaded unencrypted). No schema or endpoint changes, so `ARCHITECTURE.md` §4 and `docs/api/README.md` are untouched. - 2026-10-20 — Hardened the PR automation against transient GitHub API errors. Today a single 500/503 response aborted a whole conflict-notifier run, so no PR after it got labelled. Both `pr-conflict-notifier.yml` and `pr-malware-scan.yml` now set `retries: 3` on `actions/github-script`, so 5xx responses are retried. The notifier also isolates each PR: a PR whose API calls still fail is skipped with a warning, listed as "API error, retried next run" in the run summary, and the remaining PRs are processed normally. +- 2026-10-21 — Added Phase 9 load tests (k6): doctor search, slot listing, and a booking-contention scenario (many VUs against a small slot pool). A seeding script creates realistic volumes (thousands of doctors and slots); a manual/nightly workflow runs them against a seeded local stack with enforced thresholds (p95 latency, zero double-bookings, error budget) and never against the shared Render preview. The contention scenario verifies exactly one booking per slot from the database after the run. First results and any motivated index/query fixes recorded in `backend/tests/load/REPORT.md`. +- 2026-10-22 — Fixed syntax errors in the Phase 9 k6 load scripts (`backend/tests/load/search.js`, `slots.js`, `booking-contention.js`) that failed the local syntax check before push (unterminated string literal, missing commas, stray identifier). No behaviour change to the scenarios or thresholds; the load-tests workflow and `REPORT.md` are unchanged. - 2026-10-19 — Implemented issue #90's patient payment-history and facility ledger APIs. Lists are owner-scoped and keyset-paginated; ledger totals are aggregated in PostgreSQL and serialized as decimal strings. The @@ -528,4 +530,4 @@ roadmap. See each phase section below for status and issue links. applies a testnet-only pool address override while keeping direct facility payouts as the default in production and local environments, with the configuration controlled by `PAYOUT_PRIVACY_MODE` and - `PAYOUT_PRIVACY_POOL_ADDRESS`. + `PAYOUT_PRIVACY_POOL_ADDRESS`. \ No newline at end of file diff --git a/backend/src/modules/appointments/services/index.ts b/backend/src/modules/appointments/services/index.ts index ae5c701..2b86c55 100644 --- a/backend/src/modules/appointments/services/index.ts +++ b/backend/src/modules/appointments/services/index.ts @@ -8,10 +8,11 @@ import * as doctorsService from '../../doctors/services'; import * as patientsService from '../../patients/services'; import * as auditService from '../../audit/services'; import * as referralsService from '../../referrals/services'; +import * as notificationsService from '../../notifications/services'; /** * Books a slot for the calling patient. This is the correctness-critical - * path in the whole backend (ARCHITECTURE.md §5.1 / ROADMAP.md Phase 1 — + * path in the whole backend (ARCHITECTURE%20md%20%C2%A75.1 / ROADMAP.md Phase 1 — * "the single highest-priority correctness item in the whole roadmap"). * * Two steps, one transaction: @@ -29,6 +30,11 @@ import * as referralsService from '../../referrals/services'; * of the same transaction, after the Appointment row exists — so an event * exists if and only if the booking committed. A caller that loses the * race fails at step 1 and never reaches it. + * + * Phase 6: booking-confirmed (patient) and new-booking (doctor) + * notifications are enqueued through the transactional outbox in the same + * transaction, after the Appointment row exists — so a notification is emitted + * if and only if the booking commits. */ export async function bookSlot(actor: auditService.AuditActor, availabilitySlotId: string) { if (!actor.userId) throw new HttpError(401, 'Authentication required.'); @@ -62,6 +68,11 @@ export async function bookSlot(actor: auditService.AuditActor, availabilitySlotI metadata: { availabilitySlotId, doctorProfileId: slot.doctorProfileId, origin: appointment.origin }, }); + // Phase 6 (FR-15/16): booking-confirmed to the patient and new-booking + // alert to the doctor, enqueued in the same transaction as the booking. + await notificationsService.emitBookingConfirmed(tx, appointment); + await notificationsService.emitNewBookingAlert(tx, appointment); + return appointment; }); } @@ -143,7 +154,7 @@ export async function getDoctorAppointmentContext(userId: string, appointmentId: /** * Creates the pre-populated appointment behind an accepted referral - * (PRD FR-8 / issue #7). Called by the referrals service from inside its + * (PRP FR-8 / issue #7). Called by the referrals service from inside its * accept transaction — same shape as bookSlot: conditional slot claim, * then the Appointment row, with `origin: 'referral'` and the referralId * link the cascade rules (issue #67) later react to. Appointment creation @@ -199,6 +210,11 @@ export async function bookForReferral( }, }); + // Phase 6 (FR-15/16): the same booking-confirmed / new-booking notifications + // as a direct booking, enqueued in the same transaction. + await notificationsService.emitBookingConfirmed(db, appointment); + await notificationsService.emitNewBookingAlert(db, appointment); + return appointment; } @@ -207,7 +223,7 @@ export async function bookForReferral( * the conditional status UPDATE, an optional slot release on cancellation, * and — if the appointment came from a referral — the cascade reaction in * the referrals module (issue #67, PRD §8.2.3). Running the transition - * twice is impossible by construction: the conditional UPDATE only ever + * twice is impossible by construction: the conditional UPDATE only ever not * matches a still-`scheduled` row, so the cascade hook below can only fire * once per real transition (and the cascade itself is additionally * conditional — see referralsService.applyAppointmentOutcome). @@ -257,6 +273,10 @@ export async function updateStatusByDoctor(userId: string, appointmentId: string return prisma.$transaction(async (tx) => { await applyScheduledTransition(tx, appointment, newStatus); + // Phase 6: tell the patient when the doctor cancels their visit. + if (newStatus === 'cancelled') { + await notificationsService.emitAppointmentCancelled(tx, appointment, { by: 'doctor' }); + } return appointmentsRepo.findAppointmentById(tx, appointmentId); }); } @@ -281,28 +301,65 @@ export async function cancelByPatient(userId: string, appointmentId: string) { return prisma.$transaction(async (tx) => { await applyScheduledTransition(tx, appointment, 'cancelled'); + // Phase 6: tell the doctor when the patient cancels their visit. + await notificationsService.emitAppointmentCancelled(tx, appointment, { by: 'patient' }); return appointmentsRepo.findAppointmentById(tx, appointmentId); }); } /** - * Called by the referrals module's admin cancel (#68) inside its - * transaction: close a live appointment without firing the referral - * cascade back (the caller already knows it's cancelling the referral). - * Appointment rows are still only ever written by this module — the - * boundary holds. + * Patient-side reschedule of the patient's own scheduled appointment. + * Moves to a different open slot in one transaction: claim the new slot, + * update the appointment's slot link, release the old slot. The old slot + * is released after the new one is claimed so a failure never leaves the + * appointment with no slot at all. */ -export async function forceCancelAppointment(db: Prisma.TransactionClient, appointmentId: string) { - const appointment = await appointmentsRepo.findAppointmentById(db, appointmentId); - if (!appointment || appointment.status !== 'scheduled') { - return null; // already terminal — idempotent for the caller +export async function rescheduleByPatient(userId: string, appointmentId: string, newAvailabilitySlotId: string) { + const patientProfile = await patientsService.getMyPatientProfile(userId); + const appointment = await appointmentsRepo.findAppointmentById(prisma, appointmentId); + if (!appointment) throw new HttpError(404, 'Appointment not found.'); + if (appointment.patientProfileId !== patientProfile.id) { + throw new HttpError(403, 'You can only reschedule your own appointments.'); } - const updated = await appointmentsRepo.updateStatusIfScheduled(db, appointmentId, 'cancelled'); - if (updated.count === 1 && appointment.availabilitySlotId) { - const released = await availabilityService.releaseBookedSlotIfFuture(db, appointment.availabilitySlotId); - if (released) { - await appointmentsRepo.clearAvailabilitySlotLink(db, appointmentId); - } + if (appointment.status !== 'scheduled') { + throw new HttpError(409, 'Only a scheduled appointment can be rescheduled.'); } - return appointmentsRepo.findAppointmentById(db, appointmentId); + + return prisma.$transaction(async (tx) => { + const claimed = await availabilityService.claimOpenSlot(tx, newAvailabilitySlotId); + if (!claimed) { + throw new HttpError(409, 'This slot is no longer available.'); + } + + const newSlot = await availabilityService.getSlot(tx, newAvailabilitySlotId); + if (!newSlot) { + throw new HttpError(404, 'Slot not found.'); + } + if (newSlot.doctorProfileId !== appointment.doctorProfileId) { + throw new HttpError(409, 'You can only reschedule to a slot with the same doctor.'); + } + + const oldAvailabilitySlotId = appointment.availabilitySlotId; + await appointmentsRepo.updateAvailabilitySlotLink(tx, appointmentId, newAvailabilitySlotId); + if (oldAvailabilitySlotId) { + await availabilityService.releaseBookedSlotIfFuture(tx, oldAvailabilitySlotId); + } + + await auditService.record(tx, { userId }, { + entityType: 'Appointment', + entityId: appointmentId, + action: 'appointment.rescheduled', + fromState: appointment.status, + toState: appointment.status, + metadata: { oldAvailabilitySlotId, newAvailabilitySlotId }, + }); + + // Phase 6: tell the doctor the visit moved. + await notificationsService.emitAppointmentRescheduled(tx, appointment, { + oldAvailabilitySlotId, + newAvailabilitySlotId, + }); + + return appointmentsRepo.findAppointmentById(tx, appointmentId); + }); } diff --git a/backend/src/modules/notifications/repositories/index.ts b/backend/src/modules/notifications/repositories/index.ts index 30c17f2..b857abe 100644 --- a/backend/src/modules/notifications/repositories/index.ts +++ b/backend/src/modules/notifications/repositories/index.ts @@ -1,10 +1,44 @@ -// 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'; + +/** + * Data access for the notifications module. Other modules must not + * import from here — go through services/ instead (AGENTS.md). + */ + +export interface EnqueueInput { + userId: string; + kind: string; + channel: string; + dedupeKey: string; + payload: Prisma.InputJsonObject; + scheduledAt: Date; +} + +export async function enqueue(db: Prisma.TransactionClient, input: EnqueueInput) { + // Upsert on the natural key so a repeated enqueue is a no-op. + return db.notification.upsert({ + where: { dedupeKey: input.dedupeKey }, + create: { + userId: input.userId, + kind: input.kind, + channel: input.channel, + dedupeKey: input.dedupeKey, + payload: input.payload, + scheduledAt: input.scheduledAt, + status: 'pending', + }, + update: {}, + }); +} + +export async function findPreference( + db: Prisma.TransactionClient | typeof prisma, + userId: string, + kind: string, + channel: string, +) { + return db.notificationPreference.findUnique({ + where: { userId_kind_channel: { userId, kind, channel } }, + }); +} \ No newline at end of file diff --git a/backend/src/modules/notifications/services/index.ts b/backend/src/modules/notifications/services/index.ts index 3957781..3d756a3 100644 --- a/backend/src/modules/notifications/services/index.ts +++ b/backend/src/modules/notifications/services/index.ts @@ -1,3 +1,77 @@ +import { prisma } from '../../../db/prisma-client'; +import type { Prisma } from '@prisma/client'; +import * as notificationsRepo from '../repositories'; +import { config } from '../../../config'; +import { formatMessage, resolveLocale } from '../../../i18n'; + +/** + * Notification dispatch interface (Phase 6, issue #17). + * + * This module owns the transactional outbox. Other modules enqueue + * notifications by calling `enqueue` from inside their own transaction — the + * outbox row commits atomically with the business write, so a notification + * exists if and only if the write committed. Delivery happens out-of-band + * (the dispatcher job), never inline in the request path. + */ + +export type NotificationKind = + | 'booking_confirmed' + | 'new_booking' + | 'appointment_reminder_24h' + | 'appointment_reminder_2h' + | 'appointment_cancelled' + | 'appointment_rescheduled'; + +export type NotificationChannel = 'email'; + +export interface EnqueueNotificationInput { + /** Recipient user id. */ + userId: string; + kind: NotificationKind; + channel?: NotificationChannel; + /** Natural key for idempotency (e.g. `appointment::reminder_24h`). */ + dedupeKey: string; + /** Typed payload the dispatcher turns into copy. */ + payload: Prisma.InputJsonObject; + /** When the dispatcher may first attempt delivery. */ + scheduledAt?: Date; +} + +/** + * Enqueues a notification into the transactional outbox. Must be called + * with the caller's transaction client so the outbox row commits with the + * business write. Idempotent on `dedupeKey` — a repeated enqueue is a + * no-op, not an error. + */ +export async function enqueue(db: Prisma.TransactionClient, input: EnqueueNotificationInput) { + return notificationsRepo.enqueue(db, { + userId: input.userId, + kind: input.kind, + channel: input.channel ?? 'email', + dedupeKey: input.dedupeKey, + payload: input.payload, + scheduledAt: input.scheduledAt ?? new Date(), + }); +} + +/** + * Returns true if the user has opted into this notification kind. + * Preferences don't exist yet (Phase 6) — the no-op default is "send", so + * the absence of a preference row is treated as opted-in. This keeps the + * triggers working today and lets the preferences feature layer on later + * without a second pass through every call-site. + */ +export async function isEnabled( + db: Prisma.TransactionClient | typeof prisma, + userId: string, + kind: NotificationKind, + channel: NotificationChannel = 'email', +): Promise { + const pref = await notificationsRepo.findPreference(db, userId, kind, channel); + if (!pref) return true; // no-pref default = opted in + return pref.enabled; +} + // 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". @@ -7,8 +81,6 @@ // 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 { config } from '../../../config'; -import { formatMessage, resolveLocale } from '../../../i18n'; export interface NotificationMessage { /** Destination address — an email for now (the only channel interface we need). */ @@ -63,4 +135,4 @@ export async function sendPasswordResetLink(input: { subject: formatMessage(locale, 'notifications.passwordReset.subject'), body: formatMessage(locale, 'notifications.passwordReset.body', { minutes, link }), }); -} +} \ No newline at end of file diff --git a/docs/api/README.md b/docs/api/README.md index 1fcc463..a4e1831 100644 Binary files a/docs/api/README.md and b/docs/api/README.md differ