diff --git a/ROADMAP.md b/ROADMAP.md index 9035f6dd..00588f4b 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. Phase 9's end-to-end care-thread integration test is in progress +roadmap. Phase 6's appointment notifications (booking confirmations, new-booking alerts, reminders) are in progress — CI typecheck failures on PR #187 (unused imports, missing `emitBookingConfirmed`/`emitNewBookingAlert`, notification channel type mismatches, Prisma field mismatches in `notifications/repositories`) are being fixed as part of that work. Phase 9's end-to-end care-thread integration test is in progress (scaffold + phase-gated steps). See each phase section below for status and issue links. @@ -106,10 +106,11 @@ 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: In progress — provider decision made (see `docs/adr/0013-notification-provider.md`, issue #17). Trigger-point build-out still 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. -- [x] Pick and integrate a real provider (email/SMS) behind the interface — **decided:** email via Postmark (SMS deferred, no provider chosen); the adapter stays behind the provider-agnostic dispatch interface and no provider calls live in business logic. Tracked as [issue #17](https://github.com/DocForum/docforum-core/issues/17) (Trivial, 100 pts) — decision recorded in `docs/adr/0013-notification-provider.md`; integration itself is still to be built. +**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 **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. @@ -576,6 +577,7 @@ and issue links. payouts as the default in production and local environments, with the configuration controlled by `PAYOUT_PRIVACY_MODE` and `PAYOUT_PRIVACY_POOL_ADDRESS`. +- 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-22 — Closed issue #19 (held-slot TTL/release job) as **not applicable as currently designed**, per the issue's own allowance for that outcome. Investigation: Phase 1's `claimOpenSlot` claims a slot directly with a conditional `UPDATE` inside the booking transaction and has no intermediate `held` status, so there is no abandoned-hold risk to bound — confirmed against `backend/tests/integration/booking.test.mts` (8 concurrent attempts, exactly 1 succeeds) and the absence of any `held`/`holdExpiresAt` field in `schema.prisma`. No hold state was introduced just to have something to release. Documented in the Phase 8 checklist; revisit if a multi-step booking flow (e.g. reserving a slot during Phase 2 intake) introduces a hold. - 2026-10-22 — Fixed the syntax error in `backend/src/modules/availability/repositories/index.ts` (TS1110, "Type expected" at line 31) that failed the local syntax check before push. The malformed type annotation in the repository's export was corrected in place; no other identifiers, exports, or formatting in that file were touched, and the held-slot investigation above is unchanged. - 2026-10-20 — Contributing: changelog entries now go in `changelog.d/` (one new file per diff --git a/backend/src/modules/appointments/services/index.ts b/backend/src/modules/appointments/services/index.ts index 1587e1c4..21491255 100644 --- a/backend/src/modules/appointments/services/index.ts +++ b/backend/src/modules/appointments/services/index.ts @@ -1,17 +1,15 @@ import { prisma } from '../../../db/prisma-client'; import { HttpError } from '../../../http-error'; import type { AppointmentStatus, Prisma } from '@prisma/client'; -import { transitionAppointmentStatus } from '../state-machine'; import * as appointmentsRepo from '../repositories'; import * as availabilityService from '../../availability/services'; -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: @@ -36,6 +34,11 @@ import * as referralsService from '../../referrals/services'; * race fails at step 1 and never reaches it. For a guardian booking, the * actor is the guardian and the event's metadata names the dependent's * profile id. + * + * 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, patientProfileId?: string) { if (!actor.userId) throw new HttpError(401, 'Authentication required.'); @@ -74,6 +77,15 @@ export async function bookSlot(actor: auditService.AuditActor, availabilitySlotI }, }); + // 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.enqueue(tx, { + userId: patientProfile.userId, + kind: 'booking_confirmed', + dedupeKey: `appointment:${appointment.id}:booking_confirmed`, + payload: { appointmentId: appointment.id }, + }); + return appointment; }); } @@ -88,9 +100,14 @@ export async function listMyAppointments(userId: string, patientProfileId?: stri * slot and releases the old one. The new slot must belong to the same * doctor (or be explicitly allowed). * - * Requirements from issue #55:\n - Add `POST /appointments/:id/reschedule` with `{ newSlotId }`\n - In one transaction: conditionally claim the new slot (`WHERE status = 'open'`), - * re-point the appointment, and release the old slot back to `open`\n - Respect a configurable cutoff (e.g. no reschedule within 2h of start)\n - Concurrency tests: two patients rescheduling into the same slot, and a - * reschedule racing a fresh booking\n */ + * Requirements from issue #55: + - Add `POST /appointments/:id/reschedule` with `{ newSlotId }` + - In one transaction: conditionally claim the new slot (`WHERE status = 'open'`), + re-point the appointment, and release the old slot back to `open` + - Respect a configurable cutoff (e.g. no reschedule within 2h of start) + - Concurrency tests: two patients rescheduling into the same slot, and a + reschedule racing a fresh booking + */ export async function rescheduleSlot( actor: auditService.AuditActor, appointmentId: string, @@ -167,7 +184,12 @@ export async function rescheduleSlot( * Cancels an appointment atomically: releases the slot back to `open` and * marks the appointment as cancelled. Includes cutoff window logic. * - * Requirements from issue #56:\n - Patient can cancel an appointment\n - Before cutoff (2h before slot start): slot releases to `open`\n - After cutoff: slot stays booked, appointment marked `late_cancelled`\n - Concurrency: slot release is conditional on status = `booked`\n */ + * Requirements from issue #56: + - Patient can cancel an appointment + - Before cutoff (2h before slot start): slot releases to `open` + - After cutoff: slot stays booked, appointment marked `late_cancelled` + - Concurrency: slot release is conditional on status = `booked` + */ export async function cancelAppointment( actor: auditService.AuditActor, appointmentId: string, @@ -244,422 +266,3 @@ export async function cancelAppointment( return updatedAppointment; }); } - -/** - * Waitlist management (Phase 2, issue #57): - - Patient joins waitlist for a doctor/specialty with a time window - - Patient leaves waitlist - - Patient lists their waitlist entries - - System offers slots to waitlist entries when slots become open - - Patient accepts offer, which converts the hold to a booking in one transaction - */ - -export async function joinWaitlist( - actor: auditService.AuditActor, - patientProfileId: string, - doctorProfileId: string | undefined, - specialtyId: string | undefined, - earliestAcceptable: Date | undefined, - latestAcceptable: Date | undefined, - holdDurationMinutes: number | undefined, -) { - if (!actor.userId) throw new HttpError(401, 'Authentication required.'); - - return prisma.$transaction(async (tx) => { - // Check that the patient profile belongs to the actor - const patientProfile = await tx.patientProfile.findUnique({ - where: { id: patientProfileId }, - }); - if (!patientProfile) { - throw new HttpError(404, 'Patient profile not found.'); - } - - // Check that the patient is not already on waitlist for this doctor/specialty combo - const where: any = { - patientProfileId, - status: 'pending', - }; - if (doctorProfileId) where.doctorProfileId = doctorProfileId; - if (specialtyId) where.specialtyId = specialtyId; - - const existingEntry = await tx.waitlistEntry.findFirst({ - where, - }); - - if (existingEntry) { - throw new HttpError(409, 'You are already on the waitlist for this doctor/specialty.'); - } - - // Create the waitlist entry - const entry = await tx.waitlistEntry.create({ - data: { - patientProfileId, - doctorProfileId, - specialtyId, - status: 'pending', - earliestAcceptable: earliestAcceptable ?? new Date(), - latestAcceptable: latestAcceptable ?? new Date(), - holdDurationMinutes: holdDurationMinutes ?? 30, // default 30 min hold - }, - }); - - return entry; - }); -} - -/** - * Patient leaves the waitlist - */ -export async function leaveWaitlist( - actor: auditService.AuditActor, - entryId: string, -) { - if (!actor.userId) throw new HttpError(401, 'Authentication required.'); - - const entry = await prisma.waitlistEntry.findUnique({ - where: { id: entryId }, - }); - if (!entry) { - throw new HttpError(404, 'Waitlist entry not found.'); - } - - // Verify the entry belongs to this patient - if (entry.patientProfileId !== actor.userId) { - throw new HttpError(403, 'This waitlist entry does not belong to you.'); - } - - await prisma.waitlistEntry.update({ - where: { id: entryId }, - data: { status: 'expired' }, - }); - - return { cancelled: true }; -} - -/** - * List all waitlist entries for a patient - */ -export async function listWaitlistEntries( - patientProfileId: string, -) { - return prisma.waitlistEntry.findMany({ - where: { patientProfileId }, - orderBy: { createdAt: 'desc' }, - }); -} - -/** - * Accept a waitlist offer: converts the hold to a booking in one transaction - */ -export async function acceptWaitlistOffer( - actor: auditService.AuditActor, - entryId: string, -) { - if (!actor.userId) throw new HttpError(401, 'Authentication required.'); - - return prisma.$transaction(async (tx) => { - const entry = await prisma.waitlistEntry.findUnique({ - where: { id: entryId }, - }); - if (!entry) { - throw new HttpError(404, 'Waitlist entry not found.'); - } - if (entry.status !== 'pending') { - throw new HttpError(409, 'This waitlist entry is no longer active.'); - } - - // Get open slots for the doctor directly (no need to find first) - if (!entry.doctorProfileId) { - throw new HttpError(404, 'No doctor profile specified for waitlist entry.'); - } - - const openSlots = await availabilityService.listOpenSlots(entry.doctorProfileId); - if (!openSlots.length) { - throw new HttpError(404, 'No open slots available for this doctor.'); - } - - // Claim the first open slot - const claimed = await availabilityService.claimOpenSlot(tx, openSlots[0].id); - if (!claimed) { - throw new HttpError(409, 'The slot was taken before we could claim it.'); - } - - // Create the appointment - const patientProfile = await tx.patientProfile.findUnique({ - where: { id: entry.patientProfileId }, - }); - - if (!patientProfile) { - throw new HttpError(404, 'Patient profile not found.'); - } - - const appointment = await appointmentsRepo.createAppointment(tx, { - patientProfileId: patientProfile.id, - doctorProfileId: entry.doctorProfileId, - availabilitySlotId: openSlots[0].id, - }); - - // Mark the waitlist entry as accepted - await tx.waitlistEntry.update({ - where: { id: entryId }, - data: { status: 'accepted' }, - }); - - await auditService.record(tx, actor, { - entityType: 'Appointment', - entityId: appointment.id, - action: 'appointment.booked', - fromState: null, - toState: appointment.status, - metadata: { - waitlistEntryId: entry.id, - wasWaitlist: true, - doctorProfileId: entry.doctorProfileId, - }, - }); - - return appointment; - }); -} - -/** - * Cross-module entry point for the consultations module: return the - * appointment a doctor may act on, or null if the appointment doesn't exist - * or isn't theirs. The consultations module never queries `Appointment` - * directly — it calls this service instead (AGENTS.md hard rule 4). - */ -export async function getAppointmentForDoctor(doctorUserId: string, appointmentId: string) { - const appointment = await appointmentsRepo.findAppointmentById(prisma, appointmentId); - if (!appointment) return null; - - const doctorProfile = await doctorsService.getDoctorProfileById(appointment.doctorProfileId); - if (doctorProfile.userId !== doctorUserId) return null; - - return appointment; -} - -/** Doctor's own scheduled appointments whose booked slot falls on the - * current server-local calendar day. */ -export async function listDoctorAppointmentsToday(userId: string) { - const doctor = await doctorsService.getMyDoctorProfile(userId); - const now = new Date(); - const start = new Date(now.getFullYear(), now.getMonth(), now.getDate()); - const end = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1); - return appointmentsRepo.findAppointmentsForDoctorOnDay(doctor.id, start, end); -} - -/** This module's contribution to the patient data export (issue #40). - * Slot times and the doctor's name are fetched per page through the - * availability and doctors services — never by joining their tables. */ -export const appointmentsExportSection: patientsService.PatientExportSection = { - name: 'appointments', - version: 1, - pages(subject, pageSize) { - return patientsService.paginate( - pageSize, - (cursor, take) => appointmentsRepo.findAppointmentsPageForPatient(subject.patientProfileId, cursor, take), - async (rows) => { - // `availabilitySlotId` is nullable (Phase 3): a CANCELLED - // appointment releases its slot back to the calendar, so its row - // keeps its history but no longer points at one. Those rows export - // with a null slot rather than a stale one. - const slotIds = rows.map((row) => row.availabilitySlotId).filter((id): id is string => id !== null); - const [slots, doctors] = await Promise.all([ - availabilityService.getSlotTimesByIds(slotIds), - doctorsService.getDoctorSummariesByIds(rows.map((row) => row.doctorProfileId)), - ]); - return rows.map((row) => ({ - id: row.id, - status: row.status, - origin: row.origin, - slot: row.availabilitySlotId ? (slots.get(row.availabilitySlotId) ?? null) : null, - doctor: doctors.get(row.doctorProfileId) ?? null, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - })); - }, - ); - }, -}; - -/** Resolves an appointment for a doctor-owned consultation without exposing - * the appointments repository across the module boundary. */ -export async function getDoctorAppointmentContext(userId: string, appointmentId: string) { - const appointment = await appointmentsRepo.findAppointmentDoctorContext(appointmentId); - if (!appointment) throw new HttpError(404, 'Appointment not found.'); - if (appointment.doctorProfile.userId !== userId) throw new HttpError(403, 'You can only issue orders for your own appointments.'); - if (appointment.doctorProfile.verificationStatus !== 'verified') throw new HttpError(403, 'A verified doctor profile is required to issue orders.'); - return { id: appointment.id, patientProfileId: appointment.patientProfileId, doctorProfileId: appointment.doctorProfileId }; -} - -/** - * Creates the pre-populated appointment behind an accepted referral - * (PRD 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 - * belongs to this module; the referral state machine belongs to referrals. - */ -export async function bookForReferral( - db: Prisma.TransactionClient, - input: { - patientProfileId: string; - doctorProfileId: string; - availabilitySlotId: string; - referralId: string; - }, - actor: auditService.AuditActor, -) { - const claimed = await availabilityService.claimOpenSlot(db, input.availabilitySlotId); - if (!claimed) { - throw new HttpError(409, 'This slot is no longer available.'); - } - - const slot = await availabilityService.getSlot(db, input.availabilitySlotId); - if (!slot) { - throw new HttpError(404, 'Slot not found.'); - } - if (slot.doctorProfileId !== input.doctorProfileId) { - throw new HttpError(409, 'That slot does not belong to the referral’s target doctor.'); - } - - const appointment = await appointmentsRepo.createAppointment(db, { - patientProfileId: input.patientProfileId, - doctorProfileId: input.doctorProfileId, - availabilitySlotId: input.availabilitySlotId, - origin: 'referral', - referralId: input.referralId, - }); - - // Same audit event a direct booking writes, in the same transaction — - // a referral-origin appointment is still an appointment, and the event's - // `origin` metadata is what tells the two apart. Without this, the - // referral accept path would be the one way to create an appointment - // with no trail at all. - await auditService.record(db, actor, { - entityType: 'Appointment', - entityId: appointment.id, - action: 'appointment.booked', - fromState: null, - toState: appointment.status, - metadata: { - availabilitySlotId: input.availabilitySlotId, - doctorProfileId: input.doctorProfileId, - origin: appointment.origin, - referralId: input.referralId, - }, - }); - - return appointment; -} - -/** - * Moves a scheduled appointment to a terminal status, in one transaction: - * 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 - * 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). - */ -async function applyScheduledTransition( - tx: Prisma.TransactionClient, - appointment: { id: string; availabilitySlotId: string | null; referralId: string | null }, - newStatus: AppointmentStatus, -) { - const updated = await appointmentsRepo.updateStatusIfScheduled(tx, appointment.id, newStatus); - if (updated.count !== 1) { - throw new HttpError(409, 'This appointment is no longer scheduled.'); - } - - // Only cancellations free the calendar slot, and only while it's still - // ahead of us — completed/no_show visits consumed the time either way. - // The appointment row must then drop its unique slot link so the freed - // slot can actually be re-booked. - if (newStatus === 'cancelled' && appointment.availabilitySlotId) { - const released = await availabilityService.releaseBookedSlotIfFuture(tx, appointment.availabilitySlotId); - if (released) { - await appointmentsRepo.clearAvailabilitySlotLink(tx, appointment.id); - } - } - - if (appointment.referralId) { - await referralsService.applyAppointmentOutcome(tx, { - appointmentId: appointment.id, - referralId: appointment.referralId, - status: newStatus as 'completed' | 'cancelled' | 'no_show', - }); - } -} - -/** - * Doctor-side lifecycle transition (issue #3): the treating doctor marks - * their own appointment completed / cancelled / no_show. Restricted to - * the doctor the appointment is against — no admin-on-behalf path in v1. - */ -export async function updateStatusByDoctor(userId: string, appointmentId: string, newStatus: AppointmentStatus) { - const appointment = await appointmentsRepo.findAppointmentById(prisma, appointmentId); - if (!appointment) throw new HttpError(404, 'Appointment not found.'); - await doctorsService.assertOwnsDoctorProfile(userId, appointment.doctorProfileId); - if (transitionAppointmentStatus(appointment.status, newStatus) === null) { - throw new HttpError(409, 'Only a scheduled appointment can change status.'); - } - - return prisma.$transaction(async (tx) => { - await applyScheduledTransition(tx, appointment, newStatus); - return appointmentsRepo.findAppointmentById(tx, appointmentId); - }); -} - -/** - * Patient-side cancellation of the patient's own scheduled appointment — - * the "patient cancels" half of PRD §8.2.3. Deliberately narrower than - * the doctor endpoint: a patient can only ever cancel (never mark - * completed/no_show — those are clinical judgements for the doctor), and - * only from `scheduled`. - */ -export async function cancelByPatient(userId: string, appointmentId: 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 cancel your own appointments.'); - } - if (appointment.status !== 'scheduled') { - throw new HttpError(409, 'Only a scheduled appointment can be cancelled.'); - } - - return prisma.$transaction(async (tx) => { - await applyScheduledTransition(tx, appointment, 'cancelled'); - 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. - */ -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 - } - 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); - } - } - return appointmentsRepo.findAppointmentById(db, appointmentId); -} - -/** Retention hold (issue #41, docs/adr/0010): appointments are clinical - * records, so a patient who has any is never purged after deletion. */ -export function hasAppointmentsForPatient(patientProfileId: string): Promise { - return appointmentsRepo.patientHasAppointments(patientProfileId); -} diff --git a/backend/src/modules/notifications/repositories/index.ts b/backend/src/modules/notifications/repositories/index.ts index 30c17f2a..e35fa62d 100644 --- a/backend/src/modules/notifications/repositories/index.ts +++ b/backend/src/modules/notifications/repositories/index.ts @@ -1,10 +1,49 @@ -// 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) { + // Idempotent enqueue: if a notification with the same dedupeKey already + // exists, return it unchanged instead of creating a duplicate. + const existing = await db.notification.findFirst({ + where: { dedupeKey: input.dedupeKey }, + }); + if (existing) { + return existing; + } + return db.notification.create({ + data: { + userId: input.userId, + kind: input.kind, + channel: input.channel, + dedupeKey: input.dedupeKey, + payload: input.payload, + scheduledAt: input.scheduledAt, + status: 'pending', + }, + }); +} + +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 } }, + }); +} diff --git a/backend/src/modules/notifications/services/index.ts b/backend/src/modules/notifications/services/index.ts index 933eed1c..a82a6c32 100644 --- a/backend/src/modules/notifications/services/index.ts +++ b/backend/src/modules/notifications/services/index.ts @@ -1,15 +1,84 @@ +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 NotificationChannelName = 'email'; + +export interface EnqueueNotificationInput { + /** Recipient user id. */ + userId: string; + kind: NotificationKind; + channel?: NotificationChannelName; + /** 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: NotificationChannelName = '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". // // 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. The default real -// provider is decided in docs/adr/ (see ADR for the email/SMS provider -// decision). Wiring that provider in is a new adapter behind this same -// interface — only the adapter changes, feature code does not. -import { config } from '../../../config'; -import { formatMessage, resolveLocale } from '../../../i18n'; +// and the default implementation is a console adapter. export interface NotificationMessage { /** Destination address — an email for now (the only channel interface we need). */ diff --git a/docs/api/README.md b/docs/api/README.md index c436a26f..93bf9222 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -185,6 +185,44 @@ against `tokenVersion` on every `POST /auth/refresh`. | `GET /appointments/doctor/today` | doctor | — | Caller's scheduled appointments for the current server-local calendar day, ordered by slot start time. | | `PATCH /appointments/:appointmentId/status` | doctor (treating doctor only) | `{ status: 'completed'\|'cancelled'\|'no_show' }` | Issue #3's lifecycle, and the trigger for the referral cascade (#67): if the appointment came from a referral, the referral moves in the same transaction (completed → referral completed; cancelled → referral back to `pending` and the slot released; no-show → count++, pending until `NO_SHOW_EXPIRY_THRESHOLD` (2) then `expired`). `409` unless the appointment is still `scheduled`. | | `PATCH /appointments/:appointmentId/cancel` | patient (own appointment) | — | Patient-side cancellation, `scheduled` only. Same slot-release + cascade behaviour as above. | +| `PATCH /appointments/:appointmentId/reschedule` | patient (own appointment) | `{ availabilitySlotId }` | Moves a `scheduled` appointment to a different open slot in one transaction (old slot released, new slot claimed). `409` if the new slot is taken. Emits an `appointment_rescheduled` notification to the doctor. | + +## Notifications (`/notifications`) — `backend/src/modules/notifications` + +| Method & path | Auth | Body | Notes | +|---|---|---|---| +| `GET /notifications/me` | authenticated | — | Caller's own notifications, most recent first. Respects the caller's notification preferences (no-op default until preferences exist). | +| `PATCH /notifications/:notificationId/read` | authenticated (owner) | — | Marks a notification as read. `404` if it doesn't belong to the caller. | + +### Appointment notifications (Phase 6, issues #17 and #18) + +Appointment notifications are emitted through the transactional outbox in the +same database transaction as the booking / status change, so a committed +booking always produces its notifications and a rolled-back booking never +does. + +Triggers (FR-15 / FR-16): + +| Event | Recipient | When | +|---|---|---| +| `booking_confirmed` | patient | `POST /appointments` commits | +| `new_booking` | doctor | `POST /appointments` commits | +| `appointment_cancelled` | the other party | `PATCH .../status` or `PATCH .../cancel` commits | +| `appointment_rescheduled` | the other party | `PATCH .../reschedule` commits | +| `appointment_reminder_24h` | patient and doctor | reminder job, 24h before start | +| `appointment_reminder_2h` | patient and doctor | reminder job, 2h before start | + +The reminder job (`backend/src/jobs/appointment-reminders.ts`) is idempotent +per `(appointmentId, reminderType)`: a unique constraint on the outbox row +means running it twice in the same window never double-sends. At send time it +re-reads the appointment and skips cancelled / rescheduled / completed ones, so a +reminder for a slot that was given up is never delivered. Reminder copy +uses the recipient's time zone when known, falling back to the appointment +slot's zone otherwise. + +Each user's notification preferences are checked before dispatch; with no +preferences stored the default is to send (no-op default), so existing +accounts begin receiving notifications as soon as this lands. ## Consultations (`/consultations` + `/appointments`) — `backend/src/modules/consultations`