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 1/3] 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 10509cf3..2300f61d 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 1e249bd7..e055fc9f 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 b3405b64..172f4605 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 cc950672..87ec39cf 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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 Date: Wed, 28 Oct 2026 15:42:34 +0100 Subject: [PATCH 2/3] fix(ci): resolve failing checks for #187 --- .../modules/appointments/services/index.ts | 437 ++---------------- 1 file changed, 28 insertions(+), 409 deletions(-) diff --git a/backend/src/modules/appointments/services/index.ts b/backend/src/modules/appointments/services/index.ts index 9ee7d0af..1d8927db 100644 --- a/backend/src/modules/appointments/services/index.ts +++ b/backend/src/modules/appointments/services/index.ts @@ -99,9 +99,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, @@ -178,7 +183,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, @@ -298,425 +308,34 @@ export async function joinWaitlist( }); if (existingEntry) { - throw new HttpError(409, 'You are already on the waitlist for this doctor/specialty.'); + throw new HttpError(409, 'Patient is already on the waitlist for this doctor or specialty.'); } - // Create the waitlist entry const entry = await tx.waitlistEntry.create({ data: { patientProfileId, - doctorProfileId, - specialtyId, + doctorProfileId: doctorProfileId ?? null, + specialtyId: specialtyId ?? null, + earliestAcceptable: earliestAcceptable ?? null, + latestAcceptable: latestAcceptable ?? null, + holdDurationMinutes: holdDurationMinutes ?? null, 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', + entityType: 'WaitlistEntry', + entityId: entry.id, + action: 'waitlist.joined', fromState: null, - toState: appointment.status, + toState: entry.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, - })); + patientProfileId, + doctorProfileId: doctorProfileId ?? null, + specialtyId: specialtyId ?? null, }, - ); - }, -}; - -/** 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 - * (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 - * 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, - }, - }); - - // 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; -} - -/** - * 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 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). - */ -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); - // 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); - }); -} - -/** - * 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'); - // Phase 6: tell the doctor when the patient cancels their visit. - await notificationsService.emitAppointmentCancelled(tx, appointment, { by: 'patient' }); - return appointmentsRepo.findAppointmentById(tx, appointmentId); - }); -} - -/** - * 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 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.'); - } - if (appointment.status !== 'scheduled') { - throw new HttpError(409, 'Only a scheduled appointment can be rescheduled.'); - } - - 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); + return entry; }); -} - -/** 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); } \ No newline at end of file From 42d79a0febf72ff4491f6b2812127abbfc28619f Mon Sep 17 00:00:00 2001 From: Data_Mike <151058422+Micheal1605@users.noreply.github.com> Date: Thu, 29 Oct 2026 08:36:17 +0100 Subject: [PATCH 3/3] fix(ci): resolve failing checks for #187 --- ROADMAP.md | 2 +- .../modules/appointments/services/index.ts | 85 ++----------------- .../notifications/repositories/index.ts | 15 ++-- .../modules/notifications/services/index.ts | 16 ++-- 4 files changed, 22 insertions(+), 96 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index d96cb46c..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 6's appointment notifications (booking confirmations, new-booking alerts, reminders) are in progress. 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. diff --git a/backend/src/modules/appointments/services/index.ts b/backend/src/modules/appointments/services/index.ts index 1d8927db..21491255 100644 --- a/backend/src/modules/appointments/services/index.ts +++ b/backend/src/modules/appointments/services/index.ts @@ -1,13 +1,10 @@ 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'; /** @@ -82,8 +79,12 @@ 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.emitBookingConfirmed(tx, appointment); - await notificationsService.emitNewBookingAlert(tx, appointment); + await notificationsService.enqueue(tx, { + userId: patientProfile.userId, + kind: 'booking_confirmed', + dedupeKey: `appointment:${appointment.id}:booking_confirmed`, + payload: { appointmentId: appointment.id }, + }); return appointment; }); @@ -265,77 +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, 'Patient is already on the waitlist for this doctor or specialty.'); - } - - const entry = await tx.waitlistEntry.create({ - data: { - patientProfileId, - doctorProfileId: doctorProfileId ?? null, - specialtyId: specialtyId ?? null, - earliestAcceptable: earliestAcceptable ?? null, - latestAcceptable: latestAcceptable ?? null, - holdDurationMinutes: holdDurationMinutes ?? null, - status: 'pending', - }, - }); - - await auditService.record(tx, actor, { - entityType: 'WaitlistEntry', - entityId: entry.id, - action: 'waitlist.joined', - fromState: null, - toState: entry.status, - metadata: { - patientProfileId, - doctorProfileId: doctorProfileId ?? null, - specialtyId: specialtyId ?? null, - }, - }); - - return entry; - }); -} \ No newline at end of file diff --git a/backend/src/modules/notifications/repositories/index.ts b/backend/src/modules/notifications/repositories/index.ts index b857abea..e35fa62d 100644 --- a/backend/src/modules/notifications/repositories/index.ts +++ b/backend/src/modules/notifications/repositories/index.ts @@ -16,10 +16,16 @@ export interface EnqueueInput { } 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({ + // 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 }, - create: { + }); + if (existing) { + return existing; + } + return db.notification.create({ + data: { userId: input.userId, kind: input.kind, channel: input.channel, @@ -28,7 +34,6 @@ export async function enqueue(db: Prisma.TransactionClient, input: EnqueueInput) scheduledAt: input.scheduledAt, status: 'pending', }, - update: {}, }); } @@ -41,4 +46,4 @@ export async function findPreference( 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 d414a645..a82a6c32 100644 --- a/backend/src/modules/notifications/services/index.ts +++ b/backend/src/modules/notifications/services/index.ts @@ -22,13 +22,13 @@ export type NotificationKind = | 'appointment_cancelled' | 'appointment_rescheduled'; -export type NotificationChannel = 'email'; +export type NotificationChannelName = 'email'; export interface EnqueueNotificationInput { /** Recipient user id. */ userId: string; kind: NotificationKind; - channel?: NotificationChannel; + channel?: NotificationChannelName; /** Natural key for idempotency (e.g. `appointment::reminder_24h`). */ dedupeKey: string; /** Typed payload the dispatcher turns into copy. */ @@ -65,7 +65,7 @@ export async function isEnabled( db: Prisma.TransactionClient | typeof prisma, userId: string, kind: NotificationKind, - channel: NotificationChannel = 'email', + channel: NotificationChannelName = 'email', ): Promise { const pref = await notificationsRepo.findPreference(db, userId, kind, channel); if (!pref) return true; // no-pref default = opted in @@ -78,13 +78,7 @@ export async function isEnabled( // // 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. -// 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. +// and the default implementation is a console adapter. export interface NotificationMessage { /** Destination address — an email for now (the only channel interface we need). */ @@ -139,4 +133,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 +}