From b15d0dd013ae3c9d0850def32aa22f515a1a562d Mon Sep 17 00:00:00 2001 From: Data_Mike <151058422+Micheal1605@users.noreply.github.com> Date: Tue, 20 Oct 2026 14:04:14 +0100 Subject: [PATCH] feat: [Phase 6] Appointment notifications: booking confirmations, (#98) --- ROADMAP.md | 14 +-- .../modules/appointments/services/index.ts | 95 ++++++++++++++---- .../notifications/repositories/index.ts | 47 ++++++++- .../modules/notifications/services/index.ts | 74 +++++++++++++- docs/api/README.md | Bin 22627 -> 15846 bytes 5 files changed, 199 insertions(+), 31 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 10509cf..2300f61 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,14 +1,14 @@ # ROADMAP.md — DocForum -Last updated: 2026-10-17 -Status: Phase 0, Phase 1, and Phase 5.5 (Payments, custodial v1 — see +Last updated: 2026-10-20 +Status: Phase 0, Phase 1, Phase 5.5 (Payments, custodial v1 — see `docs/adr/0004-custodial-payments-v1.md`) built and tested. Phase 3 (Referrals) is **in progress**: #5, #7 and #65–#68 implemented and 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. @@ -101,9 +101,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 @@ -495,3 +496,4 @@ 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-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. diff --git a/backend/src/modules/appointments/services/index.ts b/backend/src/modules/appointments/services/index.ts index 1e249bd..e055fc9 100644 --- a/backend/src/modules/appointments/services/index.ts +++ b/backend/src/modules/appointments/services/index.ts @@ -7,10 +7,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: @@ -28,6 +29,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.'); @@ -61,6 +67,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; }); } @@ -116,7 +127,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 @@ -172,6 +183,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; } @@ -180,7 +196,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). @@ -230,6 +246,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); }); } @@ -254,28 +274,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 b3405b6..172f460 100644 --- a/backend/src/modules/notifications/repositories/index.ts +++ b/backend/src/modules/notifications/repositories/index.ts @@ -1,3 +1,44 @@ -// notifications repositories — placeholder. Data access only. Not to be imported -// by other modules directly — go through services/ instead. -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 } }, + }); +} diff --git a/backend/src/modules/notifications/services/index.ts b/backend/src/modules/notifications/services/index.ts index cc95067..87ec39c 100644 --- a/backend/src/modules/notifications/services/index.ts +++ b/backend/src/modules/notifications/services/index.ts @@ -1,3 +1,71 @@ -// notifications services — placeholder. Other modules must only import from here, -// never from repositories/ directly. See AGENTS.md "Non-negotiable rules". -export {}; +import { prisma } from '../../../db/prisma-client'; +import type { Prisma } from '@prisma/client'; +import * as notificationsRepo from '../repositories'; + +/** + * 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; +} diff --git a/docs/api/README.md b/docs/api/README.md index 4df31f59c8a3fff9a1b5df2e78d571de0ce15518..a4e183174641c04dbd1763e2d730defdefd32097 100644 GIT binary patch delta 3760 zcmb7{TaO$^6~~!Xu#+YrIl-XBadgO{%w=b0W5V>bjiE z|NQFA2Y+aN^rP1kt8aN4G&VROgIjGVL}!E@_O8A zz5aU9Dz?}9t!s;P?dxl&e5H;;xmeuu*k@axS^V`r|8#NX%ueysGp`oj13z25_Q0P` zt-bw|#rGciMXN={hYxSydFI@Y@BQk5&oP!@*6x)pZ_MF z-#&j6&)=PY4$qJ5^Wvk|TeQd?y|DK1`>*_(vN%wgpRutrphJ=9t~a2?8}uN~Tm1Pe z-)((v-o7nUcp-!KVq-nKCx0O(o%z1hx=(T%8cm}Eb|XIA_f9_Ph>s>SNmX_@!GB3AGY9Hr3S;|Ci>V}&idYN3cp!l46;a$XiimV3bFA}apTD^=Qq`DsY%eNo`m>07PDo7f^a;RDXU5)5-Et3 z;qa(<85vd?byO6TFH;#wp=Hn~1iu{eBM~Ay-64%GJpl!UV|F=TO$d<%aU|m*ZQIP= z>G8@BnCPmzj$Y~WkmjDZzE00O`q$JMZMsYJs7tKDQE_GGi>IqM#lL$u@^5YLQFH3% z)CNvuFgG?_QIPB0E}zpg0vbr$n$#?A7zKe&l{S=0pW_B;YBQu=sT1i#wyZP1Uh7zv z$CRYf1{*4;POJthp+JsB78#U`38TgY`EsZYVmkNcG`h95_sp|*T7T8qJ>Cg6Qz;O? zIW_(1zttRHxX5p>UFe~#e2Ok<*u=MbJXpVX8F3q z1evKgI_C3IZC;xCVXK~!t-t|~Wtx*Mp&PO+5r%6F~Aljl2{R|qfTCtW5ucvPhPc}!(#KiUT+s1ivyYJVXsG9w8Ra) zqz+J?2f|0~!=u#S#0Y1PGzK(Xyfm>Kyg2bvnOB?8fgGz;;)P4AA?*N6n!-R%6NSVY zN{}wc9o`Asl)I4O%rOVjERJ!B5aeuTp)VWopTTZ5vpOf17(QhOksPpjLvQI93D~2v z1of+dJ`%<{KnB>W1?E};J7YzWilHda#cyYMOOQ%YaLZ6Z&pF@pOZ$D598;zN3h)GO zRhk5j?4dtoK+ixeI*L)>Lr%l36W&2>hfBZ1a0NAL8T?}K)@8m)W&Myp5y(PCi?a4`qF2uo(M%j`a90I+##rXY!;Kbv zMp^`=iaUYLG14IB0+=SS|o| zPNx$^TSNLNOf)*9s<}pitkN3b8>Eutkz^hKZfLUjf?ok0cLJQU{i&G%z%x|*VTih- zEa3oE_Q?oisYD~QTVDl?Qm;lS4*dj_=3r@`#-JNt8MfG~{@wA$VwcYKYaJJWcs5G!yX0a~ZAiqsBr~F1tne3%mH1x}%RQ`yvZ(c;E@& zSLiG?PC7(a58q{Yn_asDaT>vfI>hHWb=W*Y(dgmg&E7Tsf%x&p^Y_i|yg(2_8>k literal 22627 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