From a67263f1417628c456bf0b974a013fea744a890f Mon Sep 17 00:00:00 2001 From: Ayodeji Olanrewaju <111335900+GdAyo19@users.noreply.github.com> Date: Sat, 17 Oct 2026 04:33:00 +0000 Subject: [PATCH 1/5] =?UTF-8?q?feat(referrals):=20Phase=203=20referral=20c?= =?UTF-8?q?ore=20=E2=80=94=20creation,=20accept/decline,=20cascade,=20loop?= =?UTF-8?q?=20guard,=20admin=20overrides?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A referral was still a Phase 0 sketch, and the two bug classes it was most likely to get wrong — a double-booked acceptance and a patient stuck bouncing between specialists — had no mechanism behind them at all. Closes issues #5, #7 and #65–#68 (plus the minimal #3/#4 slices they need: appointment status transitions and a one-per-appointment Consultation record). `Referral` now carries an immutable copy of the consultation notes, the lineage parent that loop detection walks, and the no-show/loop/admin bookkeeping; `Appointment.availabilitySlotId` became nullable so a cancellation can release its slot without erasing history; and `AuditEvent` is a new append-only table for the admin reassign/cancel actions. Accept books a real appointment through the same claimOpenSlot conditional UPDATE as a direct booking — proven by 8 genuinely concurrent accepts, exactly one of which wins — and appointment status transitions cascade back into the referral inside the same transaction, so a referral can never be left accepted behind a dead appointment. Repeat doctors or specialties anywhere in a chain are flagged for the admin queue rather than silently allowed; chains over the hop limit are hard rejections. Two deliberate re-scopes for review, not silent defaults: a concrete verified target_doctor_id is required, so the routing gap (#6) is closed for v1 by making unassigned referrals impossible — which also makes PRD §8.2.2's distinct "waiting for a specialist" state unreachable rather than implemented, and the ADR is still owed; and snapshotIntakeId has no column because IntakeForm doesn't exist yet (#1), so the intake half of the snapshot rule is noted as unenforceable rather than faked. Time-based referral expiry (#8) is likewise still open — only the no-show path reaches expired. Docs reconciled in the same change (ARCHITECTURE §4.2–§4.4/§4.10/§5.2/§6, ARCHITECTURE_ESSENTIALS, PRD §8.1.2–§8.1.4 and §8.2.1–§8.2.4, ROADMAP Phase 2/3 + changelog, docs/api/README.md, README, docs site). Tests: 42 unit + 55 integration against real embedded Postgres. --- ARCHITECTURE.md | 126 ++++- ARCHITECTURE_ESSENTIALS.md | 14 +- PRD.md | 14 +- README.md | 14 +- ROADMAP.md | 81 ++- .../migration.sql | 102 ++++ backend/prisma/schema.prisma | 123 +++- backend/src/app.ts | 4 + .../modules/appointments/controllers/index.ts | 20 +- .../appointments/repositories/index.ts | 39 +- .../src/modules/appointments/routes/index.ts | 16 + .../modules/appointments/services/index.ts | 150 ++++- .../src/modules/appointments/validation.ts | 4 + .../availability/repositories/index.ts | 15 + .../modules/availability/services/index.ts | 7 +- .../consultations/controllers/index.ts | 31 +- .../consultations/repositories/index.ts | 25 +- .../src/modules/consultations/routes/index.ts | 29 +- .../modules/consultations/services/index.ts | 75 ++- .../src/modules/consultations/validation.ts | 12 + .../src/modules/doctors/repositories/index.ts | 7 + backend/src/modules/doctors/services/index.ts | 34 ++ .../modules/patients/repositories/index.ts | 4 + .../src/modules/patients/services/index.ts | 6 + .../modules/referrals/controllers/index.ts | 88 ++- backend/src/modules/referrals/loop.ts | 104 ++++ .../modules/referrals/repositories/index.ts | 214 ++++++- backend/src/modules/referrals/routes/index.ts | 78 ++- .../src/modules/referrals/services/index.ts | 523 +++++++++++++++++- backend/src/modules/referrals/validation.ts | 40 ++ .../integration/admin-referrals.test.mts | 282 ++++++++++ .../tests/integration/helpers/fixtures.mts | 229 ++++++++ .../integration/referral-cascade.test.mts | 196 +++++++ .../integration/referral-foundation.test.mts | 285 ++++++++++ .../tests/integration/referral-loops.test.mts | 220 ++++++++ .../tests/integration/referral-multi.test.mts | 186 +++++++ backend/tests/unit/referral-loop.test.ts | 78 +++ .../tests/unit/referral-validation.test.ts | 50 ++ docs/api/README.md | 96 +++- docs/site/repos/core.md | 4 +- 40 files changed, 3513 insertions(+), 112 deletions(-) create mode 100644 backend/prisma/migrations/20261016214929_referral_phase3/migration.sql create mode 100644 backend/src/modules/consultations/validation.ts create mode 100644 backend/src/modules/referrals/loop.ts create mode 100644 backend/src/modules/referrals/validation.ts create mode 100644 backend/tests/integration/admin-referrals.test.mts create mode 100644 backend/tests/integration/helpers/fixtures.mts create mode 100644 backend/tests/integration/referral-cascade.test.mts create mode 100644 backend/tests/integration/referral-foundation.test.mts create mode 100644 backend/tests/integration/referral-loops.test.mts create mode 100644 backend/tests/integration/referral-multi.test.mts create mode 100644 backend/tests/unit/referral-loop.test.ts create mode 100644 backend/tests/unit/referral-validation.test.ts diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index d45e864..551bdde 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -48,8 +48,8 @@ Each module under `backend/src/modules//` owns its own `routes/`, `control - `facilities` — lab/pharmacy accounts, capability tags (what order types they can fulfill). - `availability` — slot generation and holds (the module responsible for preventing double-booking). - `appointments` — booking lifecycle, links patient + doctor + slot + intake. -- `consultations` — the record of what happened in a booked appointment (notes, outcome). -- `referrals` — referral creation, lifecycle, snapshot of context. +- `consultations` — the record of what happened in a booked appointment (notes, outcome). Minimal slice is real (create against one's own completed appointment, read for that doctor + the patient, spawn referrals); sign-off/addenda stay issue #4. +- `referrals` — referral creation, lifecycle, snapshot of context, loop flags, the appointment-status cascade, and the admin queue/override operations. This is the most cross-wired module: it creates appointments through `appointments`' service, and `appointments` calls back into `referrals`' service (`applyAppointmentOutcome`) from inside its status-transition transaction. That mutual service-to-service import is deliberate and runtime-safe under ESM live bindings (every cross-call happens in an async function, after both modules have loaded) — the alternative, either module reaching into the other's repository, is what hard rule 4 exists to prevent. - `prescriptions` — prescription issuance and versioning. - `lab-orders` — lab order issuance and versioning. - `pharmacy-orders` — fulfillment-side record for prescriptions at a specific pharmacy (kept distinct from `prescriptions` itself — see data model note in §4.7). @@ -95,7 +95,9 @@ bounded by one page of rows plus one chunk regardless of history length. Canonical source of truth: `backend/prisma/schema.prisma`. This section is the human-readable mirror — if they drift, the schema file wins and this doc should be corrected in the same PR. -**Phase 1 models are now real** (User, PatientProfile, DoctorProfile, Specialty, DoctorSpecialty, FacilityProfile, FacilityCapability, AvailabilitySlot, Appointment) — migrated via `backend/prisma/migrations/20260914110638_init/`. Everything else below (§4.3 onward) is still the Phase 0 structural placeholder. Two intentional deviations from the sketches below, both flagged in the schema file itself: `Appointment.intakeId` is deferred (nullable-by-omission, not yet a column) since `IntakeForm` doesn't exist until Phase 2; `PatientProfile.guardianUserId` is a plain string with no FK relation, since the minor/dependent consent model (§8.2.5 in PRD.md) is still an open question, not resolved here. +**Phase 1 models are real** (User, PatientProfile, DoctorProfile, Specialty, DoctorSpecialty, FacilityProfile, FacilityCapability, AvailabilitySlot, Appointment) — migrated via `backend/prisma/migrations/20260914110638_init/`. **Phase 3 added Consultation, Referral and AuditEvent** — migrated via `backend/prisma/migrations/20261016214929_referral_phase3/`; §4.3, §4.4 and §4.10 describe what actually shipped, not a sketch. Everything else below (§4.5 onward) is still the Phase 0 structural placeholder. + +Three intentional deviations from the original sketches, all flagged in the schema file itself: `Appointment.intakeId` is deferred (nullable-by-omission, not yet a column) since `IntakeForm` doesn't exist until Phase 2; `PatientProfile.guardianUserId` is a plain string with no FK relation, since the minor/dependent consent model (§8.2.5 in PRD.md) is still an open question, not resolved here; and `Referral.snapshotIntakeId` likewise has no column yet (there is no `IntakeForm` to snapshot) — the intake half of the snapshot rule cannot be violated while there is no intake data at all, and the column must be added (as a copy) when issue #1 lands. ### 4.1 User / Role model ``` @@ -143,11 +145,18 @@ AvailabilitySlot # to make double-booking a constraint violation, not an app-logic bug. Appointment - id, patient_profile_id, doctor_profile_id, availability_slot_id (FK, unique) - intake_id (FK -> IntakeForm) + id, patient_profile_id, doctor_profile_id, availability_slot_id (FK, unique, NULLABLE) + intake_id (FK -> IntakeForm — still not a column, Phase 2 issue #1/#2) status (enum: scheduled|completed|cancelled|no_show) origin (enum: direct_booking|referral), referral_id (nullable FK -> Referral) created_at, updated_at + # availability_slot_id is nullable (Phase 3): a cancelled appointment + # releases its slot back to `open` and drops the unique link, so the + # freed slot can be re-booked. The booking guarantee itself is + # unchanged — it lives in claimOpenSlot's conditional UPDATE, not in + # this column. History rows keep their referral_id, so the lineage of a + # referral that produced several appointments survives the link being + # cleared (Referral.appointments holds the full list). IntakeForm id, patient_profile_id, symptoms (structured: array of {symptom, duration, severity}), free_text_notes @@ -157,25 +166,81 @@ IntakeForm ### 4.3 Consultation ``` Consultation - id, appointment_id (FK, unique) + id, appointment_id (FK, unique — one consultation per appointment; + a re-visit is a new appointment, not a second row) notes [ENCRYPTED — Phase 8 issue #46, see §2 field-level encryption row] outcome (enum: resolved|referred|follow_up_needed) - created_at + created_at, updated_at + referrals: Referral[] # children — the lineage loop detection walks ``` **Encryption note:** `notes` is stored as an AES-256-GCM envelope (`v1:::`) via `backend/src/utils/field-encryption.ts`. Because it's encrypted, it **cannot be searched or indexed** — any future full-text search over consultation notes must decrypt in the application layer or use a separate searchable index, not a Postgres index on this column. Key rotation: `backend/scripts/rotate-field-keys.mjs` re-encrypts in batches. +Real as of Phase 3 (issue #5's minimal slice): created by the treating +doctor against their own **completed** appointment, and readable only by +that doctor and the patient of the underlying appointment. What is still +open is issue #4's fuller workflow — sign-off, addenda, a doctor editing +notes after the fact — which is exactly why the referral snapshot (§4.4) +exists rather than referrals reading this row live. + ### 4.4 Referral ``` Referral - id, source_consultation_id (FK) - referring_doctor_id, target_specialty_id, target_doctor_id (nullable) - snapshot_intake_id (FK -> IntakeForm, copy reference, immutable) + id + patient_profile_id (FK -> PatientProfile) + source_consultation_id (FK -> Consultation, required) + referring_doctor_id, target_doctor_id (both FK -> DoctorProfile, required) + target_specialty_id (nullable FK -> Specialty — denormalised label, used + by the loop guard and admin views; not issue #23's specialty management) snapshot_notes [ENCRYPTED — Phase 8 issue #46] (text copy of the referring consultation notes at time of referral — immutable) - status (enum: pending|accepted|declined|completed|expired) - resulting_appointment_id (nullable FK -> Appointment) + parent_referral_id (nullable self-FK — the referral whose appointment + produced the consultation this referral came from; this is the chain + loop detection walks) + status (enum: pending|accepted|declined|completed|expired|cancelled) + no_show_count (Int, default 0), referring_doctor_notified_at (nullable) + loop_flagged_at (nullable), loop_flag_reason (nullable text) + cancelled_reason (nullable text) + resulting_appointment_id (nullable FK -> Appointment, unique — the + appointment this referral *currently* produced) created_at, updated_at + indexes: patient_profile_id, referring_doctor_id, target_doctor_id, + status, loop_flagged_at +``` +Design note: `snapshot_notes` deliberately duplicates `Consultation.notes` +rather than pointing live at it, so a later edit to the original +consultation can never silently change what the specialist already acted +on (PRD hard question 8.1.4). `snapshotIntakeId` from the original design +has no column yet — see the §4 intro note. + +The other six fields are all Phase 3 issue work: `parentReferralId` + +`loop_flagged_*` are loop detection (#65), `no_show_count` + +`referring_doctor_notified_at` are the no-show cascade (#67), +`cancelled_reason` is admin override (#68), and `target_doctor_id` being +**required** is the explicit v1 re-scope of the routing gap (§6.1.2 — the +ADR for specialty-only routing is still owed, issue #6). + +Status transitions that exist today: +`pending → accepted` (patient accept, conditional UPDATE — the same +concurrency shape as `claimOpenSlot`), `pending → declined` (patient), +`accepted → completed` (referral-origin appointment completed), +`accepted → pending` (appointment cancelled or first no-show — bookable +again, same snapshot, no re-intake), `accepted → expired` (second +no-show, threshold `NO_SHOW_EXPIRY_THRESHOLD = 2`), and +`pending|accepted → cancelled` (admin, audited — `AuditEvent`, §4.10). + +``` +pending ──accept──▶ accepted ──appointment completed──▶ completed + │ │ + │ ├─appointment cancelled──▶ pending (slot released; same snapshot) + │ ├─no-show #1─────────────▶ pending (no_show_count = 1) + │ ├─no-show #2 (=threshold)─▶ expired (referring_doctor_notified_at set) + │ └─admin cancel────────────▶ cancelled (reason; audited; live appt closed) + ├─decline──────────▶ declined + └─admin cancel──────▶ cancelled ``` -Design note: `snapshot_notes` and `snapshot_intake_id` deliberately duplicate data rather than pointing live at `Consultation.notes`, so a later edit to the original consultation can never silently change what the specialist already acted on (PRD hard question 8.1.4). +Every arrow into `pending`/`completed`/`expired` from `accepted` is a +conditional UPDATE that only matches a referral still `accepted` *and +still pointing at the appointment that triggered it*, so a doubled hook +is a no-op (issue #67, integration-tested). ### 4.5 Prescriptions (versioned, append-only) ``` @@ -244,7 +309,7 @@ LoginAttempt (issue #35) ``` AuditEvent id, actor_user_id (nullable — null = system), actor_role (nullable Role) - entity_type ('Appointment' | 'DoctorProfile' | 'PaymentIntent' | 'Consultation' | 'Prescription' | 'LabOrder'), entity_id + entity_type ('Appointment' | 'DoctorProfile' | 'PaymentIntent' | 'Consultation' | 'Prescription' | 'LabOrder' | 'Referral'), entity_id action (dotted, e.g. 'appointment.booked'), from_state, to_state (nullable) request_id (X-Request-Id of the causing request), metadata (jsonb, nullable) created_at @@ -265,10 +330,24 @@ AuditEvent # indexes: (entity_type, entity_id, created_at), (actor_user_id, created_at), (created_at, id) — the last backs the admin cursor. ``` +There is **one** audit trail in this schema. Phase 3's referral admin +actions (#68) do not get their own table — they are `entity_type = +'Referral'` rows in this one, written through the audit module's +`record()` in the same transaction as the referral change, so the trigger +that makes the trail append-only covers them too. + Transitions recorded (action: from → to): - `appointment.booked`: `null → scheduled` (metadata: slot id, doctor profile id, origin) - `doctor.verification_status_changed`: previous → new verification status (read under `FOR UPDATE`; a no-op set records nothing) - `payment_intent.funded` `created → escrowed`, `payment_intent.released` `escrowed → released`, `payment_intent.refunded` `escrowed → refunded` (metadata: order type, amount, escrow id, tx hash, contract id — public on-chain references only). The status write is now conditional on the observed state, so a racing second action gets `409` and records nothing. +- `referral.reassign`: `null → null` — the referral is retargeted, not + state-moved, so the transition is carried in the metadata (from/to + doctor profile ids + the admin's reason, ≤ 200 chars) rather than in + from_state/to_state. +- `referral.cancel`: previous status → `cancelled` (metadata: the admin's + reason). The live appointment of an accepted referral is closed in the + same transaction (through the appointments service). + ### 4.11 Patient data export (Phase 8, issue #40) ``` PatientExport @@ -331,7 +410,6 @@ to the caller's own profile (resolved from the authenticated user, never from the request) and strips all viewer PII to a role — a deleted viewer surfaces as `deleted_user`. - ## 5. Key flows (sequence-level, not code) ### 5.1 Booking (concurrency-safe) @@ -339,11 +417,13 @@ surfaces as `deleted_user`. 2. Client attempts to book a specific `AvailabilitySlot` → backend runs this inside a DB transaction with a row-level lock / conditional update (`UPDATE ... WHERE status = 'open'`), so a second concurrent booking attempt on the same slot fails atomically instead of racing. 3. On success, `Appointment` is created referencing the now-`booked` slot. -### 5.2 Referral -1. Doctor closes a `Consultation` with outcome `referred`, selects `target_specialty` (+ optional specific doctor). -2. Backend creates `Referral` with an immutable snapshot of intake + notes. -3. Patient is notified, must **accept** (PRD OQ-3) before a new `Appointment` is generated against the target doctor's availability — accepting reuses the snapshot, so the patient does not re-fill intake. -4. If no specialist is available in that specialty (PRD edge case 8.2.2), referral stays `pending` and surfaces to the patient as "waiting for a specialist" rather than silently failing — **not yet implemented, tracked in ROADMAP.** +### 5.2 Referral (Phase 3 — issues #5/#7/#65–#68) +1. Doctor records a `Consultation` against their own **completed** appointment, then creates one or more `Referral`s from it (`POST /referrals`, or the atomic batch `POST /referrals/from-consultation/:id` / `POST /consultations/:id/referrals`) — each target gets its own `snapshot_notes` copy. Multi-referral is a deliberate decision recorded in `docs/adr/0006-multiple-referrals-per-consultation.md`: independent lifecycles born in one transaction, grouped only for presentation. v1 requires a **concrete, verified** `target_doctor_id`; a "specialty, no doctor" referral is the still-open routing gap (§6.1.2), not something this path silently defaults. +2. Creation is one transaction: gates (own consultation, completed appointment, no self-referral, target verified [+ in specialty], no duplicate target in this consultation), lineage lookup (`parent_referral_id` via consultation → appointment → referral), then the loop walk (PRD §8.1.2 / issue #65) — a repeat doctor/specialty in the chain **flags** the referral for the admin queue rather than blocking it; exceeding the hop limit (`REFERRAL_MAX_HOPS`, default 4) hard-rejects with 422. +3. Patient must **accept** (PRD OQ-3): `POST /referrals/:id/accept` does a conditional `pending → accepted` UPDATE (exactly one concurrent accept wins) and books a real `Appointment` against the target doctor through the appointments service — same `claimOpenSlot` path as a direct booking, `origin: 'referral'`, patient picks the slot, and no re-intake happens because the snapshot travels on the referral. Decline is `pending → declined`, terminal. +4. The appointment's own status transition then cascades back into the referral inside the same transaction (issue #67): `completed →` referral `completed`; `cancelled →` referral back to `pending` (slot released, re-bookable, same snapshot); `no_show →` count++ and back to `pending`, except at `NO_SHOW_EXPIRY_THRESHOLD` (2) where it becomes `expired` with `referring_doctor_notified_at` set. Every one of these writes is conditional on the referral still being `accepted` and still pointing at that appointment, so a hook firing twice is a no-op. +5. Admin override (issue #68): `GET /referrals/admin/queue` (stuck/aged/loop-flagged, with a derived reason), reassign a **pending** referral to another verified doctor (target column only — snapshot untouched), cancel a `pending`/`accepted` referral with a reason (closing its live appointment through the appointments service). Reassign and cancel each write an `AuditEvent` (§4.10), readable at `GET /referrals/admin/:id/audit-events`. +6. The "zero verified doctors in that specialty" dead end (PRD edge case 8.2.2) **cannot be constructed** in v1: a referral cannot exist without naming a verified target doctor (and being verified in the claimed specialty when one is claimed). The specialty-only case — where the patient would wait for *any* specialist — remains the §6.1.2 routing ADR (issue #6), not an implemented "waiting" state. ### 5.3 Order issuance and fulfillment 1. Specialist issues `Prescription` and/or `LabOrder` from a `Consultation`. @@ -356,16 +436,16 @@ surfaces as `deleted_user`. ### 6.1 What do we think would break? 1. Naive slot booking without the DB-level conditional update in §5.1 — the most likely first bug filed against this repo. **Resolved 2026-09-14:** implemented exactly as designed in §5.1 — see `backend/src/modules/availability/repositories/index.ts` (`claimOpenSlot`) and `backend/src/modules/appointments/services/index.ts` (`bookSlot`, the transaction wrapping it). Integration-tested with real concurrency in `backend/tests/integration/booking.test.mts`. -2. `Referral.target_doctor_id` left nullable with no fallback assignment logic — referrals to "a specialty" with no specific doctor need a real routing rule (round robin? load-based?) or they'll pile up unassigned. **Not designed yet — flag as open architecture gap.** +2. `Referral.target_doctor_id` left nullable with no fallback assignment logic — referrals to "a specialty" with no specific doctor need a real routing rule (round robin? load-based?) or they'll pile up unassigned. **Re-scoped for v1, 2026-10-17 (Phase 3):** rather than ship a nullable column with no assignment logic (the failure mode this item predicts), `target_doctor_id` is **required** — a referral cannot be created without a concrete, verified target doctor, so nothing can pile up unassigned. The routing ADR for specialty-only referrals is still owed (issue #6) and the gap stays open until it lands; note also that this makes PRD §8.2.2's "zero verified doctors in the specialty" state unreachable-by-construction rather than surfaced as a distinct "waiting for a specialist" state, which is a deliberate scope reduction to flag in review. 3. Polymorphic `FulfillmentRecord.order_id` (§4.7) — polymorphic FKs are a known footgun for referential integrity in Postgres. Needs either two separate tables (`PrescriptionFulfillment`, `LabOrderFulfillment`) or a check-constraint + application-level guard. **Current schema sketch is a simplification that should be revisited before implementation**, not treated as final. 4. JWT refresh rotation without a revocation list — a stolen refresh token has no clean kill switch until this is added. 5. **Discovered 2026-10-15 while building email verification (Phase 8, issue #34):** the email/SMS provider is still undecided (Phase 6, issue #17), so verification links can't actually be delivered in a hosted environment yet — `backend/src/modules/auth/services/email-dispatcher.ts` is a no-op seam until a provider is integrated. Until then `backend/scripts/mark-email-verified.mjs` is the documented out-of-band path for an operator with DB access. Not a reason to skip the guard: the guard is what makes the flow safe once delivery exists, and shipping it locked-off would just mean shipping it untested. ### 6.2 What edge cases are we missing (architecture-level)? 1. What happens to `AvailabilitySlot.status = 'held'` if a booking transaction fails/times out mid-flight — is there a TTL to release the hold? (Needed or slots leak into permanent limbo.) -2. Referral `expired` status exists in the enum but no expiry job is designed yet — orphaned referrals (PRD 8.1.3 sibling problem). +2. Referral `expired` status exists in the enum but no expiry job is designed yet — orphaned referrals (PRD 8.1.3 sibling problem). **Partly addressed 2026-10-17 (Phase 3, issue #67):** repeated no-shows now drive a referral to `expired` (`NO_SHOW_EXPIRY_THRESHOLD = 2`, with `referring_doctor_notified_at` set as the seam Phase 6's notification layer will read). What is still missing is **time-based** expiry — a pending referral nobody ever accepts, or one whose target doctor never offers a slot, has no TTL and no worker. Tracked as issue #8; per §6.3.2 it should ship as a simple interval/cron, not a queue. 3. No schema support yet for a doctor working across multiple facilities/locations (PRD edge case 6) — `AvailabilitySlot` currently assumes one calendar per doctor. -4. No audit-log table — every FR around auditability (PRD §7) currently relies on `created_at/updated_at` timestamps only, which is not sufficient for "who changed what." A dedicated `AuditEvent` table is likely needed before this ships to real patients. **Foundation resolved 2026-10-17 (issue #43):** `AuditEvent` (§4.10) + `audit.record()` in the same transaction as the change, append-only at the DB level, wired into booking, doctor verification and payment fund/release/refund. **Still open:** Phase 3/4 transitions (referrals, prescriptions, lab orders, fulfillment) must be wired as they're built; the admin login-throttle clear (`/auth/login-attempts/clear`) still only logs to the console; the app connects as the database owner, which could disable the triggers — enforcing append-only against the owner needs a separate least-privilege DB role (deployment work, issue #22). +4. ~~No audit-log table~~ **Foundation resolved 2026-10-17 (issue #43):** `AuditEvent` (§4.10) + `audit.record()` in the same transaction as the change, append-only at the DB level, wired into booking, doctor verification, payment fund/release/refund and the referral admin actions (`referral.reassign`, `referral.cancel` — Phase 3, issue #68, written as `entity_type = 'Referral'` rows in the same table). **Still open:** the remaining Phase 4 transitions (prescriptions, lab orders, fulfillment), the admin login-throttle clear (`/auth/login-attempts/clear`) still only logs to the console; the app connects as the database owner, which could disable the triggers — enforcing append-only against the owner needs a separate least-privilege DB role (deployment work, issue #22). PRD §7 auditability is therefore "a real mechanism exists" rather than "auditability is done"; remaining coverage stays an open gap. 5. CSRF on the cookie-authenticated routes — the refresh cookie has to be `SameSite=None; Secure` (the Pages frontend is a different site — see `docs/adr/0003`), which means any site the patient visits can make their browser send it to `/auth/refresh` and `/auth/logout`. **Resolved 2026-10-15 (issue #37):** a per-session CSRF token is bound into the refresh token and required in the `X-CSRF-Token` header, the `Origin` header is validated against an explicit allowlist, and CORS is now a per-environment allowlist (`CORS_ORIGINS`) instead of one configured string. The double-submit cookie pattern — the usual answer — is *not* usable here: a cookie set by this API is a third-party cookie to `docforum.github.io`, so the frontend's JavaScript can never read it; the token travels in the login/refresh response body instead. A request with no `Origin` (curl, server-to-server) is allowed through the Origin check but still needs the token. See `backend/src/middleware/csrf.ts`, and `docs/api/README.md` for what the client must send. Consequence: refresh cookies issued before this change carry no `csrf` claim and are rejected, i.e. existing sessions need one re-login. ### 6.3 What's overengineered (architecture-level, for v1)? diff --git a/ARCHITECTURE_ESSENTIALS.md b/ARCHITECTURE_ESSENTIALS.md index a1ff8c0..a3f9c60 100644 --- a/ARCHITECTURE_ESSENTIALS.md +++ b/ARCHITECTURE_ESSENTIALS.md @@ -22,11 +22,11 @@ Quick-reference only. Full reasoning lives in `ARCHITECTURE.md`. Full product sc ## Data model map (see ARCHITECTURE.md §4 for full field lists) `User` → `PatientProfile | DoctorProfile | FacilityProfile`; `User` → `AdminInvite` (Phase 8, issue #39 — hashed/expiring/single-use; `User.deactivatedAt` soft-deactivates an account, refusing login/refresh/access tokens without deleting anything) `DoctorProfile` —< `DoctorSpecialty` >— `Specialty` -`AvailabilitySlot` (doctor's calendar) → `Appointment` → `IntakeForm`, `Consultation` -`Consultation` → (outcome=referred) → `Referral` (snapshot!) → new `Appointment` +`AvailabilitySlot` (doctor's calendar) → `Appointment` (`availability_slot_id` nullable — a cancelled appointment releases its slot; booking safety is `claimOpenSlot`, not that column) → `IntakeForm`, `Consultation` +`Consultation` → (outcome=referred) → `Referral` (snapshot! required `target_doctor_id`, self-FK lineage `parent_referral_id`, no-show/loop/admin flags) → new `Appointment` `Appointment` → `Consultation` → immutable `Prescription`(+`PrescriptionItem`) / `LabOrder`(+`LabResult`) `FulfillmentRecord` (facility-owned) references an order but doesn't mutate it. -`User` → `EmailVerificationToken` (Phase 8, issue #34 — hashed/expiring, single-use; `User.emailVerifiedAt` gates booking + payment mutations, never login/browsing). +`User` → `EmailVerificationToken` (Phase 8, issue #34 — hashed/expiring, single-use; `User.emailVerifiedAt` gates booking, consultation/referral mutations and payment mutations, never login/browsing). `User` → `AccessLog` (Phase 8, issue #45 — PHI read-access log: who viewed which clinical record, when; fire-and-forget writes, fail-open). `LoginAttempt` (Phase 8, issue #35 — Postgres-backed login throttle buckets, one per normalised email and per client IP; counters move only via a conditional upsert). `PatientProfile` → `PatientExport` → `PatientExportChunk` (Phase 8, issue #40 — right-of-access export jobs + their chunked JSON bundle; each module contributes a section via its own service, registered in `src/export-sections.ts` — see ARCHITECTURE.md §3.1). @@ -40,10 +40,12 @@ Quick-reference only. Full reasoning lives in `ARCHITECTURE.md`. Full product sc - The actual Stellar contract calls go through `@docforum/escrow-sdk` (`backend/src/modules/payments/services/escrow-sdk-loader.ts` — dynamic `import()`, the SDK is ESM-only and this backend is CommonJS). Never reimplement contract-calling logic in this repo. ## Known unresolved design gaps (do not silently "fix" without flagging in PR description) -- No routing rule yet for a referral with no specific target doctor (which specialist gets it?). -- No expiry/TTL job for held slots or pending referrals — currently just an enum value with no worker. +- No routing rule yet for a referral with no specific target doctor (which specialist gets it?). **v1 re-scope (2026-10-17): `target_doctor_id` is required, so unassigned referrals can't exist — but that makes PRD §8.2.2's "waiting for a specialist" state unreachable rather than implemented. ADR still owed (issue #6).** +- No time-based expiry/TTL job for held slots or pending referrals — the referral *no-show* cascade (#67) now reaches `expired`, but nothing expires a referral (or a held slot) purely by age. Issue #8 / #19. +- `AuditEvent` exists (§4.10 of ARCHITECTURE.md) and records booking, doctor verification, payment fund/release/refund and the referral admin actions — auth (the admin login-throttle clear) and the remaining Phase 4 order transitions are still unaudited. PRD §7 is not satisfied yet. +- `Referral.snapshotIntakeId` has no column — `IntakeForm` doesn't exist yet (issue #1); the notes half of the snapshot rule is real and enforced. - Polymorphic `FulfillmentRecord.order_id` is a sketch, not final — may need splitting into two tables. -- `AuditEvent` foundation exists (Phase 8, issue #43 — ARCHITECTURE.md §4.10): append-only (DB triggers), written by `auditService.record(tx, …)` **inside the same transaction** as the change. Wired for booking, doctor verification and payment fund/release/refund only — **Phase 3/4 transitions must call it when they're built.** Never put PHI in `metadata`. +- `AuditEvent` foundation exists (Phase 8, issue #43 — ARCHITECTURE.md §4.10): append-only (DB triggers), written by `auditService.record(tx, …)` **inside the same transaction** as the change. Wired for booking, doctor verification, payment fund/release/refund and referral admin actions (`entity_type: 'Referral'`) — **Phase 4 transitions must call it when they're built.** There is one audit table: referral admin actions go into it, not into a second one. Never put PHI in `metadata`. - No email/SMS provider integrated yet (Phase 6, issue #17) — email verification (issue #34) therefore can't deliver its link on a hosted deployment; `backend/scripts/mark-email-verified.mjs` is the interim operator path. - One doctor = one calendar assumed; multi-facility doctors not modeled. diff --git a/PRD.md b/PRD.md index 79c5e26..45493a0 100644 --- a/PRD.md +++ b/PRD.md @@ -100,17 +100,17 @@ The core value proposition: **collapse a multi-visit, multi-queue hospital journ ### 8.1 What do we think would break? 1. **Double-booking under concurrency** — two patients booking the same slot simultaneously without a locking/transaction strategy at the DB level. This is the single most likely v1 bug class. **Resolved 2026-09-14:** `availability/repositories.ts`'s `claimOpenSlot` (a conditional `UPDATE ... WHERE status = 'open'`) makes this a DB-level guarantee, not an app-logic one. Proven by `backend/tests/integration/booking.test.mts` — 8 genuinely concurrent booking requests on the same slot, exactly 1 succeeds, verified against a real Postgres instance every run. -2. **Referral loops** — Doctor A refers to specialty B, specialist in B refers back to A or sideways to C, with no cycle detection or max-hop limit → patient stuck in a referral loop with no human-visible "this isn't working" signal. -3. **Orphaned orders** — a prescription/lab order issued, but the patient never selects a fulfilling facility, and there is no reminder/expiry logic → orders silently rot. -4. **Referral context loss** — if referral doesn't strictly carry forward intake + notes (FR-7/FR-8 not enforced at the data layer), the whole value proposition (no re-explaining) quietly breaks and nobody notices until a patient complains. +2. **Referral loops** — Doctor A refers to specialty B, specialist in B refers back to A or sideways to C, with no cycle detection or max-hop limit → patient stuck in a referral loop with no human-visible "this isn't working" signal. **Resolved 2026-10-17 (Phase 3, issue #65):** every referral carries a lineage (`parent_referral_id`, derived consultation → appointment → referral), the creation transaction walks that chain with a bounded CTE (default `REFERRAL_MAX_HOPS = 4`), and a target doctor or specialty that already appears **anywhere** in the chain — including the original referring GP — is *flagged* (`loop_flagged_at`/`loop_flag_reason`) and surfaced in the admin queue rather than silently allowed or hard-blocked (a human decides whether the repeat is clinically legitimate). Self-referral and hop-limit overflow are hard `422` rejections. Unit-tested (`tests/unit/referral-loop.test.ts`) and integration-tested end-to-end (`tests/integration/referral-loops.test.mts`). +3. **Orphaned orders** — a prescription/lab order issued, but the patient never selects a fulfilling facility, and there is no reminder/expiry logic → orders silently rot. **Referral half partly addressed 2026-10-17 (issue #67):** repeated no-shows on a referral-origin appointment now expire the referral and flag the referring doctor. **Still open:** time-based expiry (a pending referral nobody accepts, a held slot nobody books, and orders generally) has no job yet — issues #8 and #19; orders themselves stay Phase 4. +4. **Referral context loss** — if referral doesn't strictly carry forward intake + notes (FR-7/FR-8 not enforced at the data layer), the whole value proposition (no re-explaining) quietly breaks and nobody notices until a patient complains. **Notes half resolved 2026-10-17 (issue #5):** `Referral.snapshotNotes` is a copy written in the creation transaction and there is no consultation-editing path that could reach it; acceptance re-books through the real slot-claim path carrying the same snapshot, so no re-intake happens. The **intake** half is not enforceable yet — `IntakeForm` doesn't exist (issue #1) and `Referral.snapshotIntakeId` is deliberately absent rather than faked. Flagged, not dropped. 5. **Specialty mismatch at signup** — doctors self-select specialty with no verification → patients get referred into a specialty the doctor doesn't actually practice. 6. **Facility fulfillment integrity** — nothing currently stops a facility from marking an order "fulfilled" without actually doing the work, or a patient walking into an unqualified/non-partnered facility with a screenshot of an order. ### 8.2 What edge cases are we missing? -1. Patient has **no matching specialist available** (none online / none accepting new patients) after a referral — what happens? -2. Doctor **refers to a specialty with zero verified doctors on the platform** — dead end. -3. Patient **cancels or no-shows** after a referral was already created downstream. -4. **Multiple simultaneous referrals** — can a single consultation spawn more than one referral (e.g., "see both cardiology and endocrinology")? +1. Patient has **no matching specialist available** (none online / none accepting new patients) after a referral — what happens? **Still open, narrowed 2026-10-17:** with v1 requiring a concrete verified `target_doctor_id`, the dead end is now "the named doctor never offers a slot" rather than "nobody exists in this specialty" — and that case still has no time-based expiry (issue #8), so the referral sits `pending` forever until a human in the admin queue finds it. Not resolved; re-read with 8.2.2 and issue #6. +2. Doctor **refers to a specialty with zero verified doctors on the platform** — dead end. **Re-scoped 2026-10-17, not implemented as specified (Phase 3, issue #5 / #6):** this state is now **unreachable by construction** — creation rejects a target doctor who isn't verified (and isn't verified in the claimed specialty when one is claimed), so no referral can point at a dead end. The "waiting for a specialist" distinct patient-facing state issue #5's DoD asked for is therefore *not* built; it only becomes possible once the specialty-only routing ADR (issue #6) lands. Deliberate scope reduction — flagged so it isn't mistaken for a completed requirement. +3. Patient **cancels or no-shows** after a referral was already created downstream. **Resolved 2026-10-17 (issue #67):** the appointment status transition and the referral state change happen in one transaction, so a referral can never be left `accepted` behind a dead appointment. Cancelled → referral returns to `pending` (slot released, re-bookable, same snapshot); first no-show → `pending` with `noShowCount = 1`; second no-show → `expired` with `referring_doctor_notified_at` set (threshold is a deliberately boring default, not a tuned one). Every write is conditional on the referral still pointing at that appointment, so a doubled hook is a no-op — integration-tested in `tests/integration/referral-cascade.test.mts`. +4. **Multiple simultaneous referrals** — can a single consultation spawn more than one referral (e.g., "see both cardiology and endocrinology")? **Resolved 2026-10-17 (issue #66):** yes — an atomic all-or-none batch (`POST /referrals/from-consultation/:consultationId`, equivalently `POST /consultations/:id/referrals`). Duplicate doctor *or* specialty within a batch, or against a referral already made from that consultation, fails the whole batch. Each target gets its own snapshot copy; acceptance/decline is independent per referral afterwards (they were born together, they live separately), and `GET /referrals/mine` groups them by source consultation for the patient's "one visit, several referrals" view. 5. **Minor/dependent patients** — booking on behalf of a child or dependent; consent and account ownership model undefined. 6. **Doctor moonlighting across facilities** — same doctor, multiple hospitals/clinics, different availability per location — v1 assumes one doctor = one availability calendar, which may be wrong. 7. **Lab result requiring urgent follow-up** — abnormal result with no automatic escalation path back to a doctor. diff --git a/README.md b/README.md index 3e437c6..915b11a 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # docforum-core ![License](https://img.shields.io/badge/license-Apache--2.0-blue.svg) -![Status](https://img.shields.io/badge/status-Phase%201-yellow.svg) +![Status](https://img.shields.io/badge/status-Phase%203%20in%20progress-yellow.svg) ![Stack](https://img.shields.io/badge/stack-Node.js%20%2B%20TypeScript-339933.svg) **The backend of DocForum** — a platform that collapses a multi-visit, @@ -91,6 +91,7 @@ User ─┬─ PatientProfile AvailabilitySlot (doctor's calendar) → Appointment → IntakeForm, Consultation Consultation ──(outcome = referred)──▶ Referral (immutable snapshot!) → new Appointment +Referral ──▶ AuditEvent (append-only admin actions) Consultation → Prescription (+ PrescriptionItem) / LabOrder (+ LabResult) FulfillmentRecord (facility-owned) references an order but never mutates it PaymentIntent / WalletLink — relational metadata only; contract logic lives in docforum-escrow @@ -183,8 +184,15 @@ the safe-booking foundation): built and tested** — auth, doctor verification, availability/slot generation, and the booking-concurrency guarantee are real, not aspirational (see Getting started above). One item is unverified: the Docker Compose file for local dev exists but -wasn't exercisable in this environment. Phase 2 (intake, appointments -lifecycle beyond booking, consultations) is next. +wasn't exercisable in this environment. **Phase 3 (referrals): the core +is built and tested** — referral model with an immutable notes snapshot, +patient accept/decline producing a real pre-populated appointment through +the same slot-claim path, no-show/cancellation cascade, loop detection +with a hop limit, multi-referral bundles from one consultation, and an +admin queue with audited reassign/cancel. Still open within Phase 3: the +routing ADR for specialty-only referrals (issue #6) and a time-based +expiry job (issue #8). Phase 2's remaining pieces (intake, doctor's +today view, specialty discovery) come next. Full phase breakdown: [`ROADMAP.md`](ROADMAP.md). diff --git a/ROADMAP.md b/ROADMAP.md index 3309176..e394136 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,10 +2,13 @@ Last updated: 2026-10-17 Status: Phase 0, Phase 1, and Phase 5.5 (Payments, custodial v1 — see -`docs/adr/0004-custodial-payments-v1.md`) built and tested. Phases 2-6 -and 8-9 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. +`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. > **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. @@ -42,23 +45,27 @@ for status and issue links. - [x] Doc reconciliation: PRD §8.1.1 and ARCHITECTURE §6.1.1 marked resolved in place. ## Phase 2 — Intake, appointments, consultations -**Status: Not started — unblocked (Phase 1 is done). Scoped into issues #1–#4 and #23.** +**Status: In progress — issues #3 and #4 carry a minimal slice landed as Phase 3's prerequisite; intake (#1/#2) and discovery (#23) still Not started. Scoped into issues #1–#4 and #23.** - [ ] `IntakeForm` model + structured symptom entry (not free-text-only, per PRD FR-5). Tracked as [issue #1](https://github.com/DocForum/docforum-core/issues/1) (Medium, 150 pts). - [ ] Booking flow requires intake before confirmation. Tracked as [issue #2](https://github.com/DocForum/docforum-core/issues/2) (Medium, 150 pts) — depends on #1. -- [ ] `Appointment` lifecycle (`scheduled → completed/cancelled/no_show`) + doctor's today view. Tracked as [issue #3](https://github.com/DocForum/docforum-core/issues/3) (Medium, 150 pts). -- [ ] `Consultation` record + outcome (`resolved | referred | follow_up_needed`). Tracked as [issue #4](https://github.com/DocForum/docforum-core/issues/4) (High, 200 pts). +- [ ] `Appointment` lifecycle (`scheduled → completed/cancelled/no_show`) + doctor's today view. Tracked as [issue #3](https://github.com/DocForum/docforum-core/issues/3) (Medium, 150 pts) — **partially done** as part of Phase 3's referral work: `PATCH /appointments/:id/status` (treating doctor only) and `PATCH /appointments/:id/cancel` (patient, own appointment), with slot release on cancellation and the referral cascade hook. Remaining: the doctor's today view and any admin-on-behalf transition. +- [ ] `Consultation` record + outcome (`resolved | referred | follow_up_needed`). Tracked as [issue #4](https://github.com/DocForum/docforum-core/issues/4) (High, 200 pts) — **minimal slice done** for Phase 3: `POST /consultations` against one's own completed appointment (one per appointment), `GET /consultations/:id` scoped to that doctor + the patient. Remaining: sign-off, addenda / post-hoc note editing. - [ ] Doctor discovery: specialty catalog, doctor ↔ specialty assignment, public `GET /doctors?specialty=` (verified doctors only). Nothing in `backend/src` touched `Specialty`/`DoctorSpecialty` before this; `docforum-web` issue #1 depends on it. Tracked as [issue #23](https://github.com/DocForum/docforum-core/issues/23) (Medium, 150 pts). - [ ] Patient-facing view: my intake history. Not yet split into its own issue — small enough to fold into whichever of the above lands last, or split out later if it grows. ## Phase 3 — Referrals -**Status: Not started (blocked by Phase 2). Scoped into issues #5–#8.** -- [ ] `Referral` model with **immutable snapshot** of intake + notes (ARCHITECTURE.md §4.4 design note — do not implement as a live pointer), plus the "zero verified doctors in target specialty" edge case (PRD §8.2.2). Tracked as [issue #5](https://github.com/DocForum/docforum-core/issues/5) (Medium, 150 pts). -- [ ] Referral creation from a `Consultation` with outcome `referred` — folded into issue #5 above. -- [ ] Routing rule for referrals with no specific `target_doctor_id` (ARCHITECTURE.md §6.1.2 — currently an unresolved design gap; this phase must resolve or explicitly re-scope it). Tracked as [issue #6](https://github.com/DocForum/docforum-core/issues/6) (High, 200 pts) — an ADR. -- [ ] Patient accept/decline flow (PRD OQ-3 resolved: patient must confirm). Tracked as [issue #7](https://github.com/DocForum/docforum-core/issues/7) (Medium, 150 pts) — depends on #5. -- [ ] Accepted referral auto-generates a pre-populated `Appointment` against the target doctor/specialty — patient does not re-enter intake (PRD FR-8, the core value prop) — folded into issue #7 above. -- [ ] Referral expiry job (addresses PRD §8.1.3 "orphaned" pattern for referrals specifically). Tracked as [issue #8](https://github.com/DocForum/docforum-core/issues/8) (Medium, 150 pts) — depends on #5. -- [ ] Doc reconciliation: update ARCHITECTURE_ESSENTIALS.md "Known unresolved design gaps" once routing rule + expiry exist — part of issue #6's DoD. +**Status: In progress — issues #5, #7 and #65–#68 are implemented and tested (unit + 5 self-contained integration suites); #6 (routing ADR) and #8 (time-based expiry job) remain open. Original scope was #5–#8; #65–#68 were opened later against the same phase for loop detection, multi-referral, cascade and admin overrides.** +- [x] `Referral` model with **immutable snapshot** of intake + notes (ARCHITECTURE.md §4.4 design note — do not implement as a live pointer), plus the "zero verified doctors in target specialty" edge case (PRD §8.2.2). Tracked as [issue #5](https://github.com/DocForum/docforum-core/issues/5) (Medium, 150 pts) — **done with two flagged deviations for review**: (a) `snapshot_notes` is a real copy written in the creation transaction and unreachable by any consultation-edit path, but `snapshotIntakeId` has no column because `IntakeForm` doesn't exist yet (issue #1) — the intake half of the snapshot is not yet enforceable; (b) the zero-verified-doctors case is **unreachable by construction** rather than surfaced as the distinct "waiting for a specialist" state the DoD asked for — v1 requires a concrete verified `target_doctor_id` (see #6 below). Creation is an explicit `POST /referrals` from a consultation rather than an implicit trigger on outcome `referred`; the outcome still records intent and the API enforces a completed appointment. +- [x] Referral creation from a `Consultation` with outcome `referred` — folded into issue #5 above. +- [ ] Routing rule for referrals with no specific `target_doctor_id` (ARCHITECTURE.md §6.1.2 — currently an unresolved design gap; this phase must resolve or explicitly re-scope it). Tracked as [issue #6](https://github.com/DocForum/docforum-core/issues/6) (High, 200 pts) — an ADR. **Explicitly re-scoped, not resolved:** `target_doctor_id` is required in v1 so unassigned referrals cannot exist; the ADR for specialty-only routing (and the "waiting for a specialist" state it would unlock) is still owed, and the gap stays open until it lands. +- [x] Patient accept/decline flow (PRD OQ-3 resolved: patient must confirm). Tracked as [issue #7](https://github.com/DocForum/docforum-core/issues/7) (Medium, 150 pts) — depends on #5. **Done:** `POST /referrals/:id/accept` (conditional `pending → accepted`, one winner under concurrency) and `POST /referrals/:id/decline` (pending-only, terminal). +- [x] Accepted referral auto-generates a pre-populated `Appointment` against the target doctor/specialty — patient does not re-enter intake (PRD FR-8, the core value prop) — folded into issue #7 above. **Done:** accept books through `appointments`' `bookForReferral` → the same `claimOpenSlot` conditional UPDATE as a direct booking (`origin: 'referral'`), with the slot belonging to the target doctor enforced; no shortcut writes an `Appointment` row. +- [ ] Referral expiry job (addresses PRD §8.1.3 "orphaned" pattern for referrals specifically). Tracked as [issue #8](https://github.com/DocForum/docforum-core/issues/8) (Medium, 150 pts) — depends on #5. **Still open:** only the no-show-driven path reaches `expired` (issue #67 below); nothing expires a referral by age, so an accepted-never / ignored referral needs an admin to find it in the queue. +- [x] Referral loop detection + max-hop limit (PRD §8.1.2). Tracked as [issue #65](https://github.com/DocForum/docforum-core/issues/65) (High, 200 pts). **Done:** lineage via `parent_referral_id`, bounded CTE walk, strict rule (repeat doctor/specialty anywhere in the chain — including the GP return — flags for the admin queue), hard `422` on self-referral and on chains over `REFERRAL_MAX_HOPS` (default 4). +- [x] Multiple simultaneous referrals from one consultation (PRD §8.2.4). Tracked as [issue #66](https://github.com/DocForum/docforum-core/issues/66) (High, 200 pts). **Done, including its ADR:** [`docs/adr/0006-multiple-referrals-per-consultation.md`](docs/adr/0006-multiple-referrals-per-consultation.md) records the decision (allowed; independent lifecycles, atomic all-or-none creation, grouped only for the patient view; no bundle table). Implementation: one snapshot copy per target, duplicate-specialty/doctor rejection, independent per-referral acceptance, patient list grouped by source consultation. +- [x] Cascade rules when a referral-origin appointment is cancelled or no-shows (PRD §8.2.3). Tracked as [issue #67](https://github.com/DocForum/docforum-core/issues/67) (High, 200 pts). **Done:** run inside the appointment's status transaction; cancelled → referral back to `pending` with the slot released, first no-show → `pending` + `noShowCount`, second → `expired` + `referringDoctorNotifiedAt` (threshold is a v1 default, not a tuned value); all writes conditional so a doubled hook no-ops. +- [x] Admin referral operations: queue, reassign stuck referrals, override routing, audited. Tracked as [issue #68](https://github.com/DocForum/docforum-core/issues/68) (Medium, 150 pts). **Done:** `GET /referrals/admin/queue` (status / `olderThan` / `loopFlagged` filters, derived reason + age), `POST /referrals/admin/:id/reassign` (pending only, target column only — snapshot untouched), `POST /referrals/admin/:id/cancel` (closes a live appointment through the appointments service), `GET /referrals/admin/:id/audit-events`; reassign/cancel write the first `AuditEvent` rows (new table — ARCHITECTURE.md §4.10). +- [x] Doc reconciliation: ARCHITECTURE.md §4.2/§4.3/§4.4/§4.10/§5.2/§6, ARCHITECTURE_ESSENTIALS.md data model map + known gaps, PRD §8.1.2/§8.1.3/§8.1.4/§8.2.1–§8.2.4, `docs/api/README.md`. Done in this change — #6's ADR is *not* part of it and the gap is deliberately left noted. ## Phase 4 — Orders: prescriptions & lab orders **Status: Partially implemented by issues #9 and #85; remaining issue work is tracked in #10–#12.** @@ -120,7 +127,7 @@ for status and issue links. - [ ] JWT refresh revocation list (ARCHITECTURE.md §6.1.4). Tracked as [issue #18](https://github.com/DocForum/docforum-core/issues/18) (Medium, 150 pts) — unblocked now, operates on Phase 1's auth module. - [ ] Held-slot TTL/release job (ARCHITECTURE.md §6.2.1). Tracked as [issue #19](https://github.com/DocForum/docforum-core/issues/19) (Medium, 150 pts) — unblocked now, operates on Phase 1's `AvailabilitySlot`. - [x] Access-control test suite: who may read intake, consultation and payment data. Integration tests (`tests/integration/access-control.test.mts`) establishing the test harness and access-control patterns for the three PHI data categories; intake/consultation test cases are scaffolded as placeholders until Phase 2/3 routes exist, payment-intent access cases exercise the live admin-gated routes. Tracked as [issue #44](https://github.com/DocForum/docforum-core/issues/44) — **Done**. -- [ ] Privacy access-control audit: confirm intake/consultation notes are visible only per PRD §7 (patient, treating doctor(s) in-thread, fulfilling facility for that specific order only) — write tests, not just code review. Still blocked — meaningfully auditable only once Phase 3/4/5 access paths exist. Not yet scoped into an issue. +- [ ] Privacy access-control audit: confirm intake/consultation notes are visible only per PRD §7 (patient, treating doctor(s) in-thread, fulfilling facility for that specific order only) — write tests, not just code review. Still blocked — meaningfully auditable only once Phase 3/4/5 access paths exist. **One Phase 3 access path is now worth auditing (`GET /consultations/:id` — treating doctor + that appointment's patient only, and referral snapshot exposure on `GET /referrals/mine` / `/created`), but intake and orders remain unbuilt, so the audit as scoped stays blocked.** Not yet scoped into an issue. - [ ] Decide and document what's explicitly out of scope for v1 launch vs. genuinely blocking (e.g., drug-interaction checking, PRD §8.2.8 — needs an explicit go/no-go, not silence). Tracked as [issue #20](https://github.com/DocForum/docforum-core/issues/20) (Trivial, 100 pts) — a decision task, unblocked now. ## Phase 9 — Deploy & CI @@ -410,3 +417,45 @@ for status and issue links. overwriting). Rollback coupling proven with a real injected DB failure, not a mock. Phase 3/4 wiring explicitly left pending. - 2026-10-20 — Added the PR conflict notifier workflow (see the CI item above): maintainers no longer need to manually ask authors to rebase after each merge. +- 2026-10-17 — Built the Phase 3 referral core on branch + `feat/referral-phase3-cascade-multi-loop-admin`: issues #5, #7 and + #65–#68 (plus the minimal #3/#4 slices they depend on — appointment + status transitions and a one-per-appointment `Consultation` record). + Schema: `Referral` filled in from its sketch, `Consultation` linked to + its appointment, `Appointment.availabilitySlotId` made nullable so a + cancellation can release a slot. The referral↔appointments cycle is + service-to-service in both directions (accept books through + `bookForReferral` → `claimOpenSlot`; status transitions call back into + `applyAppointmentOutcome` inside the same transaction) — never a + repository import across the boundary. 42 unit tests + 55 integration + tests pass, including 8 genuinely concurrent accepts on one referral + (exactly one wins — the same proof shape as the booking test), batch + rollback, loop chains, cascade idempotency and admin-role gating. + + **One deliberate re-scope to review, not silently absorbed:** (1) + `target_doctor_id` is required, so the routing gap (ARCHITECTURE.md + §6.1.2 / issue #6) is closed for v1 by making unassigned referrals + impossible — and with it, PRD §8.2.2's distinct "waiting for a + specialist" state is unreachable rather than implemented. The ADR is + still owed. (2) `Referral.snapshotIntakeId` has no column, because + `IntakeForm` (issue #1) doesn't exist yet — the notes half of the + snapshot rule is real and enforced, the intake half is structurally + impossible to enforce and is noted as such rather than faked. + Issue #8 (time-based expiry) remains open: only the no-show cascade + reaches `expired`. The multi-referral decision (#66's ADR requirement) + is recorded as `docs/adr/0006-multiple-referrals-per-consultation.md`, + and the referral status machine is drawn out in ARCHITECTURE.md §4.4 + (#67's state-diagram DoD). Docs reconciled in the same change: + ARCHITECTURE.md §4.2–§4.4/§4.10/§5.2/§6.1.2/§6.2.2/§6.2.4, + ARCHITECTURE_ESSENTIALS.md data-model map + known gaps, PRD + §8.1.2–§8.1.4 and §8.2.1–§8.2.4, `docs/api/README.md` (which also still + listed `payments` as unimplemented — corrected), and this file's + Phase 2/3 status. + + **Audit trail (re-scoped 2026-10-20):** the referral admin actions are + recorded in the *existing* `AuditEvent` table (issue #43) as + `entity_type = 'Referral'` rows written through `auditService.record()` + in the same transaction as the referral change — not in a second, + referral-specific table. One audit trail, one set of append-only + triggers, one writer; `GET /referrals/admin/:id/audit-events` reads the + same rows the admin-wide `GET /admin/audit-events` serves. diff --git a/backend/prisma/migrations/20261016214929_referral_phase3/migration.sql b/backend/prisma/migrations/20261016214929_referral_phase3/migration.sql new file mode 100644 index 0000000..960fa61 --- /dev/null +++ b/backend/prisma/migrations/20261016214929_referral_phase3/migration.sql @@ -0,0 +1,102 @@ +-- AlterEnum +ALTER TYPE "ReferralStatus" ADD VALUE 'cancelled'; + +-- DropForeignKey +ALTER TABLE "Appointment" DROP CONSTRAINT "Appointment_availabilitySlotId_fkey"; + +-- AlterTable +ALTER TABLE "Appointment" ADD COLUMN "referralId" TEXT, +ALTER COLUMN "availabilitySlotId" DROP NOT NULL; + +-- AlterTable +ALTER TABLE "Consultation" ADD COLUMN "appointmentId" TEXT, +ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL; + +-- AlterTable +ALTER TABLE "Referral" ADD COLUMN "cancelledReason" TEXT, +ADD COLUMN "loopFlagReason" TEXT, +ADD COLUMN "loopFlaggedAt" TIMESTAMP(3), +ADD COLUMN "noShowCount" INTEGER NOT NULL DEFAULT 0, +ADD COLUMN "parentReferralId" TEXT, +ADD COLUMN "patientProfileId" TEXT NOT NULL, +ADD COLUMN "referringDoctorId" TEXT NOT NULL, +ADD COLUMN "referringDoctorNotifiedAt" TIMESTAMP(3), +ADD COLUMN "resultingAppointmentId" TEXT, +ADD COLUMN "sourceConsultationId" TEXT NOT NULL, +ADD COLUMN "targetDoctorId" TEXT NOT NULL, +ADD COLUMN "targetSpecialtyId" TEXT; + +-- CreateTable +CREATE TABLE "AuditEvent" ( + "id" TEXT NOT NULL, + "actorUserId" TEXT NOT NULL, + "actorRole" TEXT NOT NULL, + "action" TEXT NOT NULL, + "referralId" TEXT, + "reason" TEXT, + "metadata" JSONB, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "AuditEvent_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "AuditEvent_referralId_idx" ON "AuditEvent"("referralId"); + +-- CreateIndex +CREATE INDEX "AuditEvent_action_idx" ON "AuditEvent"("action"); + +-- CreateIndex +CREATE INDEX "AuditEvent_createdAt_idx" ON "AuditEvent"("createdAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "Consultation_appointmentId_key" ON "Consultation"("appointmentId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Referral_resultingAppointmentId_key" ON "Referral"("resultingAppointmentId"); + +-- CreateIndex +CREATE INDEX "Referral_patientProfileId_idx" ON "Referral"("patientProfileId"); + +-- CreateIndex +CREATE INDEX "Referral_referringDoctorId_idx" ON "Referral"("referringDoctorId"); + +-- CreateIndex +CREATE INDEX "Referral_targetDoctorId_idx" ON "Referral"("targetDoctorId"); + +-- CreateIndex +CREATE INDEX "Referral_status_idx" ON "Referral"("status"); + +-- CreateIndex +CREATE INDEX "Referral_loopFlaggedAt_idx" ON "Referral"("loopFlaggedAt"); + +-- AddForeignKey +ALTER TABLE "Appointment" ADD CONSTRAINT "Appointment_availabilitySlotId_fkey" FOREIGN KEY ("availabilitySlotId") REFERENCES "AvailabilitySlot"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Appointment" ADD CONSTRAINT "Appointment_referralId_fkey" FOREIGN KEY ("referralId") REFERENCES "Referral"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Consultation" ADD CONSTRAINT "Consultation_appointmentId_fkey" FOREIGN KEY ("appointmentId") REFERENCES "Appointment"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Referral" ADD CONSTRAINT "Referral_sourceConsultationId_fkey" FOREIGN KEY ("sourceConsultationId") REFERENCES "Consultation"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Referral" ADD CONSTRAINT "Referral_patientProfileId_fkey" FOREIGN KEY ("patientProfileId") REFERENCES "PatientProfile"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Referral" ADD CONSTRAINT "Referral_referringDoctorId_fkey" FOREIGN KEY ("referringDoctorId") REFERENCES "DoctorProfile"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Referral" ADD CONSTRAINT "Referral_targetDoctorId_fkey" FOREIGN KEY ("targetDoctorId") REFERENCES "DoctorProfile"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Referral" ADD CONSTRAINT "Referral_parentReferralId_fkey" FOREIGN KEY ("parentReferralId") REFERENCES "Referral"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Referral" ADD CONSTRAINT "Referral_resultingAppointmentId_fkey" FOREIGN KEY ("resultingAppointmentId") REFERENCES "Appointment"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "AuditEvent" ADD CONSTRAINT "AuditEvent_referralId_fkey" FOREIGN KEY ("referralId") REFERENCES "Referral"("id") ON DELETE SET NULL ON UPDATE CASCADE; + diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 8b75713..7940dd4 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -72,6 +72,10 @@ enum ReferralStatus { declined completed expired + // Admin-initiated termination (issue #68) — distinct from `expired` + // (the cascade gave up after repeated no-shows) and `declined` (the + // patient said no) so audit/metrics can tell the three exits apart. + cancelled } enum OrderStatus { @@ -215,6 +219,9 @@ model PatientProfile { prescriptions Prescription[] labOrders LabOrder[] dataExports PatientExport[] + // Phase 3: the referrals this patient is the subject of (the opposite + // side of Referral.patientProfileId). + referrals Referral[] // Phase 8 (issue #45): who viewed this patient's records. accessLogs AccessLog[] @@ -301,6 +308,11 @@ model DoctorProfile { appointments Appointment[] prescriptions Prescription[] labOrders LabOrder[] + // Referral sides a doctor can sit on: `referringReferrals` are ones + // they created; `targetedReferrals` are ones aimed at them (whether + // they ultimately see the patient or not). + referringReferrals Referral[] @relation("ReferralReferringDoctor") + targetedReferrals Referral[] @relation("ReferralTargetDoctor") @@index([verificationStatus]) } @@ -380,22 +392,38 @@ model Appointment { id String @id @default(uuid()) patientProfileId String doctorProfileId String - availabilitySlotId String @unique + // Nullable because a CANCELLED appointment releases its slot back to + // `open` (referral cascade / patient cancel) — the appointment keeps its + // history row but must stop holding the unique claim on the slot, or + // re-booking the freed slot would violate the unique constraint. + // The booking guarantee itself is unchanged: claimOpenSlot's + // conditional UPDATE on SlotStatus is what prevents double-booking. + availabilitySlotId String? @unique status AppointmentStatus @default(scheduled) origin AppointmentOrigin @default(direct_booking) + // Set when this appointment was produced by accepting a Referral (issue + // #7). The cascade rules (#67 / PRD §8.2.3) react to status transitions + // on exactly these appointments. NOT unique: one referral can produce + // several appointments over its life (re-booking after a cancellation). + // Referral.resultingAppointmentId is the unique "current" pointer. + referralId String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt patientProfile PatientProfile @relation(fields: [patientProfileId], references: [id]) doctorProfile DoctorProfile @relation(fields: [doctorProfileId], references: [id]) - availabilitySlot AvailabilitySlot @relation(fields: [availabilitySlotId], references: [id]) + availabilitySlot AvailabilitySlot? @relation(fields: [availabilitySlotId], references: [id]) + referral Referral? @relation("ReferralOriginatedAppointments", fields: [referralId], references: [id]) + // Non-owning side of Referral.resultingAppointmentId (the FK lives on + // Referral) — which referral currently points at this appointment as + // the one it produced. + referralResultOf Referral? @relation("ReferralResultingAppointment") consultation Consultation? // TODO Phase 2: intakeId (FK -> IntakeForm). PRD FR-5 requires intake // before confirmation, not enforced yet — booking works without it in // Phase 1 so the concurrency-safety work isn't blocked on IntakeForm // existing. Flagged, not silently dropped. - // TODO Phase 3: referralId (nullable FK -> Referral). } // ========================================================================= @@ -435,30 +463,101 @@ model IntakeForm { // TODO: patientProfileId, symptoms (Json), freeTextNotes } +// Phase 3 (issue #5 minimal + lineage for #65): a Consultation is the +// record of a doctor having seen the patient at a specific appointment. +// `appointmentId` is the lineage seam — Referral.parentReferralId is +// derived through it (consultation → appointment → referral), which is +// what loop detection walks. Full consultation-note workflow (append-only +// addenda, sign-off) stays issue #4/#64's scope. model Consultation { id String @id @default(uuid()) appointmentId String @unique outcome ConsultationOutcome? notes String? createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt appointment Appointment @relation(fields: [appointmentId], references: [id]) prescriptions Prescription[] labOrders LabOrder[] + // Phase 3: the referrals that grew out of this consultation (the + // opposite side of Referral.sourceConsultationId). + referrals Referral[] } +// Phase 3 (issues #5/#65/#66/#67/#68). Snapshot rule (FR-7/FR-8, hard +// rule 3): `snapshotNotes` is a copy taken at creation, never a live +// reference — reassign/cancel/cascade never touch it. model Referral { - id String @id @default(uuid()) - status ReferralStatus @default(pending) - snapshotNotes String - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - // TODO: sourceConsultationId, referringDoctorId, targetSpecialtyId, - // targetDoctorId (nullable), snapshotIntakeId, resultingAppointmentId (nullable) - // IMPORTANT: snapshotNotes / snapshotIntakeId must be copies, not live - // references — see ARCHITECTURE.md §4.4 design note. + id String @id @default(uuid()) + status ReferralStatus @default(pending) + patientProfileId String + // The consultation this referral grew out of. Required: every referral + // is created from a consultation (PRD FR-7), which is also what gives + // loop detection its lineage. + sourceConsultationId String + referringDoctorId String + // v1 requires a concrete target doctor — the routing rule for "a + // specialty, no specific doctor" is an unresolved design gap + // (ARCHITECTURE.md §6.1.2, issue #6), not silently defaulted here. + targetDoctorId String + // Optional denormalised label of the target specialty, used by the + // loop guard and admin views. Not a substitute for issue #23's real + // per-doctor specialty management. + targetSpecialtyId String? + snapshotNotes String + // Issue #65: the referral whose appointment produced the consultation + // this referral came from — the chain loop detection walks (bounded). + parentReferralId String? + // Issue #67 bookkeeping: how many times a referral-origin appointment + // has no-showed, and the "the referring doctor must know" flag set + // when repeated no-shows expire the referral. Both live on the + // referral (not a Notification row) because Phase 6's dispatch layer + // doesn't exist yet — the flag is the seam it will read. + noShowCount Int @default(0) + referringDoctorNotifiedAt DateTime? + // Issue #65: set (with a reason naming the matching hop) when the + // target doctor/specialty was already in the ancestor chain — the + // referral is still created (a human decides), but surfaces in the + // admin queue. + loopFlaggedAt DateTime? + loopFlagReason String? + // Issue #68: admin-initiated cancellation carries its reason; the + // audit trail proper is the AuditEvent row written alongside. + cancelledReason String? + // The appointment this referral currently produced (null while + // pending/declined, or after a cancelled/no-showed appointment returns + // it to pending). Kept pointing at the last no-show when expired — + // that appointment is why it expired. + resultingAppointmentId String? @unique + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + sourceConsultation Consultation @relation(fields: [sourceConsultationId], references: [id]) + patientProfile PatientProfile @relation(fields: [patientProfileId], references: [id]) + referringDoctor DoctorProfile @relation("ReferralReferringDoctor", fields: [referringDoctorId], references: [id]) + targetDoctor DoctorProfile @relation("ReferralTargetDoctor", fields: [targetDoctorId], references: [id]) + parentReferral Referral? @relation("ReferralLineage", fields: [parentReferralId], references: [id]) + childReferrals Referral[] @relation("ReferralLineage") + resultingAppointment Appointment? @relation("ReferralResultingAppointment", fields: [resultingAppointmentId], references: [id]) + // Every appointment this referral ever produced (including cancelled/ + // no-showed ones) — history survives the resultingAppointmentId link + // being cleared when the referral returns to pending. + appointments Appointment[] @relation("ReferralOriginatedAppointments") + + @@index([patientProfileId]) + @@index([referringDoctorId]) + @@index([targetDoctorId]) + @@index([status]) + @@index([loopFlaggedAt]) } +// Issue #68: who did what to a referral, when, and why. Deliberately NOT a +// second audit table: the append-only `AuditEvent` in this schema (Phase 8, +// issue #43) is the one audit trail, so referral admin actions are recorded +// as `entityType: 'Referral'` rows through the audit module +// (`auditService.record`) — same table, same triggers, same writer. + model Prescription { id String @id @default(uuid()) consultationId String diff --git a/backend/src/app.ts b/backend/src/app.ts index a9c12e5..0227990 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -15,6 +15,8 @@ import { registerPatientExportSections } from './export-sections'; import { auditRouter, adminAuditRouter, patientAuditRouter } from './modules/audit/routes'; import { escrowEventsRouter } from './modules/payments/routes/escrow-events'; import { requestId } from './middleware/request-id'; +import { referralsRouter } from './modules/referrals/routes'; +import { consultationsRouter } from './modules/consultations/routes'; export function createApp() { const app = express(); @@ -56,6 +58,8 @@ export function createApp() { // paths match the issue's spec exactly: /admin/access-logs, /patients/me/access-log. app.use('/admin', adminAuditRouter); app.use('/patients', patientAuditRouter); + app.use('/referrals', referralsRouter); + app.use('/consultations', consultationsRouter); // Must be registered last — Express only routes errors here once every // other handler has had a chance to run. diff --git a/backend/src/modules/appointments/controllers/index.ts b/backend/src/modules/appointments/controllers/index.ts index f07f9f7..f23464e 100644 --- a/backend/src/modules/appointments/controllers/index.ts +++ b/backend/src/modules/appointments/controllers/index.ts @@ -1,8 +1,9 @@ import type { Request, Response } from 'express'; import * as appointmentsService from '../services'; -import { bookSlotSchema } from '../validation'; +import { bookSlotSchema, updateStatusSchema } from '../validation'; import { HttpError } from '../../../http-error'; import { auditActor } from '../../../middleware/request-id'; +import { getParam } from '../../../request-params'; export async function book(req: Request, res: Response) { if (!req.user) throw new HttpError(401, 'Authentication required.'); @@ -16,3 +17,20 @@ export async function listMine(req: Request, res: Response) { const appointments = await appointmentsService.listMyAppointments(req.user.id); res.status(200).json(appointments); } + +export async function updateStatus(req: Request, res: Response) { + if (!req.user) throw new HttpError(401, 'Authentication required.'); + const { status } = updateStatusSchema.parse(req.body); + const appointment = await appointmentsService.updateStatusByDoctor( + req.user.id, + getParam(req, 'appointmentId'), + status, + ); + res.status(200).json(appointment); +} + +export async function cancelMine(req: Request, res: Response) { + if (!req.user) throw new HttpError(401, 'Authentication required.'); + const appointment = await appointmentsService.cancelByPatient(req.user.id, getParam(req, 'appointmentId')); + res.status(200).json(appointment); +} diff --git a/backend/src/modules/appointments/repositories/index.ts b/backend/src/modules/appointments/repositories/index.ts index d5c327c..7e323b0 100644 --- a/backend/src/modules/appointments/repositories/index.ts +++ b/backend/src/modules/appointments/repositories/index.ts @@ -1,11 +1,17 @@ import { prisma } from '../../../db/prisma-client'; -import type { Prisma } from '@prisma/client'; +import type { AppointmentStatus, Prisma } from '@prisma/client'; type DbClient = typeof prisma | Prisma.TransactionClient; export function createAppointment( db: DbClient, - data: { patientProfileId: string; doctorProfileId: string; availabilitySlotId: string }, + data: { + patientProfileId: string; + doctorProfileId: string; + availabilitySlotId: string; + origin?: 'direct_booking' | 'referral'; + referralId?: string; + }, ) { return db.appointment.create({ data }); } @@ -33,4 +39,31 @@ export function findAppointmentsForPatient(patientProfileId: string) { /** Minimal appointment context for a doctor-owned consultation. */ export function findAppointmentDoctorContext(id: string) { return prisma.appointment.findUnique({ where: { id }, include: { doctorProfile: { select: { id: true, userId: true, verificationStatus: true } } } }); -} \ No newline at end of file +} + +export function findAppointmentById(db: DbClient, id: string) { + return db.appointment.findUnique({ where: { id } }); +} + +/** + * The appointment state machine's only write path: a conditional UPDATE + * that matches `status = 'scheduled'`. Exactly one concurrent caller can + * move a given appointment out of `scheduled`; a second attempt (or a + * replayed hook) matches zero rows, which the service turns into a 409 — + * this is what makes the referral cascade (issue #67) at-most-once per + * real transition, without relying on app-level checks. + */ +export function updateStatusIfScheduled(db: DbClient, id: string, status: AppointmentStatus) { + return db.appointment.updateMany({ where: { id, status: 'scheduled' }, data: { status } }); +} + +/** + * A cancelled appointment that released its slot back to `open` must also + * drop its unique claim on availabilitySlotId — otherwise re-booking the + * freed slot hits the unique constraint (discovered by the cascade + * integration tests). The history row survives; only the slot link is + * cleared. + */ +export function clearAvailabilitySlotLink(db: DbClient, id: string) { + return db.appointment.updateMany({ where: { id }, data: { availabilitySlotId: null } }); +} diff --git a/backend/src/modules/appointments/routes/index.ts b/backend/src/modules/appointments/routes/index.ts index 04ecd69..9e05824 100644 --- a/backend/src/modules/appointments/routes/index.ts +++ b/backend/src/modules/appointments/routes/index.ts @@ -15,3 +15,19 @@ appointmentsRouter.post( appointmentsController.book, ); appointmentsRouter.get('/mine', authenticate, requireRole('patient'), appointmentsController.listMine); + +// Lifecycle (issue #3 + the cascade trigger for issue #67): only the +// treating doctor moves an appointment through completed/cancelled/ +// no_show; the patient may cancel their own — nothing else. +appointmentsRouter.patch( + '/:appointmentId/status', + authenticate, + requireRole('doctor'), + appointmentsController.updateStatus, +); +appointmentsRouter.patch( + '/:appointmentId/cancel', + authenticate, + requireRole('patient'), + appointmentsController.cancelMine, +); diff --git a/backend/src/modules/appointments/services/index.ts b/backend/src/modules/appointments/services/index.ts index 2a0ba1b..59a58a2 100644 --- a/backend/src/modules/appointments/services/index.ts +++ b/backend/src/modules/appointments/services/index.ts @@ -1,10 +1,12 @@ import { prisma } from '../../../db/prisma-client'; import { HttpError } from '../../../http-error'; +import type { AppointmentStatus, Prisma } from '@prisma/client'; import * as appointmentsRepo from '../repositories'; import * as availabilityService from '../../availability/services'; -import * as patientsService from '../../patients/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'; /** * Books a slot for the calling patient. This is the correctness-critical @@ -105,4 +107,148 @@ export async function getDoctorAppointmentContext(userId: string, appointmentId: 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 }; -} \ No newline at end of file +} + +/** + * 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; + }, +) { + 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.'); + } + + return appointmentsRepo.createAppointment(db, { + patientProfileId: input.patientProfileId, + doctorProfileId: input.doctorProfileId, + availabilitySlotId: input.availabilitySlotId, + origin: 'referral', + referralId: input.referralId, + }); +} + +/** + * 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 (appointment.status !== 'scheduled') { + 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); +} diff --git a/backend/src/modules/appointments/validation.ts b/backend/src/modules/appointments/validation.ts index 7293d8a..3548550 100644 --- a/backend/src/modules/appointments/validation.ts +++ b/backend/src/modules/appointments/validation.ts @@ -3,3 +3,7 @@ import { z } from 'zod'; export const bookSlotSchema = z.object({ availabilitySlotId: z.string().min(1), }); + +export const updateStatusSchema = z.object({ + status: z.enum(['completed', 'cancelled', 'no_show']), +}); diff --git a/backend/src/modules/availability/repositories/index.ts b/backend/src/modules/availability/repositories/index.ts index f236cf6..75e5122 100644 --- a/backend/src/modules/availability/repositories/index.ts +++ b/backend/src/modules/availability/repositories/index.ts @@ -53,3 +53,18 @@ export async function claimOpenSlot(db: DbClient, slotId: string): Promise now` — a slot whose time has passed is + * left alone: the visit happened (or didn't) either way, and re-opening + * it would put unbookable time back on the calendar. + */ +export async function releaseBookedSlotIfFuture(db: DbClient, slotId: string): Promise { + const result = await db.availabilitySlot.updateMany({ + where: { id: slotId, status: 'booked', startTime: { gt: new Date() } }, + data: { status: 'open' }, + }); + return result.count === 1; +} diff --git a/backend/src/modules/availability/services/index.ts b/backend/src/modules/availability/services/index.ts index c9843df..dd1117e 100644 --- a/backend/src/modules/availability/services/index.ts +++ b/backend/src/modules/availability/services/index.ts @@ -48,9 +48,10 @@ export async function generateSlots(input: GenerateSlotsInput) { } /** Re-exported for appointments/services — the only cross-module entry - * points into this module's booking-concurrency primitive and slot reads. - * Never import availability/repositories from another module directly. */ -export { claimOpenSlot, findSlotById as getSlot } from '../repositories'; + * points into this module's booking-concurrency primitive, slot reads and + * slot release. Never import availability/repositories from another + * module directly. */ +export { claimOpenSlot, findSlotById as getSlot, releaseBookedSlotIfFuture } from '../repositories'; /** Batch slot-time lookup for other modules (patient data export, issue * #40). Times only — no status or doctor internals. */ diff --git a/backend/src/modules/consultations/controllers/index.ts b/backend/src/modules/consultations/controllers/index.ts index 4f9c055..53e98e6 100644 --- a/backend/src/modules/consultations/controllers/index.ts +++ b/backend/src/modules/consultations/controllers/index.ts @@ -1,2 +1,29 @@ -// consultations controllers — placeholder. -export {}; +import type { Request, Response } from 'express'; +import * as consultationsService from '../services'; +import { createConsultationSchema, createReferralsSchema } from '../validation'; +import { HttpError } from '../../../http-error'; +import { getParam } from '../../../request-params'; + +export async function create(req: Request, res: Response) { + if (!req.user) throw new HttpError(401, 'Authentication required.'); + const input = createConsultationSchema.parse(req.body); + const consultation = await consultationsService.createConsultation(req.user.id, input); + res.status(201).json(consultation); +} + +export async function getOne(req: Request, res: Response) { + if (!req.user) throw new HttpError(401, 'Authentication required.'); + const consultation = await consultationsService.getConsultation(req.user.id, getParam(req, 'consultationId')); + res.status(200).json(consultation); +} + +export async function createReferrals(req: Request, res: Response) { + if (!req.user) throw new HttpError(401, 'Authentication required.'); + const { targets } = createReferralsSchema.parse(req.body); + const referrals = await consultationsService.createReferrals( + req.user.id, + getParam(req, 'consultationId'), + targets, + ); + res.status(201).json(referrals); +} diff --git a/backend/src/modules/consultations/repositories/index.ts b/backend/src/modules/consultations/repositories/index.ts index 2abd328..3ab8404 100644 --- a/backend/src/modules/consultations/repositories/index.ts +++ b/backend/src/modules/consultations/repositories/index.ts @@ -1,3 +1,22 @@ -// consultations repositories — placeholder. Data access only. Not to be imported -// by other modules directly — go through services/ instead. -export {}; +import { prisma } from '../../../db/prisma-client'; +import type { ConsultationOutcome, Prisma } from '@prisma/client'; + +type DbClient = typeof prisma | Prisma.TransactionClient; + +export function createConsultation( + db: DbClient, + data: { appointmentId: string; outcome?: ConsultationOutcome; notes?: string }, +) { + return db.consultation.create({ data }); +} + +export function findConsultationById(db: DbClient, id: string) { + return db.consultation.findUnique({ + where: { id }, + include: { appointment: true, referrals: true }, + }); +} + +export function findConsultationByAppointmentId(db: DbClient, appointmentId: string) { + return db.consultation.findUnique({ where: { appointmentId } }); +} diff --git a/backend/src/modules/consultations/routes/index.ts b/backend/src/modules/consultations/routes/index.ts index b92e71e..9b10d30 100644 --- a/backend/src/modules/consultations/routes/index.ts +++ b/backend/src/modules/consultations/routes/index.ts @@ -1,3 +1,26 @@ -// consultations routes — placeholder. See PRD.md / ARCHITECTURE.md §3 for this module's scope. -// TODO(Phase: see ROADMAP.md): define routes for consultations. -export {}; +import { Router } from 'express'; +import { authenticate, requireRole, requireVerifiedEmail } from '../../../middleware/auth'; +import * as consultationsController from '../controllers'; + +export const consultationsRouter = Router(); + +// Recording a consultation and spawning referrals from it both hand a +// patient onward with clinical context — verified-address bar, same as +// booking and referral creation itself. +consultationsRouter.post( + '/', + authenticate, + requireRole('doctor'), + requireVerifiedEmail, + consultationsController.create, +); +consultationsRouter.get('/:consultationId', authenticate, consultationsController.getOne); +// Issue #66 (PRD §8.2.4): multiple simultaneous referrals from one +// consultation, atomic all-or-none, each with its own snapshot copy. +consultationsRouter.post( + '/:consultationId/referrals', + authenticate, + requireRole('doctor'), + requireVerifiedEmail, + consultationsController.createReferrals, +); diff --git a/backend/src/modules/consultations/services/index.ts b/backend/src/modules/consultations/services/index.ts index dc0caaf..6ed8e8c 100644 --- a/backend/src/modules/consultations/services/index.ts +++ b/backend/src/modules/consultations/services/index.ts @@ -1,3 +1,72 @@ -// consultations services — placeholder. Other modules must only import from here, -// never from repositories/ directly. See AGENTS.md "Non-negotiable rules". -export {}; +// Consultations services — minimal Phase-3 slice (issue #5's lineage +// host + issue #66's multi-referral endpoint). Full consultation record +// workflow (issue #4: sign-off, addenda) stays open; this exists so a +// referral has a consultation to grow out of and a lineage to walk. +import { prisma } from '../../../db/prisma-client'; +import { HttpError } from '../../../http-error'; +import type { ConsultationOutcome } from '@prisma/client'; +import * as consultationsRepo from '../repositories'; +import * as referralsService from '../../referrals/services'; +import * as doctorsService from '../../doctors/services'; +import * as patientsService from '../../patients/services'; +import type { ReferralTargetInput } from '../../referrals/services'; + +/** Doctor records a consultation against their own completed appointment. + * One consultation per appointment (unique appointmentId) — a re-visit is + * a new appointment, not a second row here. */ +export async function createConsultation( + userId: string, + input: { appointmentId: string; outcome?: ConsultationOutcome; notes?: string }, +) { + const doctor = await doctorsService.getMyDoctorProfile(userId); + + return prisma.$transaction(async (tx) => { + const appointment = await tx.appointment.findUnique({ where: { id: input.appointmentId } }); + if (!appointment) throw new HttpError(404, 'Appointment not found.'); + if (appointment.doctorProfileId !== doctor.id) { + throw new HttpError(403, 'You can only record consultations for your own appointments.'); + } + if (appointment.status !== 'completed') { + throw new HttpError(409, 'A consultation is recorded against a completed appointment.'); + } + const existing = await consultationsRepo.findConsultationByAppointmentId(tx, input.appointmentId); + if (existing) { + throw new HttpError(409, 'A consultation already exists for this appointment.'); + } + return consultationsRepo.createConsultation(tx, { + appointmentId: input.appointmentId, + outcome: input.outcome, + notes: input.notes, + }); + }); +} + +/** Visible to the treating doctor and the patient of the underlying + * appointment — nothing wider in v1. */ +export async function getConsultation(userId: string, consultationId: string) { + const consultation = await consultationsRepo.findConsultationById(prisma, consultationId); + if (!consultation) throw new HttpError(404, 'Consultation not found.'); + + if (consultation.appointment) { + const doctor = await doctorsService.getMyDoctorProfile(userId).catch(() => null); + if (doctor && doctor.id === consultation.appointment.doctorProfileId) return consultation; + const patient = await patientsService.getMyPatientProfile(userId).catch(() => null); + if (patient && patient.id === consultation.appointment.patientProfileId) return consultation; + } + throw new HttpError(403, 'You do not have access to this consultation.'); +} + +/** + * POST /consultations/:id/referrals (issue #66): multiple simultaneous + * referrals from one consultation, atomically. Delegates to the + * referrals service — the referral write surface lives there; this is a + * service-to-service call across the module boundary, never a repository + * import. + */ +export async function createReferrals( + userId: string, + consultationId: string, + targets: ReferralTargetInput[], +) { + return referralsService.createReferralBatch(userId, consultationId, targets); +} diff --git a/backend/src/modules/consultations/validation.ts b/backend/src/modules/consultations/validation.ts new file mode 100644 index 0000000..fc47e98 --- /dev/null +++ b/backend/src/modules/consultations/validation.ts @@ -0,0 +1,12 @@ +import { z } from 'zod'; +import { referralTargetSchema } from '../referrals/validation'; + +export const createConsultationSchema = z.object({ + appointmentId: z.string().min(1), + outcome: z.enum(['resolved', 'referred', 'follow_up_needed']).optional(), + notes: z.string().trim().min(1).optional(), +}); + +export const createReferralsSchema = z.object({ + targets: z.array(referralTargetSchema).min(1), +}); diff --git a/backend/src/modules/doctors/repositories/index.ts b/backend/src/modules/doctors/repositories/index.ts index 6b99f81..13b8771 100644 --- a/backend/src/modules/doctors/repositories/index.ts +++ b/backend/src/modules/doctors/repositories/index.ts @@ -31,3 +31,10 @@ export async function lockVerificationStatus( export function updateVerificationStatus(db: DbClient, id: string, status: VerificationStatus) { return db.doctorProfile.update({ where: { id }, data: { verificationStatus: status } }); } + +export function findDoctorSpecialties(doctorProfileId: string) { + return prisma.doctorSpecialty.findMany({ + where: { doctorProfileId }, + include: { specialty: true }, + }); +} diff --git a/backend/src/modules/doctors/services/index.ts b/backend/src/modules/doctors/services/index.ts index 704dcd9..045bbfb 100644 --- a/backend/src/modules/doctors/services/index.ts +++ b/backend/src/modules/doctors/services/index.ts @@ -66,3 +66,37 @@ export async function setVerificationStatus( return profile; }); } + +/** A referral target (or admin-reassignment target) must be a verified + * doctor — an unverified profile has no business receiving patients + * (issue #68: reassignment goes through this gate, never around it). */ +export async function assertDoctorVerified(doctorProfileId: string) { + const profile = await getDoctorProfileById(doctorProfileId); + if (profile.verificationStatus !== 'verified') { + throw new HttpError(422, 'Target doctor is not verified.'); + } + return profile; +} + +/** + * Issue #68: the reassignment target must be verified AND actually hold + * the referral's target specialty. Checked through this module's own + * repository — callers (referrals/admin ops) never touch DoctorSpecialty + * directly (module-boundary hard rule). + */ +export async function assertDoctorVerifiedInSpecialty(doctorProfileId: string, specialtyId: string) { + const profile = await assertDoctorVerified(doctorProfileId); + const specialties = await doctorsRepo.findDoctorSpecialties(doctorProfileId); + const holds = specialties.some((link) => link.specialtyId === specialtyId); + if (!holds) { + throw new HttpError(422, 'Target doctor does not hold the referral’s specialty.'); + } + return profile; +} + +/** Doctor's specialty ids — used by referral creation to denormalise the + * target specialty onto the referral for the loop guard. */ +export async function listDoctorSpecialtyIds(doctorProfileId: string) { + const specialties = await doctorsRepo.findDoctorSpecialties(doctorProfileId); + return specialties.map((link) => link.specialtyId); +} diff --git a/backend/src/modules/patients/repositories/index.ts b/backend/src/modules/patients/repositories/index.ts index 8f8d28b..f1afefe 100644 --- a/backend/src/modules/patients/repositories/index.ts +++ b/backend/src/modules/patients/repositories/index.ts @@ -162,3 +162,7 @@ export function deleteUnservableExportChunks(): Promise { AND (c."attempt" <> e."attempt" OR e."status" IN ('failed', 'expired')) `; } + +export function findPatientProfileById(id: string) { + return prisma.patientProfile.findUnique({ where: { id } }); +} diff --git a/backend/src/modules/patients/services/index.ts b/backend/src/modules/patients/services/index.ts index 7adc067..b700797 100644 --- a/backend/src/modules/patients/services/index.ts +++ b/backend/src/modules/patients/services/index.ts @@ -279,3 +279,9 @@ export async function expireExports(now = new Date()): Promise<{ expired: number const orphanChunksDeleted = await patientsRepo.deleteUnservableExportChunks(); return { expired, orphanChunksDeleted }; } + +export async function getPatientProfileById(id: string) { + const profile = await patientsRepo.findPatientProfileById(id); + if (!profile) throw new HttpError(404, 'Patient profile not found.'); + return profile; +} diff --git a/backend/src/modules/referrals/controllers/index.ts b/backend/src/modules/referrals/controllers/index.ts index 48b7f1a..2dbde05 100644 --- a/backend/src/modules/referrals/controllers/index.ts +++ b/backend/src/modules/referrals/controllers/index.ts @@ -1,2 +1,86 @@ -// referrals controllers — placeholder. -export {}; +import type { Request, Response } from 'express'; +import * as referralsService from '../services'; +import { + acceptReferralSchema, + adminCancelSchema, + adminQueueQuerySchema, + adminReassignSchema, + createReferralBatchSchema, + createReferralSchema, +} from '../validation'; +import { HttpError } from '../../../http-error'; +import { getParam } from '../../../request-params'; + +export async function create(req: Request, res: Response) { + if (!req.user) throw new HttpError(401, 'Authentication required.'); + const input = createReferralSchema.parse(req.body); + const referral = await referralsService.createReferral(req.user.id, input); + res.status(201).json(referral); +} + +export async function createBatch(req: Request, res: Response) { + if (!req.user) throw new HttpError(401, 'Authentication required.'); + const sourceConsultationId = getParam(req, 'consultationId'); + const { targets } = createReferralBatchSchema.parse(req.body); + const referrals = await referralsService.createReferralBatch(req.user.id, sourceConsultationId, targets); + res.status(201).json(referrals); +} + +export async function accept(req: Request, res: Response) { + if (!req.user) throw new HttpError(401, 'Authentication required.'); + const { availabilitySlotId } = acceptReferralSchema.parse(req.body); + const referral = await referralsService.acceptReferral(req.user.id, getParam(req, 'referralId'), availabilitySlotId); + res.status(200).json(referral); +} + +export async function decline(req: Request, res: Response) { + if (!req.user) throw new HttpError(401, 'Authentication required.'); + const referral = await referralsService.declineReferral(req.user.id, getParam(req, 'referralId')); + res.status(200).json(referral); +} + +export async function listMine(req: Request, res: Response) { + if (!req.user) throw new HttpError(401, 'Authentication required.'); + const referrals = await referralsService.listMine(req.user.id); + // Grouped by source consultation for the patient's "one visit, several + // referrals" view (issue #66): independent lifecycles, presented as the + // bundle they were born as. + const grouped = referrals.reduce>((acc, referral) => { + (acc[referral.sourceConsultationId] ??= []).push(referral); + return acc; + }, {}); + res.status(200).json({ referrals, groupedByConsultation: grouped }); +} + +export async function listCreated(req: Request, res: Response) { + if (!req.user) throw new HttpError(401, 'Authentication required.'); + const referrals = await referralsService.listCreated(req.user.id); + res.status(200).json(referrals); +} + +export async function adminQueue(req: Request, res: Response) { + if (!req.user) throw new HttpError(401, 'Authentication required.'); + const filters = adminQueueQuerySchema.parse(req.query); + const rows = await referralsService.adminListReferrals(req.user.id, filters); + res.status(200).json(rows); +} + +export async function adminReassign(req: Request, res: Response) { + if (!req.user) throw new HttpError(401, 'Authentication required.'); + const input = adminReassignSchema.parse(req.body); + const referral = await referralsService.adminReassign(req.user.id, getParam(req, 'referralId'), input); + res.status(200).json(referral); +} + +export async function adminCancel(req: Request, res: Response) { + if (!req.user) throw new HttpError(401, 'Authentication required.'); + const input = adminCancelSchema.parse(req.body); + const referral = await referralsService.adminCancel(req.user.id, getParam(req, 'referralId'), input); + res.status(200).json(referral); +} + +export async function auditTrail(req: Request, res: Response) { + if (!req.user) throw new HttpError(401, 'Authentication required.'); + const events = await referralsService.listAuditEvents(req.user.id, getParam(req, 'referralId')); + res.status(200).json(events); +} diff --git a/backend/src/modules/referrals/loop.ts b/backend/src/modules/referrals/loop.ts new file mode 100644 index 0000000..2f70c9d --- /dev/null +++ b/backend/src/modules/referrals/loop.ts @@ -0,0 +1,104 @@ +// Issue #65 / PRD §8.1.2: pure loop-chain evaluation, kept free of Prisma +// so it can be unit-tested without a database. The SQL walk lives in +// referrals/repositories; this decides what the walk's output means. +// +// STRICT rule (chosen deliberately over a "return to the GP is fine" +// exception): a referral whose target doctor OR target specialty already +// appears ANYWHERE in the ancestor chain is flagged — including the +// original referring GP. A human on the admin queue decides whether the +// repeat is clinically legitimate; the system never silently allows a +// bounce and never hard-blocks a possibly-legitimate one. + +/** One ancestor referral in the chain, nearest-first. */ +export interface ChainNode { + id: string; + referringDoctorId: string; + targetDoctorId: string; + targetSpecialtyId: string | null; + /** Non-null means the chain continues further back. */ + parentReferralId: string | null; +} + +export interface LoopEvaluation { + flagged: boolean; + reason: string | null; +} + +/** + * Maximum number of referrals allowed in one lineage chain, INCLUDING the + * one being created. Bounded so the CTE walk is bounded (hard rule: no + * unbounded recursion) and so an endlessly bouncing patient hits a wall + * a human can see. Env-overridable; 4 is a boring v1 default. + */ +export const REFERRAL_MAX_HOPS = (() => { + const fromEnv = Number(process.env.REFERRAL_MAX_HOPS); + return Number.isInteger(fromEnv) && fromEnv > 0 ? fromEnv : 4; +})(); + +/** + * Decides whether a candidate referral is a loop. + * + * Throws (caller maps to 422) when: + * - the candidate targets its own referring doctor (self-referral), or + * - the ancestor chain is longer than the hop limit allows. + * + * Returns `{ flagged, reason }` otherwise: flagged referrals are still + * created — they surface in the admin queue with the reason attached. + */ +export function evaluateReferralLoop( + ancestors: ChainNode[], + candidate: { referringDoctorId: string; targetDoctorId: string; targetSpecialtyId?: string | null }, +): LoopEvaluation { + if (candidate.targetDoctorId === candidate.referringDoctorId) { + throw new LoopGuardError('A doctor cannot refer a patient to themselves.'); + } + + // ancestors is bounded by the CTE at REFERRAL_MAX_HOPS - 1 nodes; if + // the farthest one still has a parent, the real chain is longer. + const farthest = ancestors[ancestors.length - 1]; + if (farthest && farthest.parentReferralId !== null) { + throw new LoopGuardError( + `Referral chain exceeds the maximum of ${REFERRAL_MAX_HOPS} hops; escalate to a human.`, + ); + } + if (ancestors.length + 1 > REFERRAL_MAX_HOPS) { + throw new LoopGuardError( + `Referral chain exceeds the maximum of ${REFERRAL_MAX_HOPS} hops; escalate to a human.`, + ); + } + + for (const ancestor of ancestors) { + if (ancestor.targetDoctorId === candidate.targetDoctorId) { + return { + flagged: true, + reason: `Target doctor already appears in referral chain (referral ${ancestor.id}).`, + }; + } + if (ancestor.referringDoctorId === candidate.targetDoctorId) { + return { + flagged: true, + reason: `Target is the referring doctor of an earlier referral in the chain (referral ${ancestor.id}).`, + }; + } + if ( + candidate.targetSpecialtyId && + ancestor.targetSpecialtyId && + ancestor.targetSpecialtyId === candidate.targetSpecialtyId + ) { + return { + flagged: true, + reason: `Target specialty already visited in referral chain (referral ${ancestor.id}).`, + }; + } + } + + return { flagged: false, reason: null }; +} + +/** Thrown by evaluateReferralLoop for hard rejections (not flag-worthy). */ +export class LoopGuardError extends Error { + constructor(message: string) { + super(message); + this.name = 'LoopGuardError'; + } +} diff --git a/backend/src/modules/referrals/repositories/index.ts b/backend/src/modules/referrals/repositories/index.ts index 841bbbb..d1a7275 100644 --- a/backend/src/modules/referrals/repositories/index.ts +++ b/backend/src/modules/referrals/repositories/index.ts @@ -1,3 +1,211 @@ -// referrals repositories — placeholder. Data access only. Not to be imported -// by other modules directly — go through services/ instead. -export {}; +import { prisma } from '../../../db/prisma-client'; +import { Prisma } from '@prisma/client'; +import type { ReferralStatus } from '@prisma/client'; +import type { ChainNode } from '../loop'; + +type DbClient = typeof prisma | Prisma.TransactionClient; + +export interface CreateReferralData { + patientProfileId: string; + sourceConsultationId: string; + referringDoctorId: string; + targetDoctorId: string; + targetSpecialtyId?: string; + snapshotNotes: string; + parentReferralId?: string; + loopFlaggedAt?: Date; + loopFlagReason?: string; +} + +export function createReferral(db: DbClient, data: CreateReferralData) { + return db.referral.create({ data }); +} + +export function findReferralById(db: DbClient, id: string) { + return db.referral.findUnique({ where: { id } }); +} + +/** + * The referral state machine's only write path for status transitions: a + * conditional UPDATE that matches the expected *current* (status, + * resultingAppointmentId) pair — the same shape as availability's + * claimOpenSlot. Two callers racing on the same referral (a double-fired + * cascade hook, a concurrent accept) can never both match; the loser sees + * `count === 0`. This is what makes the cascade rules idempotent by + * construction (issue #67), rather than trusting callers to only fire + * once. + */ +export function updateReferralConditional( + db: DbClient, + expected: { id: string; status: ReferralStatus; resultingAppointmentId: string | null }, + data: { + status?: ReferralStatus; + resultingAppointmentId?: string | null; + noShowCount?: number; + referringDoctorNotifiedAt?: Date; + }, +) { + return db.referral.updateMany({ + where: { + id: expected.id, + status: expected.status, + resultingAppointmentId: expected.resultingAppointmentId, + }, + data, + }); +} + +/** Admin ops (issue #68): reassign/cancel are single-row, guarded on the + * expected status so two admins can't both act on the same stuck + * referral. Only ever called from the referrals service with a status the + * service has already authorised. */ +export function updateReferralIfStatus( + db: DbClient, + id: string, + expectedStatus: ReferralStatus | ReferralStatus[], + data: { + status?: ReferralStatus; + targetDoctorId?: string; + cancelledReason?: string; + }, +) { + return db.referral.updateMany({ + where: { id, status: Array.isArray(expectedStatus) ? { in: expectedStatus } : expectedStatus }, + data, + }); +} + +export function listReferralsForPatient(patientProfileId: string) { + return prisma.referral.findMany({ + where: { patientProfileId }, + include: { sourceConsultation: true }, + orderBy: { createdAt: 'desc' }, + }); +} + +export function listReferralsCreatedBy(referringDoctorId: string) { + return prisma.referral.findMany({ + where: { referringDoctorId }, + orderBy: { createdAt: 'desc' }, + }); +} + +/** Duplicate-specialty guard (#66): any referral already hanging off this + * consultation aimed at the same doctor or specialty. Matches ALL + * statuses on purpose — a declined referral still means "we tried this". */ +export function findByConsultationAndTarget( + db: DbClient, + sourceConsultationId: string, + target: { targetDoctorId: string; targetSpecialtyId?: string | null }, +) { + return db.referral.findFirst({ + where: { + sourceConsultationId, + OR: [ + { targetDoctorId: target.targetDoctorId }, + ...(target.targetSpecialtyId ? [{ targetSpecialtyId: target.targetSpecialtyId }] : []), + ], + }, + }); +} + +/** + * Issue #65: walk the ancestor chain of `fromReferralId` via its + * parentReferralId links, nearest-first, bounded at `limit` hops. If the + * last returned node still has a parent, the real chain is longer than + * the bound — evaluateReferralLoop turns that into a hard rejection. + * + * A recursive CTE, not N round-trips: bounded depth lives in the query + * itself, so a pathological chain can't fan out at the app layer. + */ +export async function findReferralAncestors( + db: DbClient, + fromReferralId: string | null, + limit: number, +): Promise { + if (!fromReferralId) return []; + const rows = await db.$queryRaw< + { + id: string; + referringDoctorId: string; + targetDoctorId: string; + targetSpecialtyId: string | null; + parentReferralId: string | null; + depth: number; + }[] + >(Prisma.sql` + WITH RECURSIVE referral_chain AS ( + SELECT + r."id", r."referringDoctorId", r."targetDoctorId", + r."targetSpecialtyId", r."parentReferralId", 1 AS depth + FROM "Referral" r + WHERE r."id" = ${fromReferralId} + UNION ALL + SELECT + r."id", r."referringDoctorId", r."targetDoctorId", + r."targetSpecialtyId", r."parentReferralId", c.depth + 1 + FROM "Referral" r + JOIN referral_chain c ON r."id" = c."parentReferralId" + WHERE c.depth < ${limit} + ) + SELECT * FROM referral_chain ORDER BY depth + `); + return rows; +} + +/** Lineage lookup for parentReferralId: the referral (if any) whose + * appointment produced this consultation. */ +export async function findParentReferralForConsultation( + db: DbClient, + sourceConsultationId: string, +): Promise { + const rows = await db.$queryRaw<{ referralId: string | null }[]>` + SELECT a."referralId" AS "referralId" + FROM "Consultation" c + JOIN "Appointment" a ON a."id" = c."appointmentId" + WHERE c."id" = ${sourceConsultationId} + `; + return rows[0]?.referralId ?? null; +} + +/** Issue #68: stuck-referral / loop-flagged admin queues. Filters are + * conjunctive; every one is optional so the same repo call serves both + * the "stale pending" view and the "loop-flagged" view. */ +export function listAdminQueue(filters: { + status?: ReferralStatus; + createdBefore?: Date; + loopFlagged?: boolean; +}) { + return prisma.referral.findMany({ + where: { + ...(filters.status ? { status: filters.status } : {}), + ...(filters.createdBefore ? { createdAt: { lt: filters.createdBefore } } : {}), + ...(filters.loopFlagged ? { loopFlaggedAt: { not: null } } : {}), + }, + include: { + sourceConsultation: { select: { id: true, outcome: true } }, + }, + orderBy: { createdAt: 'asc' }, + }); +} + +export function createAuditEvent( + db: DbClient, + event: { + actorUserId: string; + actorRole: string; + action: string; + referralId?: string; + reason?: string; + metadata?: Prisma.InputJsonValue; + }, +) { + return db.auditEvent.create({ data: event }); +} + +export function listAuditEventsForReferral(referralId: string) { + return prisma.auditEvent.findMany({ + where: { referralId }, + orderBy: { createdAt: 'desc' }, + }); +} diff --git a/backend/src/modules/referrals/routes/index.ts b/backend/src/modules/referrals/routes/index.ts index 8cf75df..5eb9a29 100644 --- a/backend/src/modules/referrals/routes/index.ts +++ b/backend/src/modules/referrals/routes/index.ts @@ -1,3 +1,75 @@ -// referrals routes — placeholder. See PRD.md / ARCHITECTURE.md §3 for this module's scope. -// TODO(Phase: see ROADMAP.md): define routes for referrals. -export {}; +import { Router } from 'express'; +import { authenticate, requireRole, requireVerifiedEmail } from '../../../middleware/auth'; +import * as referralsController from '../controllers'; + +export const referralsRouter = Router(); + +// Creating a referral hands a patient to another doctor with clinical +// context attached, and accepting one books a real slot — both are +// mutations that need a trustworthy contact address, same bar as booking +// itself (Phase 8, issue #34's guard). +referralsRouter.post( + '/', + authenticate, + requireRole('doctor'), + requireVerifiedEmail, + referralsController.create, +); +// Multiple simultaneous referrals from ONE consultation (issue #66, PRD +// §8.2.4) — atomic all-or-none batch, same guard rails as the single +// create above. Mounted here rather than in consultations/ so the whole +// referral write surface stays in this module; consultations' own routes +// live in their module. +referralsRouter.post( + '/from-consultation/:consultationId', + authenticate, + requireRole('doctor'), + requireVerifiedEmail, + referralsController.createBatch, +); +referralsRouter.post( + '/:referralId/accept', + authenticate, + requireRole('patient'), + requireVerifiedEmail, + referralsController.accept, +); +referralsRouter.post( + '/:referralId/decline', + authenticate, + requireRole('patient'), + requireVerifiedEmail, + referralsController.decline, +); +referralsRouter.get('/mine', authenticate, requireRole('patient'), referralsController.listMine); +// Referring doctors see the referrals they created — including ones the +// cascade expired after repeated no-shows (referringDoctorNotifiedAt) and +// ones the loop guard flagged. +referralsRouter.get('/created', authenticate, requireRole('doctor'), referralsController.listCreated); + +// Admin operations (issue #68). The queue doubles as the loop-detection +// admin view (#65) via ?loopFlagged=true. +referralsRouter.get( + '/admin/queue', + authenticate, + requireRole('admin'), + referralsController.adminQueue, +); +referralsRouter.post( + '/admin/:referralId/reassign', + authenticate, + requireRole('admin'), + referralsController.adminReassign, +); +referralsRouter.post( + '/admin/:referralId/cancel', + authenticate, + requireRole('admin'), + referralsController.adminCancel, +); +referralsRouter.get( + '/admin/:referralId/audit-events', + authenticate, + requireRole('admin'), + referralsController.auditTrail, +); diff --git a/backend/src/modules/referrals/services/index.ts b/backend/src/modules/referrals/services/index.ts index 07a8594..5f6f6da 100644 --- a/backend/src/modules/referrals/services/index.ts +++ b/backend/src/modules/referrals/services/index.ts @@ -1,3 +1,520 @@ -// referrals services — placeholder. Other modules must only import from here, -// never from repositories/ directly. See AGENTS.md "Non-negotiable rules". -export {}; +// Referrals services — the only exported surface other modules (and this +// module's own controllers) may call. Never import repositories/* from +// outside this module. +// +// NOTE: this module and appointments/services import each other (accept +// creates an appointment via the appointments service; appointment status +// changes cascade back through applyAppointmentOutcome below; admin cancel +// closes a live appointment through forceCancelAppointment). The cycle is +// runtime-safe under ESM live bindings — every cross-call happens inside +// an async function, after both modules have finished loading — and is +// the smallest seam that keeps each module owning its own table +// (ARCHITECTURE.md §3 / ARCHITECTURE_ESSENTIALS.md hard rule 4). +import { prisma } from '../../../db/prisma-client'; +import { HttpError } from '../../../http-error'; +import type { Prisma, ReferralStatus } from '@prisma/client'; +import * as referralsRepo from '../repositories'; +import * as appointmentsService from '../../appointments/services'; +import * as doctorsService from '../../doctors/services'; +import * as patientsService from '../../patients/services'; +import { evaluateReferralLoop, LoopGuardError, REFERRAL_MAX_HOPS } from '../loop'; + +/** How many referral-origin no-shows it takes to give up on a referral + * (PRD §8.2.3: "repeated no-shows → expired"). Two is a deliberately + * boring v1 default, not a tuned threshold — revisit when real no-show + * data exists. Lives here, not in config, so the cascade and its tests + * read from one place. */ +export const NO_SHOW_EXPIRY_THRESHOLD = 2; + +export interface ReferralTargetInput { + targetDoctorId: string; + targetSpecialtyId?: string; + snapshotNotes: string; +} + +export interface CreateReferralInput extends ReferralTargetInput { + sourceConsultationId: string; +} + +interface PreparedReferral { + patientProfileId: string; + sourceConsultationId: string; + referringDoctorId: string; + targetDoctorId: string; + targetSpecialtyId?: string; + snapshotNotes: string; + parentReferralId?: string; + loopFlaggedAt?: Date; + loopFlagReason?: string; +} + +/** + * Shared validation for one referral target against its source + * consultation. Returns the fully-prepared row data (lineage + loop flag + * resolved) or throws. `existingTargets` guards the multi-referral batch + * case (#66): two targets in one batch may not share a doctor or + * specialty. + */ +async function prepareReferralTarget( + db: Prisma.TransactionClient, + consultation: { + id: string; + appointment: { patientProfileId: string; doctorProfileId: string; status: string } | null; + }, + referringDoctorId: string, + target: ReferralTargetInput, + existingTargets: { targetDoctorId: string; targetSpecialtyId?: string | null }[], +): Promise { + if (!consultation.appointment) { + throw new HttpError(422, 'Consultation has no appointment; lineage cannot be established.'); + } + if (consultation.appointment.doctorProfileId !== referringDoctorId) { + throw new HttpError(403, 'You can only refer from your own consultation.'); + } + if (consultation.appointment.status !== 'completed') { + throw new HttpError(409, 'A referral requires a completed appointment.'); + } + + const patientProfileId = consultation.appointment.patientProfileId; + + if (target.targetDoctorId === referringDoctorId) { + throw new HttpError(422, 'A doctor cannot refer a patient to themselves.'); + } + await doctorsService.assertDoctorVerified(target.targetDoctorId); + if (target.targetSpecialtyId) { + await doctorsService.assertDoctorVerifiedInSpecialty(target.targetDoctorId, target.targetSpecialtyId); + } + + const duplicateInBatch = existingTargets.some( + (other) => + other.targetDoctorId === target.targetDoctorId || + (!!target.targetSpecialtyId && other.targetSpecialtyId === target.targetSpecialtyId), + ); + if (duplicateInBatch) { + throw new HttpError(422, 'Duplicate target doctor or specialty in this set of referrals.'); + } + + const duplicateInConsultation = await referralsRepo.findByConsultationAndTarget(db, consultation.id, { + targetDoctorId: target.targetDoctorId, + targetSpecialtyId: target.targetSpecialtyId ?? null, + }); + if (duplicateInConsultation) { + throw new HttpError(409, 'A referral to this doctor or specialty already exists for this consultation.'); + } + + const parentReferralId = + (await referralsRepo.findParentReferralForConsultation(db, consultation.id)) ?? undefined; + + let loopFlaggedAt: Date | undefined; + let loopFlagReason: string | undefined; + if (parentReferralId) { + const ancestors = await referralsRepo.findReferralAncestors(db, parentReferralId, REFERRAL_MAX_HOPS - 1); + try { + const evaluation = evaluateReferralLoop(ancestors, { + referringDoctorId, + targetDoctorId: target.targetDoctorId, + targetSpecialtyId: target.targetSpecialtyId ?? null, + }); + if (evaluation.flagged) { + loopFlaggedAt = new Date(); + loopFlagReason = evaluation.reason ?? undefined; + } + } catch (error) { + if (error instanceof LoopGuardError) { + throw new HttpError(422, error.message); + } + throw error; + } + } + + return { + patientProfileId, + sourceConsultationId: consultation.id, + referringDoctorId, + targetDoctorId: target.targetDoctorId, + targetSpecialtyId: target.targetSpecialtyId, + snapshotNotes: target.snapshotNotes, + parentReferralId, + loopFlaggedAt, + loopFlagReason, + }; +} + +async function loadConsultationForReferringDoctor(db: Prisma.TransactionClient, sourceConsultationId: string) { + const consultation = await db.consultation.findUnique({ + where: { id: sourceConsultationId }, + include: { appointment: true }, + }); + if (!consultation) throw new HttpError(404, 'Consultation not found.'); + return consultation; +} + +/** + * Single referral creation (issues #5/#65/#66/#67 foundation). One + * transaction per call — the loop walk, duplicate check and insert are + * atomic, so a concurrent identical referral loses the race on the + * duplicate check or not at all. + */ +export async function createReferral(userId: string, input: CreateReferralInput) { + const referringDoctor = await doctorsService.getMyDoctorProfile(userId); + + return prisma.$transaction(async (tx) => { + const consultation = await loadConsultationForReferringDoctor(tx, input.sourceConsultationId); + const prepared = await prepareReferralTarget(tx, consultation, referringDoctor.id, input, []); + return referralsRepo.createReferral(tx, prepared); + }); +} + +/** + * Multiple simultaneous referrals from one consultation (issue #66, PRD + * §8.2.4). All-or-none: the whole batch shares one transaction, so a + * failure on the second target rolls back the first. Each target gets its + * OWN snapshot copy (FR-7/FR-8) — two rows, two notes, never a shared + * reference. Acceptance/decline stays per-referral: they are independent + * lifecycles that merely happen to be born together. + */ +export async function createReferralBatch( + userId: string, + sourceConsultationId: string, + targets: ReferralTargetInput[], +) { + if (targets.length === 0) { + throw new HttpError(422, 'At least one referral target is required.'); + } + const referringDoctor = await doctorsService.getMyDoctorProfile(userId); + + return prisma.$transaction(async (tx) => { + const consultation = await loadConsultationForReferringDoctor(tx, sourceConsultationId); + const accepted: { targetDoctorId: string; targetSpecialtyId?: string | null }[] = []; + const created = []; + for (const target of targets) { + const prepared = await prepareReferralTarget(tx, consultation, referringDoctor.id, target, accepted); + created.push(await referralsRepo.createReferral(tx, prepared)); + accepted.push({ + targetDoctorId: target.targetDoctorId, + targetSpecialtyId: target.targetSpecialtyId ?? null, + }); + } + return created; + }); +} + +/** + * Patient accepts a pending referral: one transaction that flips the + * referral pending → accepted (conditional — the concurrency gate), then + * books the pre-populated appointment against the target doctor through + * the appointments service (slot claim + Appointment row, origin + * 'referral'). The patient picks the slot; the appointment is + * "pre-populated" in the FR-8 sense — no re-intake, the snapshot travels + * on the referral — not in the sense of an auto-chosen time. + */ +export async function acceptReferral(userId: string, referralId: string, availabilitySlotId: string) { + const patientProfile = await patientsService.getMyPatientProfile(userId); + + return prisma.$transaction(async (tx) => { + const referral = await referralsRepo.findReferralById(tx, referralId); + if (!referral) throw new HttpError(404, 'Referral not found.'); + if (referral.patientProfileId !== patientProfile.id) { + throw new HttpError(403, 'This referral is not for you.'); + } + if (referral.status !== 'pending') { + throw new HttpError(409, 'Only a pending referral can be accepted.'); + } + + // Conditional pending → accepted FIRST: if two accepts race, exactly + // one matches, same shape as claimOpenSlot (hard rule 2's philosophy, + // applied to referral state). + const claimed = await referralsRepo.updateReferralConditional( + tx, + { id: referralId, status: 'pending', resultingAppointmentId: null }, + { status: 'accepted' }, + ); + if (claimed.count !== 1) { + throw new HttpError(409, 'This referral is no longer pending.'); + } + + const appointment = await appointmentsService.bookForReferral(tx, { + patientProfileId: patientProfile.id, + doctorProfileId: referral.targetDoctorId, + availabilitySlotId, + referralId, + }); + + await referralsRepo.updateReferralConditional( + tx, + { id: referralId, status: 'accepted', resultingAppointmentId: null }, + { resultingAppointmentId: appointment.id }, + ); + + return referralsRepo.findReferralById(tx, referralId); + }); +} + +/** + * Patient declines a pending referral. Deliberately pending-only: once a + * referral is accepted it has a live appointment behind it, and the way + * out is cancelling that appointment — which the cascade below returns to + * pending (re-bookable), from which declining is then possible. Keeps one + * narrow transition instead of two overlapping exit paths. + */ +export async function declineReferral(userId: string, referralId: string) { + const patientProfile = await patientsService.getMyPatientProfile(userId); + const referral = await referralsRepo.findReferralById(prisma, referralId); + if (!referral) throw new HttpError(404, 'Referral not found.'); + if (referral.patientProfileId !== patientProfile.id) { + throw new HttpError(403, 'This referral is not for you.'); + } + if (referral.status !== 'pending') { + throw new HttpError(409, 'Only a pending referral can be declined.'); + } + + const updated = await referralsRepo.updateReferralConditional( + prisma, + { id: referralId, status: 'pending', resultingAppointmentId: null }, + { status: 'declined' }, + ); + if (updated.count !== 1) { + throw new HttpError(409, 'This referral is no longer pending.'); + } + return referralsRepo.findReferralById(prisma, referralId); +} + +export async function listMine(userId: string) { + const patientProfile = await patientsService.getMyPatientProfile(userId); + return referralsRepo.listReferralsForPatient(patientProfile.id); +} + +export async function listCreated(userId: string) { + const referringDoctor = await doctorsService.getMyDoctorProfile(userId); + return referralsRepo.listReferralsCreatedBy(referringDoctor.id); +} + +/** + * THE cascade (issue #67, PRD §8.2.3). Called by appointments/services + * inside the same transaction as the appointment's status transition, so + * the referral can never be left sitting `accepted` behind a dead + * appointment: + * + * completed → referral `completed` (closes FR-9's loop; keeps + * referral-completion metrics honest) + * cancelled → referral back to `pending`, re-bookable — re-accepting + * reuses the same snapshot, no re-intake (FR-8); the + * cancelled appointment keeps its own referralId link, so + * history survives the resultingAppointmentId being cleared + * no_show → noShowCount++, referral back to `pending`; once the count + * reaches NO_SHOW_EXPIRY_THRESHOLD the referral is + * `expired` instead, with referringDoctorNotifiedAt set — + * the flag the referring doctor's view reads (Phase 6 can + * later turn it into a real notification) + * + * Idempotency: every write is a conditional UPDATE that only matches a + * referral still `accepted` *and still pointing at this appointment* — + * a hook that fires a second time after the first one moved the referral + * matches zero rows and no-ops. The no_show counter therefore advances at + * most once per real appointment transition, even if called twice. + */ +export async function applyAppointmentOutcome( + db: Prisma.TransactionClient, + input: { appointmentId: string; referralId: string; status: 'completed' | 'cancelled' | 'no_show' }, +) { + const stillAccepted = { + id: input.referralId, + status: 'accepted' as const, + resultingAppointmentId: input.appointmentId, + }; + + if (input.status === 'completed') { + await referralsRepo.updateReferralConditional(db, stillAccepted, { status: 'completed' }); + return; + } + + if (input.status === 'cancelled') { + await referralsRepo.updateReferralConditional(db, stillAccepted, { + status: 'pending', + resultingAppointmentId: null, + }); + return; + } + + // no_show: read the current count to decide pending-vs-expired, then + // write conditionally. Safe under the caller's transaction: the + // appointment row was just updated under the same tx (locking it), and + // resultingAppointmentId is singular — no second accepted appointment + // can be racing on this referral. + const referral = await referralsRepo.findReferralById(db, input.referralId); + if (!referral || referral.status !== 'accepted' || referral.resultingAppointmentId !== input.appointmentId) { + return; // already processed (or never matched) — idempotent no-op + } + + const noShowCount = referral.noShowCount + 1; + if (noShowCount >= NO_SHOW_EXPIRY_THRESHOLD) { + await referralsRepo.updateReferralConditional(db, stillAccepted, { + status: 'expired', + noShowCount, + referringDoctorNotifiedAt: new Date(), + // resultingAppointmentId intentionally kept: the appointment that + // produced the expiring no-show stays linked as the reason. + }); + } else { + await referralsRepo.updateReferralConditional(db, stillAccepted, { + status: 'pending', + noShowCount, + resultingAppointmentId: null, + }); + } +} + +// --------------------------------------------------------------------------- +// Admin operations (issue #68) +// --------------------------------------------------------------------------- + +export interface AdminQueueFilters { + status?: ReferralStatus; + /** Duration string like "48h" or "7d". Referrals created BEFORE now - + * duration are returned. */ + olderThan?: string; + loopFlagged?: boolean; +} + +function parseDuration(value: string): number { + const match = /^(\d+)([hd])$/.exec(value.trim()); + if (!match) throw new HttpError(400, 'olderThan must look like "48h" or "7d".'); + const amount = Number(match[1]); + return match[2] === 'h' ? amount * 60 * 60 * 1000 : amount * 24 * 60 * 60 * 1000; +} + +/** Issue #68 queue endpoint: stuck referrals (by status + age) and/or + * loop-flagged ones, with a derived human-readable reason and age. */ +export async function adminListReferrals(_userId: string, filters: AdminQueueFilters) { + const createdBefore = filters.olderThan ? new Date(Date.now() - parseDuration(filters.olderThan)) : undefined; + const rows = await referralsRepo.listAdminQueue({ + status: filters.status, + createdBefore, + loopFlagged: filters.loopFlagged, + }); + + const now = Date.now(); + return rows.map((row) => ({ + ...row, + ageMinutes: Math.floor((now - row.createdAt.getTime()) / 60_000), + reason: deriveQueueReason(row), + })); +} + +function deriveQueueReason(row: { + loopFlaggedAt: Date | null; + loopFlagReason: string | null; + noShowCount: number; + status: ReferralStatus; +}): string { + if (row.loopFlaggedAt) return row.loopFlagReason ?? 'loop-flagged'; + if (row.noShowCount > 0) return `previous no-shows (${row.noShowCount})`; + if (row.status === 'pending') return 'awaiting patient acceptance'; + return 'stuck'; +} + +/** + * Reassignment (issue #68): move a PENDING referral to a different + * verified doctor, with an audit event. Only `targetDoctorId` moves — + * the snapshot (notes, patient, consultation, lineage) is untouched, per + * the constraint that reassignment must never alter the snapshot. The + * target is validated through the doctors service, never around it. + */ +export async function adminReassign( + userId: string, + referralId: string, + input: { targetDoctorId: string; reason: string }, +) { + return prisma.$transaction(async (tx) => { + const referral = await referralsRepo.findReferralById(tx, referralId); + if (!referral) throw new HttpError(404, 'Referral not found.'); + + // Reassigning an accepted referral would strand its live appointment + // against the old doctor — cancel that path; admins cancel instead. + if (referral.status !== 'pending') { + throw new HttpError(409, 'Only a pending referral can be reassigned.'); + } + if (input.targetDoctorId === referral.referringDoctorId) { + throw new HttpError(422, 'A doctor cannot be referred to themselves.'); + } + if (referral.targetSpecialtyId) { + await doctorsService.assertDoctorVerifiedInSpecialty(input.targetDoctorId, referral.targetSpecialtyId); + } else { + await doctorsService.assertDoctorVerified(input.targetDoctorId); + } + if (input.targetDoctorId === referral.targetDoctorId) { + throw new HttpError(409, 'Referral already targets that doctor.'); + } + + const updated = await referralsRepo.updateReferralIfStatus(tx, referralId, 'pending', { + targetDoctorId: input.targetDoctorId, + }); + if (updated.count !== 1) { + throw new HttpError(409, 'Referral is no longer pending; it changed while you were working.'); + } + + await referralsRepo.createAuditEvent(tx, { + actorUserId: userId, + actorRole: 'admin', + action: 'referral.reassign', + referralId, + reason: input.reason, + metadata: { fromDoctorId: referral.targetDoctorId, toDoctorId: input.targetDoctorId }, + }); + + return referralsRepo.findReferralById(tx, referralId); + }); +} + +/** + * Admin cancellation (issue #68): terminate a non-terminal referral with + * a reason, audited. If the referral is `accepted`, its live appointment + * is cancelled too (through the appointments service — never by touching + * the Appointment table from here) so we don't re-create the exact + * "dead appointment behind a live referral" bug #67 exists to prevent. + * The patient-notification hook point is the audit event + the referral + * status itself; Phase 6's dispatch layer will read both. + */ +export async function adminCancel(userId: string, referralId: string, input: { reason: string }) { + return prisma.$transaction(async (tx) => { + const referral = await referralsRepo.findReferralById(tx, referralId); + if (!referral) throw new HttpError(404, 'Referral not found.'); + if (referral.status !== 'pending' && referral.status !== 'accepted') { + throw new HttpError(409, 'Only a pending or accepted referral can be cancelled.'); + } + + // Accepted + live appointment: close the appointment first (inside + // this transaction) WITHOUT the normal cascade — we know we're + // cancelling the referral, no need for it to bounce back to pending. + if (referral.status === 'accepted' && referral.resultingAppointmentId) { + await appointmentsService.forceCancelAppointment(tx, referral.resultingAppointmentId); + } + + const updated = await referralsRepo.updateReferralIfStatus(tx, referralId, ['pending', 'accepted'], { + status: 'cancelled', + cancelledReason: input.reason, + }); + if (updated.count !== 1) { + throw new HttpError(409, 'Referral changed while you were working; reload and retry.'); + } + + await referralsRepo.createAuditEvent(tx, { + actorUserId: userId, + actorRole: 'admin', + action: 'referral.cancel', + referralId, + reason: input.reason, + }); + + return referralsRepo.findReferralById(tx, referralId); + }); +} + +export async function listAuditEvents(userId: string, referralId: string) { + // Any authenticated role may read a referral's audit trail — it's the + // accountability surface PRD §7 demands, and contains no clinical text. + void userId; + const referral = await referralsRepo.findReferralById(prisma, referralId); + if (!referral) throw new HttpError(404, 'Referral not found.'); + return referralsRepo.listAuditEventsForReferral(referralId); +} diff --git a/backend/src/modules/referrals/validation.ts b/backend/src/modules/referrals/validation.ts new file mode 100644 index 0000000..0a48ee3 --- /dev/null +++ b/backend/src/modules/referrals/validation.ts @@ -0,0 +1,40 @@ +import { z } from 'zod'; + +export const referralTargetSchema = z.object({ + // v1 requires a concrete target doctor — the routing rule for "a + // specialty, no specific doctor" is an unresolved design gap + // (ARCHITECTURE.md §6.1.2, issue #6), not silently defaulted here. + targetDoctorId: z.string().min(1), + targetSpecialtyId: z.string().min(1).optional(), + snapshotNotes: z.string().trim().min(1), +}); + +export const createReferralSchema = referralTargetSchema.extend({ + sourceConsultationId: z.string().min(1), +}); + +export const createReferralBatchSchema = z.object({ + targets: z.array(referralTargetSchema).min(1), +}); + +export const acceptReferralSchema = z.object({ + availabilitySlotId: z.string().min(1), +}); + +export const adminReassignSchema = z.object({ + targetDoctorId: z.string().min(1), + reason: z.string().trim().min(1), +}); + +export const adminCancelSchema = z.object({ + reason: z.string().trim().min(1), +}); + +export const adminQueueQuerySchema = z.object({ + status: z.enum(['pending', 'accepted', 'declined', 'completed', 'expired', 'cancelled']).optional(), + olderThan: z.string().regex(/^\d+[hd]$/).optional(), + loopFlagged: z + .enum(['true', 'false']) + .optional() + .transform((value) => value === 'true'), +}); diff --git a/backend/tests/integration/admin-referrals.test.mts b/backend/tests/integration/admin-referrals.test.mts new file mode 100644 index 0000000..c3320b8 --- /dev/null +++ b/backend/tests/integration/admin-referrals.test.mts @@ -0,0 +1,282 @@ +// Admin referral operations (issue #68): stuck queue with age/reason, +// reassign with doctors-service validation + audit trail, cancel with +// reason (closing any live appointment). Port 5444. +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import request from 'supertest'; +import EmbeddedPostgres from 'embedded-postgres'; +import { + acceptReferral, + consultationRitual, + createReferral, + generateSlots, + installEmailCapture, + makeAdmin, + makeDoctor, + makePatient, + makeSpecialty, + resetEmailSenderFixture, +} from './helpers/fixtures.mjs'; + +const BACKEND_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const PORT = 5444; +const runId = Date.now(); + +const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'docforum-test-pg-')); +const pg = new EmbeddedPostgres({ + databaseDir: dataDir, + user: 'docforum', + password: 'docforum', + port: PORT, + persistent: false, +}); +const databaseUrl = `postgresql://docforum:docforum@localhost:${PORT}/docforum_test`; + +describe('admin referral operations', () => { + let app: ReturnType; + let prisma: typeof import('../../src/db/prisma-client.js').prisma; + const emailedTokens = new Map(); + let gp: Awaited>; + let cardioA: Awaited>; + let cardioB: Awaited>; + let otherSpecialtyDoctor: Awaited>; + let patient: Awaited>; + let admin: Awaited>; + let cardioSpecialtyId: string; + + beforeAll(async () => { + await pg.initialise(); + await pg.start(); + await pg.createDatabase('docforum_test'); + execFileSync('npx', ['prisma', 'migrate', 'deploy'], { + cwd: BACKEND_ROOT, + env: { ...process.env, DATABASE_URL: databaseUrl }, + stdio: 'pipe', + }); + // Must be set before the app module (and its PrismaClient) loads. + process.env.DATABASE_URL = databaseUrl; + + const appModule = await import('../../src/app.js'); + const dbModule = await import('../../src/db/prisma-client.js'); + app = appModule.createApp(); + prisma = dbModule.prisma; + + await installEmailCapture(emailedTokens); + cardioSpecialtyId = await makeSpecialty(prisma, `Cardiology ${runId}`); + const dermSpecialtyId = await makeSpecialty(prisma, `Dermatology ${runId}`); + gp = await makeDoctor(app, prisma, emailedTokens, runId); + cardioA = await makeDoctor(app, prisma, emailedTokens, runId, { specialtyId: cardioSpecialtyId }); + cardioB = await makeDoctor(app, prisma, emailedTokens, runId, { specialtyId: cardioSpecialtyId }); + otherSpecialtyDoctor = await makeDoctor(app, prisma, emailedTokens, runId, { + specialtyId: dermSpecialtyId, + }); + patient = await makePatient(app, emailedTokens, runId); + admin = await makeAdmin(app, prisma, emailedTokens, runId); + }); + + afterAll(async () => { + await resetEmailSenderFixture(); + await prisma?.$disconnect(); + await pg.stop(); + fs.rmSync(dataDir, { recursive: true, force: true }); + }); + + async function pendingReferralToCardioA(options: { backdate?: boolean } = {}) { + const { consultationId } = await consultationRitual(app, patient, gp); + const created = await createReferral(app, gp, { + sourceConsultationId: consultationId, + targetDoctorId: cardioA.profileId, + targetSpecialtyId: cardioSpecialtyId, + snapshotNotes: 'admin fixture notes', + }); + expect(created.status).toBe(201); + if (options.backdate) { + await prisma.referral.update({ + where: { id: created.body.id }, + data: { createdAt: new Date(Date.now() - 72 * 60 * 60 * 1000) }, + }); + } + return created.body.id as string; + } + + function adminPost(pathname: string, body: object) { + return request(app) + .post(pathname) + .set('Authorization', `Bearer ${admin.accessToken}`) + .send(body); + } + + it('serves the stuck queue with age and reason, filtered by status and olderThan', async () => { + const oldId = await pendingReferralToCardioA({ backdate: true }); + const freshId = await pendingReferralToCardioA(); + + const queue = await request(app) + .get('/referrals/admin/queue?status=pending&olderThan=48h') + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(queue.status).toBe(200); + const ids = (queue.body as { id: string }[]).map((row) => row.id); + expect(ids).toContain(oldId); + expect(ids).not.toContain(freshId); + const oldRow = (queue.body as { id: string; ageMinutes: number; reason: string }[]).find( + (row) => row.id === oldId, + ); + expect(oldRow?.ageMinutes).toBeGreaterThanOrEqual(48 * 60 - 5); + expect(oldRow?.reason).toBe('awaiting patient acceptance'); + + const badQuery = await request(app) + .get('/referrals/admin/queue?olderThan=nonsense') + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(badQuery.status).toBe(400); + }); + + it('reassigns a stuck referral to a verified doctor in the specialty, audited, snapshot untouched', async () => { + const referralId = await pendingReferralToCardioA(); + const res = await adminPost(`/referrals/admin/${referralId}/reassign`, { + targetDoctorId: cardioB.profileId, + reason: 'cardioA on leave', + }); + expect(res.status).toBe(200); + expect(res.body.targetDoctorId).toBe(cardioB.profileId); + expect(res.body.snapshotNotes).toBe('admin fixture notes'); + expect(res.body.status).toBe('pending'); + + const events = await prisma.auditEvent.findMany({ where: { referralId } }); + expect(events).toHaveLength(1); + expect(events[0].action).toBe('referral.reassign'); + expect(events[0].actorUserId).toBe(admin.userId); + expect(events[0].reason).toBe('cardioA on leave'); + expect(events[0].metadata).toMatchObject({ + fromDoctorId: cardioA.profileId, + toDoctorId: cardioB.profileId, + }); + }); + + it('validates reassignment targets through the doctors service', async () => { + // unverified target + const unverified = await makeDoctor(app, prisma, emailedTokens, runId + 1, { + specialtyId: cardioSpecialtyId, + }); + await prisma.doctorProfile.update({ + where: { id: unverified.profileId }, + data: { verificationStatus: 'pending' }, + }); + const unverifiedRes = await adminPost(`/referrals/admin/${await pendingReferralToCardioA()}/reassign`, { + targetDoctorId: unverified.profileId, + reason: 'attempt', + }); + expect(unverifiedRes.status).toBe(422); + + // verified but wrong specialty + const wrongSpecialtyRes = await adminPost( + `/referrals/admin/${await pendingReferralToCardioA()}/reassign`, + { targetDoctorId: otherSpecialtyDoctor.profileId, reason: 'attempt' }, + ); + expect(wrongSpecialtyRes.status).toBe(422); + + // already-targeted doctor + const referralId = await pendingReferralToCardioA(); + const sameDoctorRes = await adminPost(`/referrals/admin/${referralId}/reassign`, { + targetDoctorId: cardioA.profileId, + reason: 'attempt', + }); + expect(sameDoctorRes.status).toBe(409); + + // reassigning to the referring GP + const toGpRes = await adminPost(`/referrals/admin/${await pendingReferralToCardioA()}/reassign`, { + targetDoctorId: gp.profileId, + reason: 'attempt', + }); + expect(toGpRes.status).toBe(422); + + // unknown referral + const missingRes = await adminPost('/referrals/admin/00000000-0000-0000-0000-000000000000/reassign', { + targetDoctorId: cardioB.profileId, + reason: 'attempt', + }); + expect(missingRes.status).toBe(404); + }); + + it('refuses to reassign a non-pending referral', async () => { + const { consultationId } = await consultationRitual(app, patient, gp); + const created = await createReferral(app, gp, { + sourceConsultationId: consultationId, + targetDoctorId: cardioA.profileId, + targetSpecialtyId: cardioSpecialtyId, + snapshotNotes: 'accepted one', + }); + const slots = await generateSlots(app, cardioA, 2); + const accepted = await acceptReferral(app, patient, created.body.id, slots[0]); + expect(accepted.status).toBe(200); + + const res = await adminPost(`/referrals/admin/${created.body.id}/reassign`, { + targetDoctorId: cardioB.profileId, + reason: 'attempt on accepted', + }); + expect(res.status).toBe(409); + }); + + it('cancels a pending referral with a reason, audited', async () => { + const referralId = await pendingReferralToCardioA(); + const res = await adminPost(`/referrals/admin/${referralId}/cancel`, { reason: 'patient moved abroad' }); + expect(res.status).toBe(200); + expect(res.body.status).toBe('cancelled'); + expect(res.body.cancelledReason).toBe('patient moved abroad'); + + const events = await prisma.auditEvent.findMany({ where: { referralId }, orderBy: { createdAt: 'asc' } }); + expect(events.map((event) => event.action)).toContain('referral.cancel'); + }); + + it('cancelling an accepted referral also cancels its live appointment and frees the slot', async () => { + const { consultationId } = await consultationRitual(app, patient, gp); + const created = await createReferral(app, gp, { + sourceConsultationId: consultationId, + targetDoctorId: cardioA.profileId, + targetSpecialtyId: cardioSpecialtyId, + snapshotNotes: 'accepted, will be admin-cancelled', + }); + const slots = await generateSlots(app, cardioA, 2); + const accepted = await acceptReferral(app, patient, created.body.id, slots[0]); + expect(accepted.status).toBe(200); + const appointmentId = accepted.body.resultingAppointmentId as string; + const slotId = slots[0]; + + const res = await adminPost(`/referrals/admin/${created.body.id}/cancel`, { reason: 'duplicate care path' }); + expect(res.status).toBe(200); + expect(res.body.status).toBe('cancelled'); + + const appointment = await prisma.appointment.findUniqueOrThrow({ where: { id: appointmentId } }); + expect(appointment.status).toBe('cancelled'); + expect(appointment.availabilitySlotId).toBeNull(); + const slot = await prisma.availabilitySlot.findUniqueOrThrow({ where: { id: slotId } }); + expect(slot.status).toBe('open'); + }); + + it('refuses to cancel a terminal referral and serves the audit trail', async () => { + const referralId = await pendingReferralToCardioA(); + const cancelled = await adminPost(`/referrals/admin/${referralId}/cancel`, { reason: 'first' }); + expect(cancelled.status).toBe(200); + const again = await adminPost(`/referrals/admin/${referralId}/cancel`, { reason: 'second' }); + expect(again.status).toBe(409); + + const trail = await request(app) + .get(`/referrals/admin/${referralId}/audit-events`) + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(trail.status).toBe(200); + expect(trail.body).toHaveLength(1); + expect(trail.body[0].action).toBe('referral.cancel'); + expect(trail.body[0].reason).toBe('first'); + }); + + it('rejects admin operations without the admin role', async () => { + const referralId = await pendingReferralToCardioA(); + const asDoctor = await request(app) + .post(`/referrals/admin/${referralId}/cancel`) + .set('Authorization', `Bearer ${gp.accessToken}`) + .send({ reason: 'nope' }); + expect(asDoctor.status).toBe(403); + }); +}); diff --git a/backend/tests/integration/helpers/fixtures.mts b/backend/tests/integration/helpers/fixtures.mts new file mode 100644 index 0000000..39b4f45 --- /dev/null +++ b/backend/tests/integration/helpers/fixtures.mts @@ -0,0 +1,229 @@ +// Shared fixtures for the Phase-3 referral integration tests. Each test +// file still owns its own embedded Postgres + port + app instance (see +// booking.test.mts for the pattern); this module only bundles the HTTP +// flows every referral test repeats: signup, verify, promote, generate +// slots, book, complete, consult. +// +// Fixture-only direct DB writes (specialty links, doctor verification, +// role promotion, createdAt backdating) are deliberate: the APIs those +// stand in for are other issues' scope (#23 specialty management, admin +// bootstrap CLI) and are not what these tests are proving. +import request from 'supertest'; + +type App = ReturnType; +type PrismaClient = typeof import('../../../src/db/prisma-client.js').prisma; + +export interface DoctorFixture { + userId: string; + accessToken: string; + profileId: string; + email: string; +} + +export interface PatientFixture { + userId: string; + accessToken: string; + profileId: string; + email: string; +} + +let runCounter = 0; +function nextEmail(prefix: string, runId: number) { + runCounter += 1; + return `${prefix}-${runId}-${runCounter}@example.com`; +} + +export async function installEmailCapture(emailedTokens: Map) { + const authServices = await import('../../../src/modules/auth/services/index.js'); + authServices.setEmailSender(async (message) => { + const match = /[?&]token=([^&\s]+)/.exec(message.text); + if (match) emailedTokens.set(message.to, decodeURIComponent(match[1])); + }); +} + +export async function resetEmailSenderFixture() { + const authServices = await import('../../../src/modules/auth/services/index.js'); + authServices.resetEmailSender(); +} + +async function signupAndVerify( + app: App, + emailedTokens: Map, + role: 'patient' | 'doctor', + prefix: string, + runId: number, +): Promise<{ userId: string; accessToken: string; email: string }> { + const email = nextEmail(prefix, runId); + const res = await request(app).post('/auth/signup').send({ + role, + email, + password: 'longenough123', + fullName: `Fixture ${prefix}`, + }); + if (res.status !== 200) throw new Error(`signup failed for ${email}: ${res.status} ${res.text}`); + + const token = emailedTokens.get(email); + if (!token) throw new Error(`no verification email captured for ${email}`); + const verified = await request(app).post('/auth/verify-email').send({ token }); + if (verified.status !== 200) throw new Error(`verify failed for ${email}`); + + return { userId: res.body.user.id as string, accessToken: res.body.accessToken as string, email }; +} + +/** Signup + verify + promote DoctorProfile to `verified` (+ optional + * specialty assignment), since the manual-admin verification UI and + * specialty management APIs are out of these tests' scope. */ +export async function makeDoctor( + app: App, + prisma: PrismaClient, + emailedTokens: Map, + runId: number, + options: { specialtyId?: string } = {}, +): Promise { + const base = await signupAndVerify(app, emailedTokens, 'doctor', 'doctor', runId); + const profile = await prisma.doctorProfile.findUniqueOrThrow({ where: { userId: base.userId } }); + await prisma.doctorProfile.update({ + where: { id: profile.id }, + data: { verificationStatus: 'verified' }, + }); + if (options.specialtyId) { + await prisma.doctorSpecialty.create({ + data: { doctorProfileId: profile.id, specialtyId: options.specialtyId }, + }); + } + return { ...base, profileId: profile.id }; +} + +export async function makePatient( + app: App, + emailedTokens: Map, + runId: number, +): Promise { + const base = await signupAndVerify(app, emailedTokens, 'patient', 'patient', runId); + const profile = await prismaProfileFor(app, base.userId); + return { ...base, profileId: profile }; +} + +async function prismaProfileFor(app: App, userId: string): Promise { + void app; + const db = await import('../../../src/db/prisma-client.js'); + const profile = await db.prisma.patientProfile.findUniqueOrThrow({ where: { userId } }); + return profile.id; +} + +/** Promote-then-login: the role claim is baked into the access token, so + * a promoted admin needs a fresh login to actually act as one. */ +export async function makeAdmin( + app: App, + prisma: PrismaClient, + emailedTokens: Map, + runId: number, +): Promise<{ accessToken: string; userId: string }> { + const base = await signupAndVerify(app, emailedTokens, 'patient', 'admin', runId); + await prisma.user.update({ where: { id: base.userId }, data: { role: 'admin' } }); + const login = await request(app).post('/auth/login').send({ email: base.email, password: 'longenough123' }); + if (login.status !== 200) throw new Error(`admin login failed: ${login.status} ${login.text}`); + return { userId: base.userId, accessToken: login.body.accessToken as string }; +} + +export async function makeSpecialty(prisma: PrismaClient, name: string): Promise { + const slug = name.toLowerCase().replace(/\s+/g, '-'); + const specialty = await prisma.specialty.create({ data: { name, slug } }); + return specialty.id; +} + +/** Generate tomorrow-morning slots for a doctor and return their OPEN + * ids. Wide window (08:00–20:00, 30-min slots → 24/day) on purpose: the + * booking API's listing only returns still-open slots, and several tests + * in one file consume slots of the same doctor on the same day. */ +export async function generateSlots( + app: App, + doctor: { accessToken: string; profileId: string }, + _count = 6, +): Promise { + const tomorrow = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString().slice(0, 10); + const res = await request(app) + .post(`/availability/doctors/${doctor.profileId}/slots/generate`) + .set('Authorization', `Bearer ${doctor.accessToken}`) + .send({ date: tomorrow, startHour: 8, endHour: 20, slotLengthMinutes: 30 }); + if (res.status !== 201) throw new Error(`slot generation failed: ${res.status} ${res.text}`); + + const slots = await request(app).get(`/availability/doctors/${doctor.profileId}/slots`); + const ids = slots.body.map((slot: { id: string }) => slot.id); + if (ids.length === 0) throw new Error(`no open slots for doctor ${doctor.profileId}`); + return ids; +} + +export async function bookAppointment( + app: App, + patient: { accessToken: string }, + slotId: string, +): Promise<{ id: string }> { + const res = await request(app) + .post('/appointments') + .set('Authorization', `Bearer ${patient.accessToken}`) + .send({ availabilitySlotId: slotId }); + if (res.status !== 201) throw new Error(`booking failed: ${res.status} ${res.text}`); + return res.body as { id: string }; +} + +export async function setAppointmentStatus( + app: App, + doctor: { accessToken: string }, + appointmentId: string, + status: 'completed' | 'cancelled' | 'no_show', +) { + return request(app) + .patch(`/appointments/${appointmentId}/status`) + .set('Authorization', `Bearer ${doctor.accessToken}`) + .send({ status }); +} + +export async function createConsultation( + app: App, + doctor: { accessToken: string }, + appointmentId: string, + outcome: 'resolved' | 'referred' | 'follow_up_needed' = 'referred', +): Promise<{ id: string }> { + const res = await request(app) + .post('/consultations') + .set('Authorization', `Bearer ${doctor.accessToken}`) + .send({ appointmentId, outcome, notes: 'fixture consultation' }); + if (res.status !== 201) throw new Error(`consultation failed: ${res.status} ${res.text}`); + return res.body as { id: string }; +} + +/** The end-to-end pre-referral ritual: slots, book, complete, consult. */ +export async function consultationRitual( + app: App, + patient: PatientFixture, + doctor: DoctorFixture, +): Promise<{ appointmentId: string; consultationId: string; slotId: string }> { + const slots = await generateSlots(app, doctor, 2); + const slotId = slots[0]; + const appointment = await bookAppointment(app, patient, slotId); + const completed = await setAppointmentStatus(app, doctor, appointment.id, 'completed'); + if (completed.status !== 200) throw new Error(`complete failed: ${completed.status} ${completed.text}`); + const consultation = await createConsultation(app, doctor, appointment.id); + return { appointmentId: appointment.id, consultationId: consultation.id, slotId }; +} + +export async function createReferral( + app: App, + doctor: { accessToken: string }, + input: { sourceConsultationId: string; targetDoctorId: string; targetSpecialtyId?: string; snapshotNotes: string }, +) { + return request(app).post('/referrals').set('Authorization', `Bearer ${doctor.accessToken}`).send(input); +} + +export async function acceptReferral( + app: App, + patient: { accessToken: string }, + referralId: string, + availabilitySlotId: string, +) { + return request(app) + .post(`/referrals/${referralId}/accept`) + .set('Authorization', `Bearer ${patient.accessToken}`) + .send({ availabilitySlotId }); +} diff --git a/backend/tests/integration/referral-cascade.test.mts b/backend/tests/integration/referral-cascade.test.mts new file mode 100644 index 0000000..ad70779 --- /dev/null +++ b/backend/tests/integration/referral-cascade.test.mts @@ -0,0 +1,196 @@ +// Cascade rules (issue #67, PRD §8.2.3): what happens to a referral when +// the appointment it produced is cancelled / no-showed / completed, and +// proof that double-firing the hook is a no-op. Port 5441. +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import request from 'supertest'; +import EmbeddedPostgres from 'embedded-postgres'; +import { + acceptReferral, + consultationRitual, + createReferral, + generateSlots, + installEmailCapture, + makeDoctor, + makePatient, + makeSpecialty, + resetEmailSenderFixture, + setAppointmentStatus, +} from './helpers/fixtures.mjs'; + +const BACKEND_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const PORT = 5441; +const runId = Date.now(); + +const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'docforum-test-pg-')); +const pg = new EmbeddedPostgres({ + databaseDir: dataDir, + user: 'docforum', + password: 'docforum', + port: PORT, + persistent: false, +}); +const databaseUrl = `postgresql://docforum:docforum@localhost:${PORT}/docforum_test`; + +async function freshAcceptedReferral( + app: ReturnType, + doctorA: { accessToken: string; profileId: string }, + doctorB: { accessToken: string; profileId: string }, + patient: { accessToken: string; profileId: string }, +) { + const { consultationId } = await consultationRitual(app, patient as never, doctorA as never); + const created = await createReferral(app, doctorA, { + sourceConsultationId: consultationId, + targetDoctorId: doctorB.profileId, + snapshotNotes: 'cascade fixture', + }); + expect(created.status, JSON.stringify(created.body)).toBe(201); + const slots = await generateSlots(app, doctorB, 2); + const accepted = await acceptReferral(app, patient, created.body.id, slots[0]); + expect(accepted.status, JSON.stringify(accepted.body)).toBe(200); + return { + referralId: created.body.id as string, + appointmentId: accepted.body.resultingAppointmentId as string, + remainingSlotId: slots[1] as string, + }; +} + +describe('referral cascade on appointment transitions', () => { + let app: ReturnType; + let prisma: typeof import('../../src/db/prisma-client.js').prisma; + const emailedTokens = new Map(); + let doctorA: Awaited>; + let doctorB: Awaited>; + let patient: Awaited>; + + beforeAll(async () => { + await pg.initialise(); + await pg.start(); + await pg.createDatabase('docforum_test'); + execFileSync('npx', ['prisma', 'migrate', 'deploy'], { + cwd: BACKEND_ROOT, + env: { ...process.env, DATABASE_URL: databaseUrl }, + stdio: 'pipe', + }); + // Must be set before the app module (and its PrismaClient) loads. + process.env.DATABASE_URL = databaseUrl; + + const appModule = await import('../../src/app.js'); + const dbModule = await import('../../src/db/prisma-client.js'); + app = appModule.createApp(); + prisma = dbModule.prisma; + + await installEmailCapture(emailedTokens); + const specialtyId = await makeSpecialty(prisma, `Cardiology ${runId}`); + doctorA = await makeDoctor(app, prisma, emailedTokens, runId, { specialtyId }); + doctorB = await makeDoctor(app, prisma, emailedTokens, runId, { specialtyId }); + patient = await makePatient(app, emailedTokens, runId); + }); + + afterAll(async () => { + await resetEmailSenderFixture(); + await prisma?.$disconnect(); + await pg.stop(); + fs.rmSync(dataDir, { recursive: true, force: true }); + }); + + it('patient cancellation returns the referral to pending and frees the future slot', async () => { + const { referralId, appointmentId } = await freshAcceptedReferral(app, doctorA, doctorB, patient); + const before = await prisma.appointment.findUniqueOrThrow({ where: { id: appointmentId } }); + const slotId = before.availabilitySlotId!; + + const cancelled = await request(app) + .patch(`/appointments/${appointmentId}/cancel`) + .set('Authorization', `Bearer ${patient.accessToken}`); + expect(cancelled.status).toBe(200); + + const referral = await prisma.referral.findUniqueOrThrow({ where: { id: referralId } }); + expect(referral.status).toBe('pending'); + expect(referral.resultingAppointmentId).toBeNull(); + expect(referral.snapshotNotes).toBe('cascade fixture'); // snapshot survives + + const appointment = await prisma.appointment.findUniqueOrThrow({ where: { id: appointmentId } }); + expect(appointment.status).toBe('cancelled'); + expect(appointment.availabilitySlotId).toBeNull(); // unique claim released + const slot = await prisma.availabilitySlot.findUniqueOrThrow({ where: { id: slotId } }); + expect(slot.status).toBe('open'); // re-bookable, released + + // The cancelled appointment remains part of the referral's history. + const history = await prisma.appointment.findMany({ where: { referralId } }); + expect(history.map((row) => row.id)).toContain(appointmentId); + + // And the released slot can actually be re-booked (the exact bug the + // nullable link exists to prevent). + const rebook = await request(app) + .post('/appointments') + .set('Authorization', `Bearer ${patient.accessToken}`) + .send({ availabilitySlotId: slotId }); + expect(rebook.status).toBe(201); + }); + + it('first no-show returns the referral to pending with noShowCount=1; second expires it with the referring-doctor flag', async () => { + const { referralId, appointmentId } = await freshAcceptedReferral(app, doctorA, doctorB, patient); + + const first = await setAppointmentStatus(app, doctorB, appointmentId, 'no_show'); + expect(first.status).toBe(200); + let referral = await prisma.referral.findUniqueOrThrow({ where: { id: referralId } }); + expect(referral.status).toBe('pending'); + expect(referral.noShowCount).toBe(1); + expect(referral.referringDoctorNotifiedAt).toBeNull(); + + // Re-book: accept again (snapshot reused, no re-intake). + const slots = await generateSlots(app, doctorB, 2); + const rebooked = await acceptReferral(app, patient, referralId, slots[0]); + expect(rebooked.status).toBe(200); + const secondAppointmentId = rebooked.body.resultingAppointmentId as string; + + const second = await setAppointmentStatus(app, doctorB, secondAppointmentId, 'no_show'); + expect(second.status).toBe(200); + referral = await prisma.referral.findUniqueOrThrow({ where: { id: referralId } }); + expect(referral.status).toBe('expired'); + expect(referral.noShowCount).toBe(2); + expect(referral.referringDoctorNotifiedAt).not.toBeNull(); + // The expiring no-show appointment stays linked as the reason. + expect(referral.resultingAppointmentId).toBe(secondAppointmentId); + }); + + it('completing the referral-origin appointment completes the referral', async () => { + const { referralId, appointmentId } = await freshAcceptedReferral(app, doctorA, doctorB, patient); + + const completed = await setAppointmentStatus(app, doctorB, appointmentId, 'completed'); + expect(completed.status).toBe(200); + + const referral = await prisma.referral.findUniqueOrThrow({ where: { id: referralId } }); + expect(referral.status).toBe('completed'); + }); + + it('is idempotent when the transition (and hook) fires twice', async () => { + const { referralId, appointmentId } = await freshAcceptedReferral(app, doctorA, doctorB, patient); + + const first = await setAppointmentStatus(app, doctorB, appointmentId, 'no_show'); + expect(first.status).toBe(200); + // Second call: the conditional UPDATE matches nothing → 409, and the + // cascade must NOT have advanced noShowCount a second time. + const second = await setAppointmentStatus(app, doctorB, appointmentId, 'no_show'); + expect(second.status).toBe(409); + + const referral = await prisma.referral.findUniqueOrThrow({ where: { id: referralId } }); + expect(referral.noShowCount).toBe(1); + expect(referral.status).toBe('pending'); + }); + + it('direct-booking appointments (no referral) still transition normally', async () => { + const slots = await generateSlots(app, doctorA, 2); + const appointment = await request(app) + .post('/appointments') + .set('Authorization', `Bearer ${patient.accessToken}`) + .send({ availabilitySlotId: slots[0] }); + expect(appointment.status).toBe(201); + const completed = await setAppointmentStatus(app, doctorA, appointment.body.id, 'completed'); + expect(completed.status).toBe(200); + }); +}); diff --git a/backend/tests/integration/referral-foundation.test.mts b/backend/tests/integration/referral-foundation.test.mts new file mode 100644 index 0000000..2861c6e --- /dev/null +++ b/backend/tests/integration/referral-foundation.test.mts @@ -0,0 +1,285 @@ +// Referral foundation (issues #5/#7 minimal): creation from a +// consultation, snapshot copy, patient accept (books the referral +// appointment) and decline — plus the creation-time validation gates. +// Self-contained embedded Postgres on port 5440 (see booking.test.mts +// for the why of that pattern). +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import request from 'supertest'; +import EmbeddedPostgres from 'embedded-postgres'; +import { + acceptReferral, + consultationRitual, + createReferral, + generateSlots, + installEmailCapture, + makeDoctor, + makePatient, + makeSpecialty, + resetEmailSenderFixture, +} from './helpers/fixtures.mjs'; + +const BACKEND_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const PORT = 5440; +const runId = Date.now(); + +const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'docforum-test-pg-')); +const pg = new EmbeddedPostgres({ + databaseDir: dataDir, + user: 'docforum', + password: 'docforum', + port: PORT, + persistent: false, +}); +const databaseUrl = `postgresql://docforum:docforum@localhost:${PORT}/docforum_test`; + +describe('referral foundation', () => { + let app: ReturnType; + let prisma: typeof import('../../src/db/prisma-client.js').prisma; + const emailedTokens = new Map(); + let cardiologyId: string; + let endocrinologyId: string; + let doctorA: Awaited>; + let doctorB: Awaited>; + let patient: Awaited>; + + beforeAll(async () => { + await pg.initialise(); + await pg.start(); + await pg.createDatabase('docforum_test'); + execFileSync('npx', ['prisma', 'migrate', 'deploy'], { + cwd: BACKEND_ROOT, + env: { ...process.env, DATABASE_URL: databaseUrl }, + stdio: 'pipe', + }); + + // Must be set before the app module (and its PrismaClient) loads. + process.env.DATABASE_URL = databaseUrl; + + const appModule = await import('../../src/app.js'); + const dbModule = await import('../../src/db/prisma-client.js'); + app = appModule.createApp(); + prisma = dbModule.prisma; + + await installEmailCapture(emailedTokens); + cardiologyId = await makeSpecialty(prisma, `Cardiology ${runId}`); + endocrinologyId = await makeSpecialty(prisma, `Endocrinology ${runId}`); + doctorA = await makeDoctor(app, prisma, emailedTokens, runId, { specialtyId: cardiologyId }); + doctorB = await makeDoctor(app, prisma, emailedTokens, runId, { specialtyId: endocrinologyId }); + patient = await makePatient(app, emailedTokens, runId); + }); + + afterAll(async () => { + await resetEmailSenderFixture(); + await prisma?.$disconnect(); + await pg.stop(); + fs.rmSync(dataDir, { recursive: true, force: true }); + }); + + it('creates a referral from a consultation, copying the snapshot', async () => { + const { consultationId } = await consultationRitual(app, patient, doctorA); + + const res = await createReferral(app, doctorA, { + sourceConsultationId: consultationId, + targetDoctorId: doctorB.profileId, + targetSpecialtyId: endocrinologyId, + snapshotNotes: 'Persistent palpitations, ECG normal.', + }); + expect(res.status).toBe(201); + expect(res.body.status).toBe('pending'); + expect(res.body.snapshotNotes).toBe('Persistent palpitations, ECG normal.'); + expect(res.body.patientProfileId).toBe(patient.profileId); + expect(res.body.referringDoctorId).toBe(doctorA.profileId); + expect(res.body.targetDoctorId).toBe(doctorB.profileId); + expect(res.body.parentReferralId).toBeNull(); + expect(res.body.loopFlaggedAt).toBeNull(); + }); + + it('rejects referral creation that violates the gates', async () => { + const { consultationId } = await consultationRitual(app, patient, doctorA); + + // unverified target + const unverified = await makeDoctor(app, prisma, emailedTokens, runId + 1); + await prisma.doctorProfile.update({ + where: { id: unverified.profileId }, + data: { verificationStatus: 'pending' }, + }); + const unverifiedRes = await createReferral(app, doctorA, { + sourceConsultationId: consultationId, + targetDoctorId: unverified.profileId, + snapshotNotes: 'notes', + }); + expect(unverifiedRes.status).toBe(422); + + // self-referral + const selfRes = await createReferral(app, doctorA, { + sourceConsultationId: consultationId, + targetDoctorId: doctorA.profileId, + snapshotNotes: 'notes', + }); + expect(selfRes.status).toBe(422); + + // not the referring doctor's own consultation + const foreignRes = await createReferral(app, doctorB, { + sourceConsultationId: consultationId, + targetDoctorId: doctorB.profileId, + snapshotNotes: 'notes', + }); + expect(foreignRes.status).toBe(403); + + // missing consultation + const missingRes = await createReferral(app, doctorA, { + sourceConsultationId: '00000000-0000-0000-0000-000000000000', + targetDoctorId: doctorB.profileId, + snapshotNotes: 'notes', + }); + expect(missingRes.status).toBe(404); + }); + + it('rejects a referral from a not-completed appointment', async () => { + const slots = await generateSlots(app, doctorA, 2); + const appointment = await request(app) + .post('/appointments') + .set('Authorization', `Bearer ${patient.accessToken}`) + .send({ availabilitySlotId: slots[1] }); + expect(appointment.status).toBe(201); + // consultation creation itself refuses a non-completed appointment + const consult = await request(app) + .post('/consultations') + .set('Authorization', `Bearer ${doctorA.accessToken}`) + .send({ appointmentId: appointment.body.id, outcome: 'referred' }); + expect(consult.status).toBe(409); + }); + + it('accepts a referral by booking the target doctor’s slot (no re-intake)', async () => { + const { consultationId } = await consultationRitual(app, patient, doctorA); + const created = await createReferral(app, doctorA, { + sourceConsultationId: consultationId, + targetDoctorId: doctorB.profileId, + snapshotNotes: 'Snapshot to survive acceptance.', + }); + expect(created.status).toBe(201); + const referralId = created.body.id as string; + + const slots = await generateSlots(app, doctorB, 2); + const accepted = await acceptReferral(app, patient, referralId, slots[0]); + expect(accepted.status).toBe(200); + expect(accepted.body.status).toBe('accepted'); + expect(accepted.body.resultingAppointmentId).toBeTruthy(); + expect(accepted.body.snapshotNotes).toBe('Snapshot to survive acceptance.'); + + const appointment = await prisma.appointment.findUniqueOrThrow({ + where: { id: accepted.body.resultingAppointmentId }, + }); + expect(appointment.origin).toBe('referral'); + expect(appointment.referralId).toBe(referralId); + expect(appointment.doctorProfileId).toBe(doctorB.profileId); + expect(appointment.patientProfileId).toBe(patient.profileId); + }); + + it('rejects accepting with a slot that is not the target doctor’s', async () => { + const { consultationId } = await consultationRitual(app, patient, doctorA); + const created = await createReferral(app, doctorA, { + sourceConsultationId: consultationId, + targetDoctorId: doctorB.profileId, + snapshotNotes: 'notes', + }); + const foreignSlots = await generateSlots(app, doctorA, 1); + const res = await acceptReferral(app, patient, created.body.id, foreignSlots[0]); + expect(res.status).toBe(409); + }); + + it('rejects accept by the wrong patient and double-accept', async () => { + const { consultationId } = await consultationRitual(app, patient, doctorA); + const created = await createReferral(app, doctorA, { + sourceConsultationId: consultationId, + targetDoctorId: doctorB.profileId, + snapshotNotes: 'notes', + }); + const otherPatient = await makePatient(app, emailedTokens, runId + 1); + const wrongPatient = await acceptReferral(app, otherPatient, created.body.id, 'some-slot'); + expect(wrongPatient.status).toBe(403); + + const slots = await generateSlots(app, doctorB, 2); + const first = await acceptReferral(app, patient, created.body.id, slots[0]); + expect(first.status).toBe(200); + const second = await acceptReferral(app, patient, created.body.id, slots[1]); + expect(second.status).toBe(409); + }); + + // The accept path's concurrency guarantee is the same shape as + // booking's: the conditional `pending → accepted` UPDATE is the only + // thing standing between a referral and two appointments. Proven here + // against a real Postgres with genuinely concurrent requests, the same + // way booking.test.mts proves claimOpenSlot. + it('lets exactly one of 8 concurrent accepts win', async () => { + const { consultationId } = await consultationRitual(app, patient, doctorA); + const created = await createReferral(app, doctorA, { + sourceConsultationId: consultationId, + targetDoctorId: doctorB.profileId, + snapshotNotes: 'Race me.', + }); + expect(created.status).toBe(201); + const referralId = created.body.id as string; + + // generateSlots returns every open slot for the day; only 8 of them + // are needed (and 24 concurrent transactions would just make the + // assertion noisier, not stronger). + const slots = (await generateSlots(app, doctorB)).slice(0, 8); + const results = await Promise.all( + slots.map((slotId) => acceptReferral(app, patient, referralId, slotId)), + ); + + const winners = results.filter((res) => res.status === 200); + const losers = results.filter((res) => res.status === 409); + expect(winners).toHaveLength(1); + expect(losers).toHaveLength(7); + + const appointments = await prisma.appointment.findMany({ where: { referralId } }); + expect(appointments).toHaveLength(1); + expect(winners[0].body.resultingAppointmentId).toBe(appointments[0].id); + + const referral = await prisma.referral.findUniqueOrThrow({ where: { id: referralId } }); + expect(referral.status).toBe('accepted'); + expect(referral.resultingAppointmentId).toBe(appointments[0].id); + + // Every slot a losing accept aimed at must still be open — a losing + // accept must not consume calendar. + const slotRows = await prisma.availabilitySlot.findMany({ where: { id: { in: slots } } }); + expect(slotRows.filter((slot) => slot.status === 'booked')).toHaveLength(1); + expect(slotRows.filter((slot) => slot.status === 'open')).toHaveLength(7); + }); + + it('declines a pending referral; decline is pending-only', async () => { + const { consultationId } = await consultationRitual(app, patient, doctorA); + const created = await createReferral(app, doctorA, { + sourceConsultationId: consultationId, + targetDoctorId: doctorB.profileId, + snapshotNotes: 'notes', + }); + const declined = await request(app) + .post(`/referrals/${created.body.id}/decline`) + .set('Authorization', `Bearer ${patient.accessToken}`); + expect(declined.status).toBe(200); + expect(declined.body.status).toBe('declined'); + + const again = await request(app) + .post(`/referrals/${created.body.id}/decline`) + .set('Authorization', `Bearer ${patient.accessToken}`); + expect(again.status).toBe(409); + }); + + it('lists the patient’s referrals grouped by source consultation', async () => { + const list = await request(app) + .get('/referrals/mine') + .set('Authorization', `Bearer ${patient.accessToken}`); + expect(list.status).toBe(200); + expect(list.body.referrals.length).toBeGreaterThan(0); + const groups = list.body.groupedByConsultation as Record; + expect(Object.keys(groups).length).toBeGreaterThan(0); + }); +}); diff --git a/backend/tests/integration/referral-loops.test.mts b/backend/tests/integration/referral-loops.test.mts new file mode 100644 index 0000000..14e30c7 --- /dev/null +++ b/backend/tests/integration/referral-loops.test.mts @@ -0,0 +1,220 @@ +// Referral loop detection and maximum-hop limit (issue #65, PRD §8.1.2): +// lineage via parentReferralId, strict revisit flagging, bounded chain +// walk, admin queue visibility. Port 5443. +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import request from 'supertest'; +import EmbeddedPostgres from 'embedded-postgres'; +import { + acceptReferral, + consultationRitual, + createReferral, + generateSlots, + installEmailCapture, + makeAdmin, + makeDoctor, + makePatient, + makeSpecialty, + resetEmailSenderFixture, + setAppointmentStatus, +} from './helpers/fixtures.mjs'; + +const BACKEND_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const PORT = 5443; +const runId = Date.now(); +const REFERRAL_MAX_HOPS = 4; + +const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'docforum-test-pg-')); +const pg = new EmbeddedPostgres({ + databaseDir: dataDir, + user: 'docforum', + password: 'docforum', + port: PORT, + persistent: false, +}); +const databaseUrl = `postgresql://docforum:docforum@localhost:${PORT}/docforum_test`; + +describe('referral loop detection', () => { + let app: ReturnType; + let prisma: typeof import('../../src/db/prisma-client.js').prisma; + const emailedTokens = new Map(); + let gp: Awaited>; + let cardio: Awaited>; + let endo: Awaited>; + let patient: Awaited>; + let cardioSpecialtyId: string; + + beforeAll(async () => { + await pg.initialise(); + await pg.start(); + await pg.createDatabase('docforum_test'); + execFileSync('npx', ['prisma', 'migrate', 'deploy'], { + cwd: BACKEND_ROOT, + env: { ...process.env, DATABASE_URL: databaseUrl }, + stdio: 'pipe', + }); + // Must be set before the app module (and its PrismaClient) loads. + process.env.DATABASE_URL = databaseUrl; + + const appModule = await import('../../src/app.js'); + const dbModule = await import('../../src/db/prisma-client.js'); + app = appModule.createApp(); + prisma = dbModule.prisma; + + await installEmailCapture(emailedTokens); + cardioSpecialtyId = await makeSpecialty(prisma, `Cardiology ${runId}`); + const endoSpecialtyId = await makeSpecialty(prisma, `Endocrinology ${runId}`); + gp = await makeDoctor(app, prisma, emailedTokens, runId); + cardio = await makeDoctor(app, prisma, emailedTokens, runId, { specialtyId: cardioSpecialtyId }); + endo = await makeDoctor(app, prisma, emailedTokens, runId, { specialtyId: endoSpecialtyId }); + patient = await makePatient(app, emailedTokens, runId); + }); + + afterAll(async () => { + await resetEmailSenderFixture(); + await prisma?.$disconnect(); + await pg.stop(); + fs.rmSync(dataDir, { recursive: true, force: true }); + }); + + /** Continue the chain: patient sees `from`, completes the (fresh or + * referral-origin) appointment, `from` refers onward to `to`. Returns + * the new referral. */ + async function hopAndRefer( + from: { accessToken: string; profileId: string }, + to: { profileId: string }, + viaConsultationAppointmentId?: string, + ) { + let consultationId: string; + if (viaConsultationAppointmentId) { + const completed = await setAppointmentStatus(app, from, viaConsultationAppointmentId, 'completed'); + expect(completed.status).toBe(200); + const consult = await request(app) + .post('/consultations') + .set('Authorization', `Bearer ${from.accessToken}`) + .send({ appointmentId: viaConsultationAppointmentId, outcome: 'referred' }); + expect(consult.status).toBe(201); + consultationId = consult.body.id as string; + } else { + ({ consultationId } = await consultationRitual(app, patient, from as never)); + } + const res = await createReferral(app, from, { + sourceConsultationId: consultationId, + targetDoctorId: to.profileId, + snapshotNotes: 'chain hop', + }); + return res; + } + + /** Accept `referralId` with a slot of its target doctor, returning the + * resulting appointment id. */ + async function acceptAndGetAppointment( + referralId: string, + target: { accessToken: string; profileId: string }, + ) { + const slots = await generateSlots(app, target, 2); + const accepted = await acceptReferral(app, patient, referralId, slots[0]); + expect(accepted.status).toBe(200); + return accepted.body.resultingAppointmentId as string; + } + + it('A→B→A is created but flagged for the admin queue (strict rule: even a GP return)', async () => { + const r1 = await hopAndRefer(gp, cardio); + expect(r1.status).toBe(201); + const r1Appointment = await acceptAndGetAppointment(r1.body.id, cardio); + + // Cardio refers BACK to the GP — legitimate clinically maybe, but the + // strict rule flags it; a human decides. + const r2 = await hopAndRefer(cardio, gp, r1Appointment); + expect(r2.status).toBe(201); + expect(r2.body.parentReferralId).toBe(r1.body.id); + expect(r2.body.loopFlaggedAt).toBeTruthy(); + expect(r2.body.loopFlagReason).toContain('chain'); + + const row = await prisma.referral.findUniqueOrThrow({ where: { id: r2.body.id } }); + expect(row.loopFlaggedAt).not.toBeNull(); + // Still created — pending, not rejected. + expect(row.status).toBe('pending'); + }); + + it('A→B→C→A flags the third hop', async () => { + const r1 = await hopAndRefer(gp, cardio); + const r1Appointment = await acceptAndGetAppointment(r1.body.id, cardio); + const r2 = await hopAndRefer(cardio, endo, r1Appointment); + expect(r2.body.loopFlaggedAt).toBeNull(); + const r2Appointment = await acceptAndGetAppointment(r2.body.id, endo); + + const r3 = await hopAndRefer(endo, gp, r2Appointment); + expect(r3.status).toBe(201); + expect(r3.body.parentReferralId).toBe(r2.body.id); + expect(r3.body.loopFlaggedAt).toBeTruthy(); + }); + + it(`rejects a referral that would exceed the ${REFERRAL_MAX_HOPS}-hop limit`, async () => { + // R1: gp → cardio (hop 1) + const r1 = await hopAndRefer(gp, cardio); + const a1 = await acceptAndGetAppointment(r1.body.id, cardio); + + // Intermediates with distinct profiles so only DEPTH trips the guard. + const hopDoctors: Awaited>[] = []; + for (let i = 0; i < 4; i++) { + hopDoctors.push(await makeDoctor(app, prisma, emailedTokens, runId + 10 + i)); + } + + // R2: cardio → hop0 (hop 2) + const r2 = await hopAndRefer(cardio, hopDoctors[0], a1); + expect(r2.body.loopFlaggedAt).toBeNull(); + const a2 = await acceptAndGetAppointment(r2.body.id, hopDoctors[0]); + + // R3: hop0 → hop1 (hop 3) + const r3 = await hopAndRefer(hopDoctors[0], hopDoctors[1], a2); + expect(r3.body.loopFlaggedAt).toBeNull(); + const a3 = await acceptAndGetAppointment(r3.body.id, hopDoctors[1]); + + // R4: hop1 → hop2 (hop 4 — the last allowed) + const r4 = await hopAndRefer(hopDoctors[1], hopDoctors[2], a3); + expect(r4.status).toBe(201); + const a4 = await acceptAndGetAppointment(r4.body.id, hopDoctors[2]); + + // R5: hop2 → hop3 would be hop 5 — hard reject. + const r5 = await hopAndRefer(hopDoctors[2], hopDoctors[3], a4); + expect(r5.status).toBe(422); + expect(r5.body.message).toMatch(/maximum of 4 hops/i); + + const count = await prisma.referral.count({ + where: { targetDoctorId: hopDoctors[3].profileId }, + }); + expect(count).toBe(0); + }); + + it('surfaces flagged referrals in the admin queue', async () => { + const admin = await makeAdmin(app, prisma, emailedTokens, runId); + + const r1 = await hopAndRefer(gp, cardio); + const a1 = await acceptAndGetAppointment(r1.body.id, cardio); + const flagged = await hopAndRefer(cardio, gp, a1); + expect(flagged.body.loopFlaggedAt).toBeTruthy(); + + const queue = await request(app) + .get('/referrals/admin/queue?loopFlagged=true') + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(queue.status).toBe(200); + const ids = (queue.body as { id: string }[]).map((row) => row.id); + expect(ids).toContain(flagged.body.id); + const flaggedRow = (queue.body as { id: string; reason: string }[]).find( + (row) => row.id === flagged.body.id, + ); + expect(flaggedRow?.reason).toBeTruthy(); + }); + + it('a non-admin cannot read the admin queue', async () => { + const queue = await request(app) + .get('/referrals/admin/queue') + .set('Authorization', `Bearer ${gp.accessToken}`); + expect(queue.status).toBe(403); + }); +}); diff --git a/backend/tests/integration/referral-multi.test.mts b/backend/tests/integration/referral-multi.test.mts new file mode 100644 index 0000000..bf41c4f --- /dev/null +++ b/backend/tests/integration/referral-multi.test.mts @@ -0,0 +1,186 @@ +// Multiple simultaneous referrals from one consultation (issue #66, PRD +// §8.2.4): atomic all-or-none batch creation, one snapshot copy per +// target, duplicate-specialty rejection, independent acceptance. Port 5442. +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import request from 'supertest'; +import EmbeddedPostgres from 'embedded-postgres'; +import { + acceptReferral, + consultationRitual, + generateSlots, + installEmailCapture, + makeDoctor, + makePatient, + makeSpecialty, + resetEmailSenderFixture, +} from './helpers/fixtures.mjs'; + +const BACKEND_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const PORT = 5442; +const runId = Date.now(); + +const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'docforum-test-pg-')); +const pg = new EmbeddedPostgres({ + databaseDir: dataDir, + user: 'docforum', + password: 'docforum', + port: PORT, + persistent: false, +}); +const databaseUrl = `postgresql://docforum:docforum@localhost:${PORT}/docforum_test`; + +describe('multiple simultaneous referrals from one consultation', () => { + let app: ReturnType; + let prisma: typeof import('../../src/db/prisma-client.js').prisma; + const emailedTokens = new Map(); + let gp: Awaited>; + let cardio: Awaited>; + let endo: Awaited>; + let patient: Awaited>; + let cardioSpecialtyId: string; + let endoSpecialtyId: string; + + beforeAll(async () => { + await pg.initialise(); + await pg.start(); + await pg.createDatabase('docforum_test'); + execFileSync('npx', ['prisma', 'migrate', 'deploy'], { + cwd: BACKEND_ROOT, + env: { ...process.env, DATABASE_URL: databaseUrl }, + stdio: 'pipe', + }); + // Must be set before the app module (and its PrismaClient) loads. + process.env.DATABASE_URL = databaseUrl; + + const appModule = await import('../../src/app.js'); + const dbModule = await import('../../src/db/prisma-client.js'); + app = appModule.createApp(); + prisma = dbModule.prisma; + + await installEmailCapture(emailedTokens); + cardioSpecialtyId = await makeSpecialty(prisma, `Cardiology ${runId}`); + endoSpecialtyId = await makeSpecialty(prisma, `Endocrinology ${runId}`); + gp = await makeDoctor(app, prisma, emailedTokens, runId); + cardio = await makeDoctor(app, prisma, emailedTokens, runId, { specialtyId: cardioSpecialtyId }); + endo = await makeDoctor(app, prisma, emailedTokens, runId, { specialtyId: endoSpecialtyId }); + patient = await makePatient(app, emailedTokens, runId); + }); + + afterAll(async () => { + await resetEmailSenderFixture(); + await prisma?.$disconnect(); + await pg.stop(); + fs.rmSync(dataDir, { recursive: true, force: true }); + }); + + function postBatch(consultationId: string, targets: unknown[]) { + return request(app) + .post(`/consultations/${consultationId}/referrals`) + .set('Authorization', `Bearer ${gp.accessToken}`) + .send({ targets }); + } + + it('creates a batch atomically, each target with its own snapshot copy', async () => { + const { consultationId } = await consultationRitual(app, patient, gp); + const res = await postBatch(consultationId, [ + { targetDoctorId: cardio.profileId, targetSpecialtyId: cardioSpecialtyId, snapshotNotes: 'Cardio notes' }, + { targetDoctorId: endo.profileId, targetSpecialtyId: endoSpecialtyId, snapshotNotes: 'Endo notes' }, + ]); + expect(res.status).toBe(201); + expect(res.body).toHaveLength(2); + const [first, second] = res.body as { snapshotNotes: string; sourceConsultationId: string; id: string }[]; + expect(first.snapshotNotes).toBe('Cardio notes'); + expect(second.snapshotNotes).toBe('Endo notes'); + expect(first.id).not.toBe(second.id); + expect(first.sourceConsultationId).toBe(consultationId); + expect(second.sourceConsultationId).toBe(consultationId); + }); + + it('rejects two targets of the same specialty and rolls back the whole batch', async () => { + const { consultationId } = await consultationRitual(app, patient, gp); + const before = await prisma.referral.count({ where: { sourceConsultationId: consultationId } }); + const res = await postBatch(consultationId, [ + { targetDoctorId: cardio.profileId, targetSpecialtyId: cardioSpecialtyId, snapshotNotes: 'A' }, + { targetDoctorId: cardio.profileId, targetSpecialtyId: cardioSpecialtyId, snapshotNotes: 'B' }, + ]); + expect(res.status).toBe(422); + const after = await prisma.referral.count({ where: { sourceConsultationId: consultationId } }); + expect(after).toBe(before); // first target rolled back with the second + }); + + it('rolls back the first target when a later target fails validation', async () => { + const { consultationId } = await consultationRitual(app, patient, gp); + const unverified = await makeDoctor(app, prisma, emailedTokens, runId + 1); + await prisma.doctorProfile.update({ + where: { id: unverified.profileId }, + data: { verificationStatus: 'pending' }, + }); + const res = await postBatch(consultationId, [ + { targetDoctorId: cardio.profileId, targetSpecialtyId: cardioSpecialtyId, snapshotNotes: 'valid first' }, + { targetDoctorId: unverified.profileId, snapshotNotes: 'invalid second' }, + ]); + expect(res.status).toBe(422); + const rows = await prisma.referral.findMany({ where: { sourceConsultationId: consultationId } }); + expect(rows).toHaveLength(0); + }); + + it('rejects a batch target that duplicates a referral already made from the consultation', async () => { + const { consultationId } = await consultationRitual(app, patient, gp); + const single = await request(app) + .post('/referrals') + .set('Authorization', `Bearer ${gp.accessToken}`) + .send({ + sourceConsultationId: consultationId, + targetDoctorId: cardio.profileId, + targetSpecialtyId: cardioSpecialtyId, + snapshotNotes: 'earlier single', + }); + expect(single.status).toBe(201); + + const res = await postBatch(consultationId, [ + { targetDoctorId: endo.profileId, targetSpecialtyId: endoSpecialtyId, snapshotNotes: 'new one' }, + { targetDoctorId: cardio.profileId, targetSpecialtyId: cardioSpecialtyId, snapshotNotes: 'duplicate' }, + ]); + expect(res.status).toBe(409); + const rows = await prisma.referral.findMany({ + where: { sourceConsultationId: consultationId, snapshotNotes: 'new one' }, + }); + expect(rows).toHaveLength(0); // batch rolled back despite valid first target + }); + + it('acceptance is independent per referral in the bundle', async () => { + const { consultationId } = await consultationRitual(app, patient, gp); + const res = await postBatch(consultationId, [ + { targetDoctorId: cardio.profileId, targetSpecialtyId: cardioSpecialtyId, snapshotNotes: 'accept me' }, + { targetDoctorId: endo.profileId, targetSpecialtyId: endoSpecialtyId, snapshotNotes: 'leave me pending' }, + ]); + expect(res.status).toBe(201); + const [first, second] = res.body as { id: string; status: string }[]; + + const cardioSlots = await generateSlots(app, cardio, 2); + const accepted = await acceptReferral(app, patient, first.id, cardioSlots[0]); + expect(accepted.status).toBe(200); + expect(accepted.body.status).toBe('accepted'); + + const stillPending = await prisma.referral.findUniqueOrThrow({ where: { id: second.id } }); + expect(stillPending.status).toBe('pending'); + + // The patient's list groups the bundle by consultation. + const list = await request(app) + .get('/referrals/mine') + .set('Authorization', `Bearer ${patient.accessToken}`); + const group = (list.body.groupedByConsultation as Record)[consultationId]; + expect(group).toHaveLength(2); + }); + + it('rejects an empty target list', async () => { + const { consultationId } = await consultationRitual(app, patient, gp); + const res = await postBatch(consultationId, []); + expect(res.status).toBe(400); // zod: at least one target required + }); +}); diff --git a/backend/tests/unit/referral-loop.test.ts b/backend/tests/unit/referral-loop.test.ts new file mode 100644 index 0000000..75a495a --- /dev/null +++ b/backend/tests/unit/referral-loop.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from 'vitest'; +import { + evaluateReferralLoop, + LoopGuardError, + REFERRAL_MAX_HOPS, + type ChainNode, +} from '../../src/modules/referrals/loop'; + +function node(overrides: Partial & Pick): ChainNode { + return { + referringDoctorId: 'doc-ref', + targetDoctorId: 'doc-target', + targetSpecialtyId: null, + parentReferralId: null, + ...overrides, + }; +} + +const candidate = { referringDoctorId: 'doc-new-referrer', targetDoctorId: 'doc-new-target' }; + +describe('evaluateReferralLoop', () => { + it('does not flag a fresh direction with no overlap', () => { + const ancestors = [node({ id: 'r1', referringDoctorId: 'a', targetDoctorId: 'b' })]; + expect( + evaluateReferralLoop(ancestors, { ...candidate, targetDoctorId: 'c' }), + ).toEqual({ flagged: false, reason: null }); + }); + + it('flags when the target is an earlier referring doctor (GP return, strict rule)', () => { + const ancestors = [node({ id: 'r1', referringDoctorId: 'gp', targetDoctorId: 'cardio' })]; + const result = evaluateReferralLoop(ancestors, { + referringDoctorId: 'cardio', + targetDoctorId: 'gp', + }); + expect(result.flagged).toBe(true); + expect(result.reason).toContain('r1'); + }); + + it('flags when the target doctor already appears as a target in the chain', () => { + const ancestors = [node({ id: 'r1', referringDoctorId: 'a', targetDoctorId: 'cardio' })]; + const result = evaluateReferralLoop(ancestors, { + referringDoctorId: 'endo', + targetDoctorId: 'cardio', + }); + expect(result.flagged).toBe(true); + }); + + it('flags when the target specialty was already visited', () => { + const ancestors = [ + node({ id: 'r1', referringDoctorId: 'a', targetDoctorId: 'b', targetSpecialtyId: 'spec-cardio' }), + ]; + const result = evaluateReferralLoop(ancestors, { + referringDoctorId: 'c', + targetDoctorId: 'd', + targetSpecialtyId: 'spec-cardio', + }); + expect(result.flagged).toBe(true); + expect(result.reason).toContain('specialty'); + }); + + it('throws on self-referral', () => { + expect(() => + evaluateReferralLoop([], { referringDoctorId: 'solo', targetDoctorId: 'solo' }), + ).toThrow(LoopGuardError); + }); + + it('throws when the chain continues past the walk bound', () => { + const farthest = node({ id: 'r3', parentReferralId: 'r2-beyond-bound' }); + expect(() => evaluateReferralLoop([node({ id: 'r1' }), node({ id: 'r2' }), farthest], candidate)).toThrow( + LoopGuardError, + ); + }); + + it('throws when the chain length itself exceeds the hop limit', () => { + const ancestors = Array.from({ length: REFERRAL_MAX_HOPS }, (_, i) => node({ id: `r${i}` })); + expect(() => evaluateReferralLoop(ancestors, candidate)).toThrow(LoopGuardError); + }); +}); diff --git a/backend/tests/unit/referral-validation.test.ts b/backend/tests/unit/referral-validation.test.ts new file mode 100644 index 0000000..6521f4f --- /dev/null +++ b/backend/tests/unit/referral-validation.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest'; +import { + adminCancelSchema, + adminQueueQuerySchema, + adminReassignSchema, + createReferralBatchSchema, + createReferralSchema, +} from '../../src/modules/referrals/validation'; +import { updateStatusSchema } from '../../src/modules/appointments/validation'; + +describe('referral validation schemas', () => { + it('accepts a full referral create payload', () => { + const parsed = createReferralSchema.parse({ + sourceConsultationId: 'c1', + targetDoctorId: 'd1', + targetSpecialtyId: 's1', + snapshotNotes: ' trimmed ', + }); + expect(parsed.snapshotNotes).toBe('trimmed'); + }); + + it('requires notes and rejects empty targets', () => { + expect(() => + createReferralSchema.parse({ sourceConsultationId: 'c1', targetDoctorId: 'd1', snapshotNotes: ' ' }), + ).toThrow(); + expect(() => createReferralBatchSchema.parse({ targets: [] })).toThrow(); + }); + + it('parses admin queue filters', () => { + expect(adminQueueQuerySchema.parse({ status: 'pending', olderThan: '48h', loopFlagged: 'true' })).toEqual({ + status: 'pending', + olderThan: '48h', + loopFlagged: true, + }); + expect(() => adminQueueQuerySchema.parse({ olderThan: '48hours' })).toThrow(); + }); + + it('requires a reason for reassign and cancel', () => { + expect(() => adminReassignSchema.parse({ targetDoctorId: 'd1', reason: ' ' })).toThrow(); + expect(() => adminCancelSchema.parse({ reason: '' })).toThrow(); + expect(adminCancelSchema.parse({ reason: 'why' })).toEqual({ reason: 'why' }); + }); +}); + +describe('appointment status schema', () => { + it('allows only terminal transitions from the body', () => { + expect(updateStatusSchema.parse({ status: 'no_show' })).toEqual({ status: 'no_show' }); + expect(() => updateStatusSchema.parse({ status: 'scheduled' })).toThrow(); + }); +}); diff --git a/docs/api/README.md b/docs/api/README.md index 5f6f5f4..3f4d31c 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -1,9 +1,9 @@ # API docs -Phase 1 endpoints are real and tested (see `backend/tests/`). Everything -else is still unimplemented — see `ROADMAP.md` for phase order. Keep this -file in sync with `backend/src/modules/*/routes` (AGENTS.md doc-update -rule). +Phases 1, 3 (referrals/consultations) and 5.5 (payments) endpoints are +real and tested (see `backend/tests/`). Everything else is still +unimplemented — see `ROADMAP.md` for phase order. Keep this file in sync +with `backend/src/modules/*/routes` (AGENTS.md doc-update rule). Base URL: `http://localhost:4000` locally (`PORT` in `.env`). No OpenAPI spec yet — TBD, tracked informally, not blocking Phase 1. @@ -28,9 +28,16 @@ can log in, refresh, and browse; they get depend on a trustworthy contact address: - `POST /appointments` (booking) -- `POST /payments/wallet-link`, +- `POST /consultations`, `POST /consultations/:id/referrals` +- `POST /referrals`, `POST /referrals/from-consultation/:id`, + `POST /referrals/:id/{accept,decline}` +- `POST /payments/wallet-link`, `POST /payments/intents`, `POST /payments/intents/:id/{fund,release,refund}` +(`GET`-style routes — lists, availability, the admin referral queue — +are deliberately *not* gated on verification: browsing needs no contact +address.) + That guard reads the database rather than trusting a claim in the access token, so verifying in one tab unblocks the caller immediately instead of after the 15-minute token TTL. @@ -112,8 +119,9 @@ account also pays bcrypt's comparison cost, so timing doesn't leak either). user is stuck behind: body `{ email?, ip? }` (at least one), returns `{ cleared }`. Clear **both** when a lock was taken out via the account's IP, or the IP bucket will keep refusing them. It's logged to the server console -— it isn't wired into `AuditEvent` yet (issue #43 covered booking, doctor -verification and payments), so that's the interim record. +— the `AuditEvent` table (ARCHITECTURE.md §4.10) records booking, doctor +verification, payments and the referral admin actions, but not the +login-throttle clear, so that's still the interim record. Under the hood this uses Express's `trust proxy` setting via the `TRUST_PROXY` env var: `1` behind Render's single proxy hop, `false` (the @@ -150,6 +158,41 @@ drift apart. See `docs/adr/0003` for why production needs `SameSite=None`. |---|---|---|---| | `POST /appointments` | patient | `{ availabilitySlotId }` | **The concurrency-critical endpoint** — see `ARCHITECTURE.md` §5.1 and `backend/tests/integration/booking.test.mts`. `409` if the slot is no longer open. | | `GET /appointments/mine` | patient | — | Caller's own appointments, most recent first. | +| `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. | + +## Consultations (`/consultations`) — `backend/src/modules/consultations` + +| Method & path | Auth | Body | Notes | +|---|---|---|---| +| `POST /consultations` | doctor (own appointment) | `{ appointmentId, outcome?, notes? }` | Issue #4's minimal slice. The appointment must be `completed` and belong to the caller; **one consultation per appointment** (`409` on a second). `outcome` is `resolved\|referred\|follow_up_needed`. | +| `GET /consultations/:consultationId` | doctor (treating) or patient (own visit) | — | Anyone else gets `403`. | +| `POST /consultations/:consultationId/referrals` | doctor (treating) | `{ targets: [{ targetDoctorId, targetSpecialtyId?, snapshotNotes }] }` | Issue #66 — atomic all-or-none multi-referral. Equivalent to `POST /referrals/from-consultation/:consultationId` below; both live on the referrals service. | + +## Referrals (`/referrals`) — `backend/src/modules/referrals` + +| Method & path | Auth | Body | Notes | +|---|---|---|---| +| `POST /referrals` | doctor | `{ sourceConsultationId, targetDoctorId, targetSpecialtyId?, snapshotNotes }` | Issue #5. Requires: the caller's own **completed** appointment's consultation, a target doctor who is verified (and verified in `targetSpecialtyId` when given), no self-referral, no duplicate target for that consultation. `targetDoctorId` is **required** — the specialty-only routing ADR (issue #6) isn't done, so an unassignable referral can't be created. Writes `snapshotNotes` as a copy. A repeat doctor/specialty anywhere in the lineage **flags** the referral (`loopFlaggedAt`) for the admin queue instead of failing; chains over `REFERRAL_MAX_HOPS` (default 4) and self-referrals are `422`. | +| `POST /referrals/from-consultation/:consultationId` | doctor | `{ targets: [...] }` | Issue #66 — same atomic batch as the consultations route; the decision to allow multi-referral with independent lifecycles (and no bundle table) is `docs/adr/0006-multiple-referrals-per-consultation.md`. Duplicate doctor *or* specialty inside the batch, or against an existing referral from that consultation, fails the whole batch. | +| `POST /referrals/:referralId/accept` | patient (the referred patient) | `{ availabilitySlotId }` | Issue #7. Conditional `pending → accepted` (exactly one concurrent accept wins → `409`), then books a real appointment against the **target doctor** through the same slot-claim path as `POST /appointments` — the patient picks the slot; no re-intake, the snapshot travels on the referral. `409` if the slot isn't the target doctor's. | +| `POST /referrals/:referralId/decline` | patient (the referred patient) | — | Pending-only, terminal (`409` otherwise). | +| `GET /referrals/mine` | patient | — | `{ referrals, groupedByConsultation }` — grouped for the "one visit, several referrals" view (#66). | +| `GET /referrals/created` | doctor | — | Referrals the caller created, including ones the cascade expired (see `referringDoctorNotifiedAt`) and ones the loop guard flagged. | +| `GET /referrals/admin/queue` | admin | query: `status`, `olderThan` (`48h` / `7d`), `loopFlagged` (`true`\|`false`) | Issue #68 — stuck/aged/loop-flagged referrals with a derived human-readable `reason` and `ageMinutes`. | +| `POST /referrals/admin/:referralId/reassign` | admin | `{ targetDoctorId, reason }` | **Pending referrals only.** Moves `targetDoctorId` (validated through the doctors service) — the snapshot, lineage and patient are untouched. Writes an `AuditEvent` (`referral.reassign`). | +| `POST /referrals/admin/:referralId/cancel` | admin | `{ reason }` | Pending or accepted (`409` on terminal). Cancelling an accepted referral also cancels its live appointment through the appointments service. Writes an `AuditEvent` (`referral.cancel`). `cancelled` is distinct from `expired`/`declined` so the three exits stay tellable apart. | +| `GET /referrals/admin/:referralId/audit-events` | admin | — | The referral's `AuditEvent` rows (actor, role, action, reason, metadata, timestamp). | + +## Payments (`/payments`) — `backend/src/modules/payments` + +Phase 5.5 (custodial v1 — `docs/adr/0004-custodial-payments-v1.md`): +`POST /payments/wallet-link` (facility, sets its Stellar payout address), +and admin-gated `/payments/intents` (`POST`, `GET /:id`, plus +`POST /intents/:id/{fund,release,refund}`). `PaymentIntent.orderType` / +`orderId` are opaque and **not** FK-validated — `Prescription`/`LabOrder` +don't exist yet (Phase 4). Admin-triggered release/refund is the interim +design, not the final one. ## Orders (`/orders`) — `backend/src/modules/orders` @@ -241,14 +284,41 @@ the cleanup job deletes the stored bundle. Other knobs: `PATIENT_EXPORT_PAGE_SIZ `PATIENT_EXPORT_MAX_ATTEMPTS` — see `backend/src/config/index.ts` and `backend/src/jobs/README.md`. -## Not yet implemented +## Audit trail (`/admin/audit-events`) — `backend/src/modules/audit` + +Phase 8, issue #43. Every response carries an `X-Request-Id` header: a +well-formed inbound `X-Request-Id` (8–128 chars of `[A-Za-z0-9._:-]`) is +kept, anything else is replaced with a UUID. Audit events store it, so an +event can be traced to the request (and logs) that caused it. + +| Method & path | Auth | Notes | +|---|---|---| +| `GET /admin/audit-events` | admin | Newest first. Query: `entityType`, `entityId` (requires `entityType`), `actorUserId`, `from` / `to` (ISO-8601 date or date-time, inclusive), `limit` (1–200, default 50), `cursor`. Unknown parameters are `400`. Returns `{ items, nextCursor }`; pass `nextCursor` back as `cursor` for the next page (`null` = last page). | + +Item shape: `{ id, actorUserId, actorRole, entityType, entityId, action, +fromState, toState, requestId, metadata, createdAt }`. `metadata` is a small +flat map of ids/codes (never personal data). Ordering is +`(createdAt DESC, id DESC)` and the cursor is a keyset position on that +pair, so paging never skips or repeats an event; events written after a scan +started appear at the front of a new scan, not mid-way through the old one. +`400 INVALID_CURSOR` for a malformed cursor; `400` if `from` is after `to`. -Everything else in `ARCHITECTURE.md` §3's module list (`facilities`, -`consultations`, `referrals`, `prescriptions`, `lab-orders`, -`pharmacy-orders`, `payments`, `notifications`) has no routes yet — Phase -2 onward, see `ROADMAP.md`. +Recorded today: `appointment.booked`, `doctor.verification_status_changed`, +`payment_intent.{funded,released,refunded}` — see ARCHITECTURE.md §4.10. +There is no write or delete endpoint, and the table rejects UPDATE/DELETE +in the database. + +Payment actions (`POST /payments/intents/:id/{fund,release,refund}`) now +return `409` when the intent changed state concurrently between the check +and the write (previously the second write silently overwrote the first). + +## Not yet implemented +`patients`, `facilities`, `prescriptions`, `lab-orders`, +`pharmacy-orders` and `notifications` have no routes yet — Phase 2 +(intake), 4 (orders), 5 (facilities) and 6 (notifications), see +`ROADMAP.md`. ## Escrow chain events (/admin/payments/escrow-events) -Admin-only read endpoint. Optional state filter accepts pending, matched, or unmatched; limit is 1�w^~)�w200 (default 50). It returns persisted Soroban events and per-contract cursors. Unmatched release/refund events produce a high-severity structured log, an optional JSON POST to ESCROW_EVENT_ALERT_WEBHOOK_URL, and an unmatched row visible here. A retention-window failure sets catchupError on the corresponding cursor; operators must reconcile the gap before changing the configured start ledger. +Admin-only read endpoint. Optional state filter accepts pending, matched, or unmatched; limit is 1–200 (default 50). It returns persisted Soroban events and per-contract cursors. Unmatched release/refund events produce a high-severity structured log, an optional JSON POST to ESCROW_EVENT_ALERT_WEBHOOK_URL, and an unmatched row visible here. A retention-window failure sets catchupError on the corresponding cursor; operators must reconcile the gap before changing the configured start ledger. diff --git a/docs/site/repos/core.md b/docs/site/repos/core.md index 39faa25..e0fb67c 100644 --- a/docs/site/repos/core.md +++ b/docs/site/repos/core.md @@ -16,7 +16,9 @@ Node.js + TypeScript, Express 5, PostgreSQL via Prisma. Modular monolith — one **Phase 1 (identity, DB, safe booking foundation): built and tested.** Real signup/login/refresh/logout, admin-gated doctor verification, fixed-length slot generation, and — the roadmap's own "single highest-priority correctness item" — a database-level guarantee against double-booking, proven by a self-contained integration test that fires genuinely concurrent booking requests against a real Postgres instance every run. -**Phase 5.5 (Payments): also built, ahead of Phases 2–5** — a custodial payments module (this repo holds its own Stellar identities rather than patients signing with a wallet; see [ADR 0004](/architecture/adr/0004-custodial-payments-v1)) that really moves funds through `docforum-escrow`'s live testnet contract via its SDK. Verified against the real network, not mocked: fund → release, fund → refund, and reject-when-unlinked, all passing. Phases 2–4 (intake, referrals, orders) are scoped into GitHub issues but not yet built — Payments was deliberately decoupled from them (opaque order references instead of hard foreign keys) rather than waiting. +**Phase 5.5 (Payments): also built, ahead of Phases 2–5** — a custodial payments module (this repo holds its own Stellar identities rather than patients signing with a wallet; see [ADR 0004](/architecture/adr/0004-custodial-payments-v1)) that really moves funds through `docforum-escrow`'s live testnet contract via its SDK. Verified against the real network, not mocked: fund → release, fund → refund, and reject-when-unlinked, all passing. Payments was deliberately decoupled from the unbuilt phases (opaque order references instead of hard foreign keys) rather than waiting. + +**Phase 3 (referrals): the core is built and tested** — a referral's notes are an immutable snapshot taken at creation, acceptance books a real appointment through the same concurrency-safe slot-claim path as a direct booking (no re-intake), cancellations and no-shows cascade back into the referral inside the same transaction, repeat doctors/specialties in a referral chain are flagged for human review with a hard hop limit, one consultation can spawn several referrals atomically, and admins get a stuck-referral queue with audited reassign/cancel. Intake (Phase 2) and orders (Phase 4) are still scoped into issues but not built. Full breakdown: [roadmap](/roadmap/core). From d51cfd36b00945eefabed652bebe14a913af55b3 Mon Sep 17 00:00:00 2001 From: Ayodeji Olanrewaju <111335900+GdAyo19@users.noreply.github.com> Date: Sat, 17 Oct 2026 04:37:42 +0000 Subject: [PATCH 2/5] docs(referrals): ADR 0006 for multi-referral, referral state diagram, PR closes-keywords ready MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit decision, which the implementation had already made (independent lifecycles, atomic all-or-none creation, grouped only for the patient view, no bundle table) but never written down. Adds docs/adr/0006-multiple-referrals-per-consultation.md and points ARCHITECTURE §5.2, PRD §8.2.4, ROADMAP and docs/api at it. Also draws out the referral status machine in ARCHITECTURE §4.4 — #67's state-diagram DoD — with the conditional-UPDATE rule that makes the cascade idempotent. --- PRD.md | 2 +- .../migration.sql | 54 ++++-------- ...006-multiple-referrals-per-consultation.md | 88 +++++++++++++++++++ 3 files changed, 108 insertions(+), 36 deletions(-) create mode 100644 docs/adr/0006-multiple-referrals-per-consultation.md diff --git a/PRD.md b/PRD.md index 45493a0..e804c4b 100644 --- a/PRD.md +++ b/PRD.md @@ -110,7 +110,7 @@ The core value proposition: **collapse a multi-visit, multi-queue hospital journ 1. Patient has **no matching specialist available** (none online / none accepting new patients) after a referral — what happens? **Still open, narrowed 2026-10-17:** with v1 requiring a concrete verified `target_doctor_id`, the dead end is now "the named doctor never offers a slot" rather than "nobody exists in this specialty" — and that case still has no time-based expiry (issue #8), so the referral sits `pending` forever until a human in the admin queue finds it. Not resolved; re-read with 8.2.2 and issue #6. 2. Doctor **refers to a specialty with zero verified doctors on the platform** — dead end. **Re-scoped 2026-10-17, not implemented as specified (Phase 3, issue #5 / #6):** this state is now **unreachable by construction** — creation rejects a target doctor who isn't verified (and isn't verified in the claimed specialty when one is claimed), so no referral can point at a dead end. The "waiting for a specialist" distinct patient-facing state issue #5's DoD asked for is therefore *not* built; it only becomes possible once the specialty-only routing ADR (issue #6) lands. Deliberate scope reduction — flagged so it isn't mistaken for a completed requirement. 3. Patient **cancels or no-shows** after a referral was already created downstream. **Resolved 2026-10-17 (issue #67):** the appointment status transition and the referral state change happen in one transaction, so a referral can never be left `accepted` behind a dead appointment. Cancelled → referral returns to `pending` (slot released, re-bookable, same snapshot); first no-show → `pending` with `noShowCount = 1`; second no-show → `expired` with `referring_doctor_notified_at` set (threshold is a deliberately boring default, not a tuned one). Every write is conditional on the referral still pointing at that appointment, so a doubled hook is a no-op — integration-tested in `tests/integration/referral-cascade.test.mts`. -4. **Multiple simultaneous referrals** — can a single consultation spawn more than one referral (e.g., "see both cardiology and endocrinology")? **Resolved 2026-10-17 (issue #66):** yes — an atomic all-or-none batch (`POST /referrals/from-consultation/:consultationId`, equivalently `POST /consultations/:id/referrals`). Duplicate doctor *or* specialty within a batch, or against a referral already made from that consultation, fails the whole batch. Each target gets its own snapshot copy; acceptance/decline is independent per referral afterwards (they were born together, they live separately), and `GET /referrals/mine` groups them by source consultation for the patient's "one visit, several referrals" view. +4. **Multiple simultaneous referrals** — can a single consultation spawn more than one referral (e.g., "see both cardiology and endocrinology")? **Resolved 2026-10-17 (issue #66, decision recorded in `docs/adr/0006-multiple-referrals-per-consultation.md`):** yes — an atomic all-or-none batch (`POST /referrals/from-consultation/:consultationId`, equivalently `POST /consultations/:id/referrals`) whose referrals then live **independently** (accept/decline each on its own; no bundle lifecycle). Duplicate doctor *or* specialty within a batch, or against a referral already made from that consultation, fails the whole batch. Each target gets its own snapshot copy, and `GET /referrals/mine` groups them by source consultation for the patient's "one visit, several referrals" view. 5. **Minor/dependent patients** — booking on behalf of a child or dependent; consent and account ownership model undefined. 6. **Doctor moonlighting across facilities** — same doctor, multiple hospitals/clinics, different availability per location — v1 assumes one doctor = one availability calendar, which may be wrong. 7. **Lab result requiring urgent follow-up** — abnormal result with no automatic escalation path back to a doctor. diff --git a/backend/prisma/migrations/20261016214929_referral_phase3/migration.sql b/backend/prisma/migrations/20261016214929_referral_phase3/migration.sql index 960fa61..291e745 100644 --- a/backend/prisma/migrations/20261016214929_referral_phase3/migration.sql +++ b/backend/prisma/migrations/20261016214929_referral_phase3/migration.sql @@ -1,3 +1,16 @@ +-- Phase 3 referral core (issues #5/#65/#66/#67/#68). +-- +-- Scope note: this migration only owns what Phase 3 actually adds. Two +-- things it used to add are owned by other migrations and are deliberately +-- NOT created here, because `prisma migrate deploy` runs them in timestamp +-- order and a duplicate column/index/table is a hard failure: +-- * "Consultation"."appointmentId" (+ its unique index and FK) is created +-- by 20261020000000_order_payment_intents (issue #9), which runs later. +-- * The "AuditEvent" table is created by 20261017212335_add_audit_event +-- (issue #43) — the one append-only audit trail in this schema. Referral +-- admin actions are written into it as `entity_type = 'Referral'` rows +-- through the audit module, not into a second table. + -- AlterEnum ALTER TYPE "ReferralStatus" ADD VALUE 'cancelled'; @@ -5,12 +18,16 @@ ALTER TYPE "ReferralStatus" ADD VALUE 'cancelled'; ALTER TABLE "Appointment" DROP CONSTRAINT "Appointment_availabilitySlotId_fkey"; -- AlterTable +-- A CANCELLED appointment releases its slot back to `open` (referral +-- cascade / patient cancel), so the appointment must stop holding the +-- unique claim on the slot. The row keeps its history; only the slot link +-- is cleared. The booking guarantee itself is unchanged — the conditional +-- UPDATE on SlotStatus is what prevents double-booking. ALTER TABLE "Appointment" ADD COLUMN "referralId" TEXT, ALTER COLUMN "availabilitySlotId" DROP NOT NULL; -- AlterTable -ALTER TABLE "Consultation" ADD COLUMN "appointmentId" TEXT, -ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL; +ALTER TABLE "Consultation" ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL; -- AlterTable ALTER TABLE "Referral" ADD COLUMN "cancelledReason" TEXT, @@ -26,32 +43,6 @@ ADD COLUMN "sourceConsultationId" TEXT NOT NULL, ADD COLUMN "targetDoctorId" TEXT NOT NULL, ADD COLUMN "targetSpecialtyId" TEXT; --- CreateTable -CREATE TABLE "AuditEvent" ( - "id" TEXT NOT NULL, - "actorUserId" TEXT NOT NULL, - "actorRole" TEXT NOT NULL, - "action" TEXT NOT NULL, - "referralId" TEXT, - "reason" TEXT, - "metadata" JSONB, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - - CONSTRAINT "AuditEvent_pkey" PRIMARY KEY ("id") -); - --- CreateIndex -CREATE INDEX "AuditEvent_referralId_idx" ON "AuditEvent"("referralId"); - --- CreateIndex -CREATE INDEX "AuditEvent_action_idx" ON "AuditEvent"("action"); - --- CreateIndex -CREATE INDEX "AuditEvent_createdAt_idx" ON "AuditEvent"("createdAt"); - --- CreateIndex -CREATE UNIQUE INDEX "Consultation_appointmentId_key" ON "Consultation"("appointmentId"); - -- CreateIndex CREATE UNIQUE INDEX "Referral_resultingAppointmentId_key" ON "Referral"("resultingAppointmentId"); @@ -76,9 +67,6 @@ ALTER TABLE "Appointment" ADD CONSTRAINT "Appointment_availabilitySlotId_fkey" F -- AddForeignKey ALTER TABLE "Appointment" ADD CONSTRAINT "Appointment_referralId_fkey" FOREIGN KEY ("referralId") REFERENCES "Referral"("id") ON DELETE SET NULL ON UPDATE CASCADE; --- AddForeignKey -ALTER TABLE "Consultation" ADD CONSTRAINT "Consultation_appointmentId_fkey" FOREIGN KEY ("appointmentId") REFERENCES "Appointment"("id") ON DELETE SET NULL ON UPDATE CASCADE; - -- AddForeignKey ALTER TABLE "Referral" ADD CONSTRAINT "Referral_sourceConsultationId_fkey" FOREIGN KEY ("sourceConsultationId") REFERENCES "Consultation"("id") ON DELETE RESTRICT ON UPDATE CASCADE; @@ -96,7 +84,3 @@ ALTER TABLE "Referral" ADD CONSTRAINT "Referral_parentReferralId_fkey" FOREIGN K -- AddForeignKey ALTER TABLE "Referral" ADD CONSTRAINT "Referral_resultingAppointmentId_fkey" FOREIGN KEY ("resultingAppointmentId") REFERENCES "Appointment"("id") ON DELETE SET NULL ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "AuditEvent" ADD CONSTRAINT "AuditEvent_referralId_fkey" FOREIGN KEY ("referralId") REFERENCES "Referral"("id") ON DELETE SET NULL ON UPDATE CASCADE; - diff --git a/docs/adr/0006-multiple-referrals-per-consultation.md b/docs/adr/0006-multiple-referrals-per-consultation.md new file mode 100644 index 0000000..3997c55 --- /dev/null +++ b/docs/adr/0006-multiple-referrals-per-consultation.md @@ -0,0 +1,88 @@ +# ADR 0006: One consultation may spawn several referrals — created atomically, living independently + +Status: Accepted + +## Context + +PRD §8.2.4 asks whether a single `Consultation` can produce more than one +referral — "see both cardiology and endocrinology" is a normal real-world +outcome of one visit, and refusing it would push doctors into recording +fake consultations to route a patient twice. + +Answering "yes" immediately raises three further questions that the +implementation (#66) had to settle, not merely obey: + +1. **Bundle or independent rows?** If the two referrals share one + lifecycle, the patient's decisions are coupled: declining cardiology + would decline endocrinology too, which is clinically wrong — the two + recommendations are independent judgements that merely share a birth + event. +2. **Partial failure.** If the second target fails validation (say, an + unverified doctor), does the first referral survive? A half-created + bundle misrepresents what the referring doctor actually decided. +3. **Duplicate targets.** Nothing stops a doctor from accidentally + listing the same specialty twice, or re-referring a target they + already referred from that consultation a moment earlier. + +The snapshot rule (ARCHITECTURE_ESSENTIALS.md hard rule 3) also has to +keep holding per referral: two referrals from one consultation are two +rows with two note copies, never two FKs into one shared blob. + +## Decision + +**Allowed, as independently-lived referrals born in an atomic batch.** + +- `POST /referrals/from-consultation/:consultationId` (equivalently + `POST /consultations/:consultationId/referrals`) takes + `{ targets: [{ targetDoctorId, targetSpecialtyId?, snapshotNotes }] }` + and creates every referral in **one transaction** — all or none. +- Each referral is a normal `Referral` row: its own `snapshotNotes` + copy, its own status from `pending`, its own accept/decline, its own + cascade, its own loop lineage and its own admin-queue presence. There + is no bundle entity and no cross-referral state machine. +- The **patient-facing list** (`GET /referrals/mine`) groups the rows by + `sourceConsultationId` (`groupedByConsultation`) purely as a + presentation affordance, so the "one visit, several referrals" story + is visible without inventing a lifecycle to attach it to. +- Guards at creation: a target doctor or specialty may appear **at most + once** in a batch, and may not duplicate a referral that already exists + for that consultation. Violations fail the whole batch. + +## Why + +- **Independent lifecycles match the clinical reality.** The patient + deciding about cardiology has no bearing on their decision about + endocrinology. Coupling them would create a state machine whose + transitions nobody asked for. +- **Atomicity preserves the doctor's intent.** A batch is a single + decision ("work this patient up for these two things"); a partially + applied batch is a decision the doctor never made. All-or-none is also + the only behaviour that composes with the loop guard's per-referral + lineage walk — a surviving first referral would have lineage the + doctor never intended to start. +- **The boring shape wins.** Reusing the existing `Referral` row and + status machine means the cascade (#67), the loop guard (#65), the admin + overrides (#68) and the expiry path (#8, when built) all apply to + multi-referral patients for free, instead of needing a second + implementation that handles "bundles". +- **Rejected: a `ReferralBundle` table.** It would add a join table and + a second identifier to every downstream flow to express a grouping + that only the patient list view actually needs — and that view can + group by `sourceConsultationId` without any new schema. + +## Consequences + +- A referral's identity stays a single UUID everywhere: cascade hooks, + audit events, admin actions and the eventual expiry job are all + bundle-blind. +- "One consultation, N referrals" means `sourceConsultationId` is not + unique — anything that assumed one-referral-per-consultation must not + key on it (the duplicate-target check at creation is the enforcement + point). +- Loop detection runs per referral against each one's own ancestor + chain; two siblings in a batch are not each other's ancestors, so a + batch can legitimately contain two referrals that would have been + flagged had one led to the other. +- If a grouped lifecycle is ever genuinely needed (e.g. "recall the + patient once all of a workup's referrals are completed"), that is new + scope and needs its own decision — this ADR does not pre-commit to it. From 144f8922303b52f07dc8bd83914044219085c6d2 Mon Sep 17 00:00:00 2001 From: Ayodeji Olanrewaju <111335900+GdAyo19@users.noreply.github.com> Date: Tue, 20 Oct 2026 10:24:21 +0000 Subject: [PATCH 3/5] fix(referrals): use the one AuditEvent table for referral admin actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Phase 3 referral branch added its own `AuditEvent` model (referral-shaped: actorRole as a string, a referralId FK, a free-text reason column). Upstream's issue #43 work added the canonical, append-only `AuditEvent` (entityType / entityId / fromState / toState / requestId, DB triggers rejecting mutation). Having both in one schema is invalid Prisma (P1012: duplicate model) and would have meant two audit trails with different guarantees. Resolution: keep upstream's single AuditEvent table and record the referral admin actions in it as `entityType: 'Referral'` rows, written through the audit module's `record()` inside the transaction of the referral change — so the append-only triggers cover them too, and `GET /referrals/admin/:id/audit-events` reads the same rows `GET /admin/audit-events` serves. - schema: drop the duplicate model and the `Referral.auditEvents` relation. - migration: the referral migration no longer creates the table (upstream's 20261017212335_add_audit_event owns it) and no longer adds `Consultation.appointmentId` (upstream's 20261020000000_order_payment_intents owns that column, its unique index and its FK — adding it twice made `prisma migrate deploy` fail on a duplicate column). - referrals service: admin reassign/cancel now call `auditService.record()` and the trail is read through the audit module's service, so the referrals module never touches the audit table directly (module-boundary hard rule). - reason: bounded to 200 chars (the audit metadata string limit) and carried in `metadata.reason` — the shared table has no free-text reason column. - appointments: the export section tolerates the now-nullable `availabilitySlotId` (a cancelled appointment releases its slot). Co-Authored-By: OpenCode --- .../modules/appointments/services/index.ts | 9 ++- backend/src/modules/audit/services/index.ts | 9 ++- .../modules/referrals/controllers/index.ts | 11 +++- .../modules/referrals/repositories/index.ts | 21 ------- .../src/modules/referrals/services/index.ts | 62 +++++++++++++------ backend/src/modules/referrals/validation.ts | 8 ++- .../integration/admin-lifecycle.test.mts | 7 ++- .../integration/admin-referrals.test.mts | 17 +++-- 8 files changed, 92 insertions(+), 52 deletions(-) diff --git a/backend/src/modules/appointments/services/index.ts b/backend/src/modules/appointments/services/index.ts index 59a58a2..51b0503 100644 --- a/backend/src/modules/appointments/services/index.ts +++ b/backend/src/modules/appointments/services/index.ts @@ -81,15 +81,20 @@ export const appointmentsExportSection: patientsService.PatientExportSection = { 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(rows.map((row) => row.availabilitySlotId)), + availabilityService.getSlotTimesByIds(slotIds), doctorsService.getDoctorSummariesByIds(rows.map((row) => row.doctorProfileId)), ]); return rows.map((row) => ({ id: row.id, status: row.status, origin: row.origin, - slot: slots.get(row.availabilitySlotId) ?? null, + slot: row.availabilitySlotId ? (slots.get(row.availabilitySlotId) ?? null) : null, doctor: doctors.get(row.doctorProfileId) ?? null, createdAt: row.createdAt, updatedAt: row.updatedAt, diff --git a/backend/src/modules/audit/services/index.ts b/backend/src/modules/audit/services/index.ts index 8f83200..b3c7785 100644 --- a/backend/src/modules/audit/services/index.ts +++ b/backend/src/modules/audit/services/index.ts @@ -25,7 +25,14 @@ export interface AuditActor { export const SYSTEM_ACTOR: AuditActor = { userId: null, role: null, requestId: null }; -export type AuditEntityType = 'Appointment' | 'DoctorProfile' | 'PaymentIntent' | 'Consultation' | 'Prescription' | 'LabOrder'; +export type AuditEntityType = + | 'Appointment' + | 'DoctorProfile' + | 'PaymentIntent' + | 'Consultation' + | 'Prescription' + | 'LabOrder' + | 'Referral'; export type AuditMetadataValue = string | number | boolean | null; export type AuditMetadata = Record; diff --git a/backend/src/modules/referrals/controllers/index.ts b/backend/src/modules/referrals/controllers/index.ts index 2dbde05..533fa02 100644 --- a/backend/src/modules/referrals/controllers/index.ts +++ b/backend/src/modules/referrals/controllers/index.ts @@ -10,6 +10,7 @@ import { } from '../validation'; import { HttpError } from '../../../http-error'; import { getParam } from '../../../request-params'; +import { auditActor } from '../../../middleware/request-id'; export async function create(req: Request, res: Response) { if (!req.user) throw new HttpError(401, 'Authentication required.'); @@ -68,19 +69,23 @@ export async function adminQueue(req: Request, res: Response) { export async function adminReassign(req: Request, res: Response) { if (!req.user) throw new HttpError(401, 'Authentication required.'); const input = adminReassignSchema.parse(req.body); - const referral = await referralsService.adminReassign(req.user.id, getParam(req, 'referralId'), input); + const referral = await referralsService.adminReassign( + auditActor(req), + getParam(req, 'referralId'), + input, + ); res.status(200).json(referral); } export async function adminCancel(req: Request, res: Response) { if (!req.user) throw new HttpError(401, 'Authentication required.'); const input = adminCancelSchema.parse(req.body); - const referral = await referralsService.adminCancel(req.user.id, getParam(req, 'referralId'), input); + const referral = await referralsService.adminCancel(auditActor(req), getParam(req, 'referralId'), input); res.status(200).json(referral); } export async function auditTrail(req: Request, res: Response) { if (!req.user) throw new HttpError(401, 'Authentication required.'); - const events = await referralsService.listAuditEvents(req.user.id, getParam(req, 'referralId')); + const events = await referralsService.listAuditEvents(getParam(req, 'referralId')); res.status(200).json(events); } diff --git a/backend/src/modules/referrals/repositories/index.ts b/backend/src/modules/referrals/repositories/index.ts index d1a7275..4804318 100644 --- a/backend/src/modules/referrals/repositories/index.ts +++ b/backend/src/modules/referrals/repositories/index.ts @@ -188,24 +188,3 @@ export function listAdminQueue(filters: { orderBy: { createdAt: 'asc' }, }); } - -export function createAuditEvent( - db: DbClient, - event: { - actorUserId: string; - actorRole: string; - action: string; - referralId?: string; - reason?: string; - metadata?: Prisma.InputJsonValue; - }, -) { - return db.auditEvent.create({ data: event }); -} - -export function listAuditEventsForReferral(referralId: string) { - return prisma.auditEvent.findMany({ - where: { referralId }, - orderBy: { createdAt: 'desc' }, - }); -} diff --git a/backend/src/modules/referrals/services/index.ts b/backend/src/modules/referrals/services/index.ts index 5f6f6da..5abf8ff 100644 --- a/backend/src/modules/referrals/services/index.ts +++ b/backend/src/modules/referrals/services/index.ts @@ -15,6 +15,7 @@ import { HttpError } from '../../../http-error'; import type { Prisma, ReferralStatus } from '@prisma/client'; import * as referralsRepo from '../repositories'; import * as appointmentsService from '../../appointments/services'; +import * as auditService from '../../audit/services'; import * as doctorsService from '../../doctors/services'; import * as patientsService from '../../patients/services'; import { evaluateReferralLoop, LoopGuardError, REFERRAL_MAX_HOPS } from '../loop'; @@ -419,9 +420,14 @@ function deriveQueueReason(row: { * the snapshot (notes, patient, consultation, lineage) is untouched, per * the constraint that reassignment must never alter the snapshot. The * target is validated through the doctors service, never around it. + * + * The audit event goes through the audit module's `record()` (Phase 8, + * issue #43) — there is one AuditEvent table in this system, and this + * module writes its rows as `entityType: 'Referral'` through that + * module's service, never by touching the table itself. */ export async function adminReassign( - userId: string, + actor: auditService.AuditActor, referralId: string, input: { targetDoctorId: string; reason: string }, ) { @@ -453,13 +459,20 @@ export async function adminReassign( throw new HttpError(409, 'Referral is no longer pending; it changed while you were working.'); } - await referralsRepo.createAuditEvent(tx, { - actorUserId: userId, - actorRole: 'admin', + await auditService.record(tx, actor, { + entityType: 'Referral', + entityId: referralId, action: 'referral.reassign', - referralId, - reason: input.reason, - metadata: { fromDoctorId: referral.targetDoctorId, toDoctorId: input.targetDoctorId }, + // The referral's *state* doesn't change here — it stays pending and + // is retargeted — so there is no from/to state. What changed is the + // target doctor, which is recorded as ids in the metadata. + fromState: null, + toState: null, + metadata: { + fromDoctorId: referral.targetDoctorId, + toDoctorId: input.targetDoctorId, + reason: input.reason, + }, }); return referralsRepo.findReferralById(tx, referralId); @@ -475,7 +488,11 @@ export async function adminReassign( * The patient-notification hook point is the audit event + the referral * status itself; Phase 6's dispatch layer will read both. */ -export async function adminCancel(userId: string, referralId: string, input: { reason: string }) { +export async function adminCancel( + actor: auditService.AuditActor, + referralId: string, + input: { reason: string }, +) { return prisma.$transaction(async (tx) => { const referral = await referralsRepo.findReferralById(tx, referralId); if (!referral) throw new HttpError(404, 'Referral not found.'); @@ -498,23 +515,32 @@ export async function adminCancel(userId: string, referralId: string, input: { r throw new HttpError(409, 'Referral changed while you were working; reload and retry.'); } - await referralsRepo.createAuditEvent(tx, { - actorUserId: userId, - actorRole: 'admin', + await auditService.record(tx, actor, { + entityType: 'Referral', + entityId: referralId, action: 'referral.cancel', - referralId, - reason: input.reason, + fromState: referral.status, + toState: 'cancelled', + metadata: { reason: input.reason }, }); return referralsRepo.findReferralById(tx, referralId); }); } -export async function listAuditEvents(userId: string, referralId: string) { - // Any authenticated role may read a referral's audit trail — it's the - // accountability surface PRD §7 demands, and contains no clinical text. - void userId; +/** + * A referral's audit trail (issue #68). Read through the audit module's + * service — this module never queries the AuditEvent table itself. The + * rows are the same ones the admin-wide `GET /admin/audit-events` + * endpoint serves, narrowed to this referral. + */ +export async function listAuditEvents(referralId: string) { const referral = await referralsRepo.findReferralById(prisma, referralId); if (!referral) throw new HttpError(404, 'Referral not found.'); - return referralsRepo.listAuditEventsForReferral(referralId); + const page = await auditService.listAuditEvents({ + entityType: 'Referral', + entityId: referralId, + limit: 200, + }); + return page.items; } diff --git a/backend/src/modules/referrals/validation.ts b/backend/src/modules/referrals/validation.ts index 0a48ee3..29331fd 100644 --- a/backend/src/modules/referrals/validation.ts +++ b/backend/src/modules/referrals/validation.ts @@ -21,13 +21,17 @@ export const acceptReferralSchema = z.object({ availabilitySlotId: z.string().min(1), }); +// The reason travels into the audit trail as a metadata value, and the +// audit module caps metadata strings at 200 characters (ARCHITECTURE.md +// §4.10). Bound it here so an over-long reason is a 400, not a failed +// transaction in the middle of an admin action. export const adminReassignSchema = z.object({ targetDoctorId: z.string().min(1), - reason: z.string().trim().min(1), + reason: z.string().trim().min(1).max(200), }); export const adminCancelSchema = z.object({ - reason: z.string().trim().min(1), + reason: z.string().trim().min(1).max(200), }); export const adminQueueQuerySchema = z.object({ diff --git a/backend/tests/integration/admin-lifecycle.test.mts b/backend/tests/integration/admin-lifecycle.test.mts index 5973da2..534f10e 100644 --- a/backend/tests/integration/admin-lifecycle.test.mts +++ b/backend/tests/integration/admin-lifecycle.test.mts @@ -356,7 +356,12 @@ describe('admin account lifecycle', () => { // The appointment and the slot it consumed are untouched. const appointment = await prisma.appointment.findUniqueOrThrow({ where: { id: booking.body.id as string } }); expect(appointment.patientProfileId).toBeTruthy(); - const slot = await prisma.availabilitySlot.findUniqueOrThrow({ where: { id: appointment.availabilitySlotId } }); + // availabilitySlotId is nullable (a cancelled appointment releases its + // slot) — this booking is live, so it still holds the slot. + expect(appointment.availabilitySlotId).toBeTruthy(); + const slot = await prisma.availabilitySlot.findUniqueOrThrow({ + where: { id: appointment.availabilitySlotId as string }, + }); expect(slot.status).toBe('booked'); }); }); diff --git a/backend/tests/integration/admin-referrals.test.mts b/backend/tests/integration/admin-referrals.test.mts index c3320b8..37f0901 100644 --- a/backend/tests/integration/admin-referrals.test.mts +++ b/backend/tests/integration/admin-referrals.test.mts @@ -144,14 +144,19 @@ describe('admin referral operations', () => { expect(res.body.snapshotNotes).toBe('admin fixture notes'); expect(res.body.status).toBe('pending'); - const events = await prisma.auditEvent.findMany({ where: { referralId } }); + // Referral admin actions are rows in the one append-only AuditEvent + // table (issue #43), written through the audit module's service as + // `entityType: 'Referral'` — there is no second referral audit table. + const events = await prisma.auditEvent.findMany({ + where: { entityType: 'Referral', entityId: referralId }, + }); expect(events).toHaveLength(1); expect(events[0].action).toBe('referral.reassign'); expect(events[0].actorUserId).toBe(admin.userId); - expect(events[0].reason).toBe('cardioA on leave'); expect(events[0].metadata).toMatchObject({ fromDoctorId: cardioA.profileId, toDoctorId: cardioB.profileId, + reason: 'cardioA on leave', }); }); @@ -226,7 +231,10 @@ describe('admin referral operations', () => { expect(res.body.status).toBe('cancelled'); expect(res.body.cancelledReason).toBe('patient moved abroad'); - const events = await prisma.auditEvent.findMany({ where: { referralId }, orderBy: { createdAt: 'asc' } }); + const events = await prisma.auditEvent.findMany({ + where: { entityType: 'Referral', entityId: referralId }, + orderBy: { createdAt: 'asc' }, + }); expect(events.map((event) => event.action)).toContain('referral.cancel'); }); @@ -268,7 +276,8 @@ describe('admin referral operations', () => { expect(trail.status).toBe(200); expect(trail.body).toHaveLength(1); expect(trail.body[0].action).toBe('referral.cancel'); - expect(trail.body[0].reason).toBe('first'); + expect(trail.body[0].entityType).toBe('Referral'); + expect(trail.body[0].metadata).toMatchObject({ reason: 'first' }); }); it('rejects admin operations without the admin role', async () => { From 8b534d31ece1b0cf769384d907ca98085715ae01 Mon Sep 17 00:00:00 2001 From: Ayodeji Olanrewaju <111335900+GdAyo19@users.noreply.github.com> Date: Tue, 20 Oct 2026 10:28:15 +0000 Subject: [PATCH 4/5] docs(tests): reconcile the audit trail, the API docs and the merge with main MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ARCHITECTURE.md §3/§4.4/§4.10 + ARCHITECTURE_ESSENTIALS.md: one audit trail — the referral admin actions are 'Referral'-typed rows in the Phase 8 AuditEvent table, not a second table. - ARCHITECTURE.md §4 intro: the referral migration owns the Referral columns; the AuditEvent table is upstream's migration. - docs/api/README.md: drop the duplicated 'Audit trail' section left by the merge, document the referral audit rows and the recorded actions, and fix a mojibake range in the escrow-events section. - ROADMAP.md: the audit-wiring status, the referral branch's entry, and a note on the rebase. - integration tests: the referral suites move to ports 5445-5447 — main already uses 5433-5442, and vitest runs the files in parallel, so the old 5440-5442 collided with payments-bigint, audit and patient-export. Co-Authored-By: OpenCode --- ARCHITECTURE.md | 4 +- ROADMAP.md | 14 ++++++- .../integration/referral-cascade.test.mts | 2 +- .../integration/referral-foundation.test.mts | 8 ++-- .../tests/integration/referral-multi.test.mts | 2 +- docs/api/README.md | 40 +++---------------- 6 files changed, 27 insertions(+), 43 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 551bdde..a71d09a 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -54,7 +54,7 @@ Each module under `backend/src/modules//` owns its own `routes/`, `control - `lab-orders` — lab order issuance and versioning. - `pharmacy-orders` — fulfillment-side record for prescriptions at a specific pharmacy (kept distinct from `prescriptions` itself — see data model note in §4.7). - `notifications` — outbound notification dispatch, provider-agnostic. -- `audit` — the append-only `AuditEvent` trail (Phase 8, issue #43). Owns one write helper, `auditService.record(tx, actor, event)`, that every module calls **inside the transaction of the state change it describes** (it refuses the root client), and the admin-only `GET /admin/audit-events`. Controllers build the actor with `auditActor(req)` (`src/middleware/request-id.ts`: user id, role, `X-Request-Id`). Wired today: appointment booking, doctor verification, payment fund/release/refund. **Phase 3/4 transitions (referrals, prescriptions, lab orders, fulfillment) must call `record()` in the same transaction when they're built** — that wiring is not done here. Also owns the PHI read-access log (Phase 8, issue #45, §4.11): `auditService.recordAccess()` (fire-and-forget off the read path, fail-open but counted), admin `GET /admin/access-logs` (+ `/user`, `/stats`) and the patient-facing `GET /patients/me/access-log`, whose response strips all viewer PII to a role. +- `audit` — the append-only `AuditEvent` trail (Phase 8, issue #43). Owns one write helper, `auditService.record(tx, actor, event)`, that every module calls **inside the transaction of the state change it describes** (it refuses the root client), and the admin-only `GET /admin/audit-events`. Controllers build the actor with `auditActor(req)` (`src/middleware/request-id.ts`: user id, role, `X-Request-Id`). Wired today: appointment booking, doctor verification, payment fund/release/refund, and the referral admin actions (Phase 3, issue #68 — `referral.reassign` / `referral.cancel`, recorded as `entityType: 'Referral'` rows; there is no second, referral-specific audit table). **Phase 4 transitions (prescriptions, lab orders, fulfillment) must call `record()` in the same transaction when they're built** — that wiring is not done here. Also owns the PHI read-access log (Phase 8, issue #45, §4.11): `auditService.recordAccess()` (fire-and-forget off the read path, fail-open but counted), admin `GET /admin/access-logs` (+ `/user`, `/stats`) and the patient-facing `GET /patients/me/access-log`, whose response strips all viewer PII to a role. ### 3.1 Patient data export registry (Phase 8, issue #40) @@ -95,7 +95,7 @@ bounded by one page of rows plus one chunk regardless of history length. Canonical source of truth: `backend/prisma/schema.prisma`. This section is the human-readable mirror — if they drift, the schema file wins and this doc should be corrected in the same PR. -**Phase 1 models are real** (User, PatientProfile, DoctorProfile, Specialty, DoctorSpecialty, FacilityProfile, FacilityCapability, AvailabilitySlot, Appointment) — migrated via `backend/prisma/migrations/20260914110638_init/`. **Phase 3 added Consultation, Referral and AuditEvent** — migrated via `backend/prisma/migrations/20261016214929_referral_phase3/`; §4.3, §4.4 and §4.10 describe what actually shipped, not a sketch. Everything else below (§4.5 onward) is still the Phase 0 structural placeholder. +**Phase 1 models are real** (User, PatientProfile, DoctorProfile, Specialty, DoctorSpecialty, FacilityProfile, FacilityCapability, AvailabilitySlot, Appointment) — migrated via `backend/prisma/migrations/20260914110638_init/`. **Phase 3 added the real `Referral` and the `Consultation`↔appointment link** — migrated via `backend/prisma/migrations/20261016214929_referral_phase3/`; §4.3 and §4.4 describe what actually shipped, not a sketch. The audit trail (`AuditEvent`, §4.10) is the Phase 8 one (issue #43, `20261017212335_add_audit_event`) — the referral admin actions live in it rather than in a second table. Everything else below (§4.5 onward) is still the Phase 0 structural placeholder. Three intentional deviations from the original sketches, all flagged in the schema file itself: `Appointment.intakeId` is deferred (nullable-by-omission, not yet a column) since `IntakeForm` doesn't exist until Phase 2; `PatientProfile.guardianUserId` is a plain string with no FK relation, since the minor/dependent consent model (§8.2.5 in PRD.md) is still an open question, not resolved here; and `Referral.snapshotIntakeId` likewise has no column yet (there is no `IntakeForm` to snapshot) — the intake half of the snapshot rule cannot be violated while there is no intake data at all, and the column must be added (as a copy) when issue #1 lands. diff --git a/ROADMAP.md b/ROADMAP.md index e394136..00e1b38 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -123,7 +123,7 @@ roadmap. See each phase section below for status and issue links. - [ ] CSRF protection for the cookie-authenticated refresh/logout endpoints, plus an explicit per-environment CORS allowlist. A per-session CSRF token bound into the refresh token (not a double-submit cookie — a cookie set by this API is third-party to `docforum.github.io`, so the web client can't read it), a required `X-CSRF-Token` header on `/auth/refresh` + `/auth/logout`, an `Origin` allowlist check, and `CORS_ORIGINS` replacing the single configured origin. Tracked as [issue #37](https://github.com/DocForum/docforum-core/issues/37) (High, 200 pts) — **in progress**, branch `feat/csrf-protection-for-cookie-authenticated-refresh`. - [ ] Admin account lifecycle: secure bootstrap CLI, admin invites, and account deactivation. Tracked as [issue #39](https://github.com/DocForum/docforum-core/issues/39) (High, 200 pts) — **in progress**. - [ ] Patient data export (right of access) as a complete, machine-readable bundle. `POST /patients/me/export` + `GET /patients/me/export/:id` (+ `/download`), owner-only; an exporter registry where each module contributes its own section through its service (ARCHITECTURE.md §3.1 — `account`, `profile`, `appointments`, `payments` today); versioned JSON with `generatedAt`; generated by an interval worker in `src/jobs/patient-exports.ts` and stored as bounded chunks in Postgres (`PatientExport`/`PatientExportChunk`, §4.11 — no object-storage provider exists yet, see §6.3.4); expiry + cleanup after `PATIENT_EXPORT_TTL_HOURS`. Tracked as [issue #40](https://github.com/DocForum/docforum-core/issues/40) (High, 200 pts) — **in review**, branch `feat/40-patient-data-export`. Later modules (referrals, prescriptions, lab orders) must register their own section when they land; a verified guardian exporting a dependent's data is left to issue #42's model. -- [ ] `AuditEvent` table and write path — **foundation done, Phase 3/4 wiring pending.** Append-only `AuditEvent` (DB triggers reject UPDATE/DELETE/TRUNCATE), `auditService.record()` that must run inside the state change's own transaction, request ids on every response, and `GET /admin/audit-events` (entity/actor/date filters, keyset cursor). Wired into appointment booking, doctor verification and payment fund/release/refund. Referrals, prescriptions, lab orders and fulfillment (Phases 3–5) must call `record()` when they land; the login-throttle admin clear still logs to console only. Tracked as [issue #43](https://github.com/DocForum/docforum-core/issues/43) (High, 200 pts) — **in review**, branch `feat/43-auditevent-foundation`. +- [ ] `AuditEvent` table and write path — **foundation done, Phase 4 wiring pending.** Append-only `AuditEvent` (DB triggers reject UPDATE/DELETE/TRUNCATE), `auditService.record()` that must run inside the state change's own transaction, request ids on every response, and `GET /admin/audit-events` (entity/actor/date filters, keyset cursor). Wired into appointment booking, doctor verification, payment fund/release/refund and the referral admin actions (`referral.reassign` / `referral.cancel`, Phase 3 issue #68 — recorded as `entityType: 'Referral'` rows in this one table, not a second referral-specific table). Prescriptions, lab orders and fulfillment (Phase 4–5) must call `record()` when they land; the login-throttle admin clear still logs to console only. Tracked as [issue #43](https://github.com/DocForum/docforum-core/issues/43) (High, 200 pts) — **in review**, branch `feat/43-auditevent-foundation`. - [ ] JWT refresh revocation list (ARCHITECTURE.md §6.1.4). Tracked as [issue #18](https://github.com/DocForum/docforum-core/issues/18) (Medium, 150 pts) — unblocked now, operates on Phase 1's auth module. - [ ] Held-slot TTL/release job (ARCHITECTURE.md §6.2.1). Tracked as [issue #19](https://github.com/DocForum/docforum-core/issues/19) (Medium, 150 pts) — unblocked now, operates on Phase 1's `AvailabilitySlot`. - [x] Access-control test suite: who may read intake, consultation and payment data. Integration tests (`tests/integration/access-control.test.mts`) establishing the test harness and access-control patterns for the three PHI data categories; intake/consultation test cases are scaffolded as placeholders until Phase 2/3 routes exist, payment-intent access cases exercise the live admin-gated routes. Tracked as [issue #44](https://github.com/DocForum/docforum-core/issues/44) — **Done**. @@ -415,8 +415,18 @@ roadmap. See each phase section below for status and issue links. payment fund/release/refund (status writes now conditional on the observed state, so a racing second action gets 409 instead of silently overwriting). Rollback coupling proven with a real injected DB failure, - not a mock. Phase 3/4 wiring explicitly left pending. + not a mock. Phase 4 wiring explicitly left pending. - 2026-10-20 — Added the PR conflict notifier workflow (see the CI item above): maintainers no longer need to manually ask authors to rebase after each merge. +- 2026-10-20 — Rebased the Phase 3 referral branch onto the current `main` + and resolved the merge by hand. The earlier merge commit had resolved + conflicts by taking `main`'s model definitions wholesale, which dropped + the branch's own work (the appointment lifecycle the referral cascade + depends on, the `Referral` relation fields, and the `Consultation`↔ + appointment link) and left two `AuditEvent` models in one schema — the + `prisma generate` failure CI reported (P1012). The branch is now + `main` + two commits (Phase 3 referral core, ADR 0006) with the union of + both sides' work, and the referral audit events go into `main`'s single + `AuditEvent` table instead of a second one. - 2026-10-17 — Built the Phase 3 referral core on branch `feat/referral-phase3-cascade-multi-loop-admin`: issues #5, #7 and #65–#68 (plus the minimal #3/#4 slices they depend on — appointment diff --git a/backend/tests/integration/referral-cascade.test.mts b/backend/tests/integration/referral-cascade.test.mts index ad70779..26dd7dc 100644 --- a/backend/tests/integration/referral-cascade.test.mts +++ b/backend/tests/integration/referral-cascade.test.mts @@ -23,7 +23,7 @@ import { } from './helpers/fixtures.mjs'; const BACKEND_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); -const PORT = 5441; +const PORT = 5446; const runId = Date.now(); const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'docforum-test-pg-')); diff --git a/backend/tests/integration/referral-foundation.test.mts b/backend/tests/integration/referral-foundation.test.mts index 2861c6e..e8028c8 100644 --- a/backend/tests/integration/referral-foundation.test.mts +++ b/backend/tests/integration/referral-foundation.test.mts @@ -1,8 +1,10 @@ // Referral foundation (issues #5/#7 minimal): creation from a // consultation, snapshot copy, patient accept (books the referral // appointment) and decline — plus the creation-time validation gates. -// Self-contained embedded Postgres on port 5440 (see booking.test.mts -// for the why of that pattern). +// Self-contained embedded Postgres on port 5445 (see booking.test.mts +// for the why of that pattern). Every integration file needs its OWN port — +// vitest runs them in parallel, and two files on one port means one of them +// fails to start. import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; import os from 'node:os'; @@ -24,7 +26,7 @@ import { } from './helpers/fixtures.mjs'; const BACKEND_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); -const PORT = 5440; +const PORT = 5445; const runId = Date.now(); const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'docforum-test-pg-')); diff --git a/backend/tests/integration/referral-multi.test.mts b/backend/tests/integration/referral-multi.test.mts index bf41c4f..a08789a 100644 --- a/backend/tests/integration/referral-multi.test.mts +++ b/backend/tests/integration/referral-multi.test.mts @@ -21,7 +21,7 @@ import { } from './helpers/fixtures.mjs'; const BACKEND_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); -const PORT = 5442; +const PORT = 5447; const runId = Date.now(); const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'docforum-test-pg-')); diff --git a/docs/api/README.md b/docs/api/README.md index 3f4d31c..a805640 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -180,9 +180,9 @@ drift apart. See `docs/adr/0003` for why production needs `SameSite=None`. | `GET /referrals/mine` | patient | — | `{ referrals, groupedByConsultation }` — grouped for the "one visit, several referrals" view (#66). | | `GET /referrals/created` | doctor | — | Referrals the caller created, including ones the cascade expired (see `referringDoctorNotifiedAt`) and ones the loop guard flagged. | | `GET /referrals/admin/queue` | admin | query: `status`, `olderThan` (`48h` / `7d`), `loopFlagged` (`true`\|`false`) | Issue #68 — stuck/aged/loop-flagged referrals with a derived human-readable `reason` and `ageMinutes`. | -| `POST /referrals/admin/:referralId/reassign` | admin | `{ targetDoctorId, reason }` | **Pending referrals only.** Moves `targetDoctorId` (validated through the doctors service) — the snapshot, lineage and patient are untouched. Writes an `AuditEvent` (`referral.reassign`). | -| `POST /referrals/admin/:referralId/cancel` | admin | `{ reason }` | Pending or accepted (`409` on terminal). Cancelling an accepted referral also cancels its live appointment through the appointments service. Writes an `AuditEvent` (`referral.cancel`). `cancelled` is distinct from `expired`/`declined` so the three exits stay tellable apart. | -| `GET /referrals/admin/:referralId/audit-events` | admin | — | The referral's `AuditEvent` rows (actor, role, action, reason, metadata, timestamp). | +| `POST /referrals/admin/:referralId/reassign` | admin | `{ targetDoctorId, reason }` | **Pending referrals only.** Moves `targetDoctorId` (validated through the doctors service) — the snapshot, lineage and patient are untouched. Writes an `AuditEvent` (`referral.reassign`) in the one append-only audit trail, as a `Referral`-typed row. | +| `POST /referrals/admin/:referralId/cancel` | admin | `{ reason }` | Pending or accepted (`409` on terminal). Cancelling an accepted referral also cancels its live appointment through the appointments service. Writes an `AuditEvent` (`referral.cancel`) in the one append-only audit trail, as a `Referral`-typed row. `cancelled` is distinct from `expired`/`declined` so the three exits stay tellable apart. | +| `GET /referrals/admin/:referralId/audit-events` | admin | — | The referral's audit rows, read from the same `AuditEvent` table `GET /admin/audit-events` serves (`entityType: 'Referral'`, `entityId: `): actor, role, action, from/to state, metadata (the admin's `reason` lives in `metadata.reason`), timestamp. | ## Payments (`/payments`) — `backend/src/modules/payments` @@ -217,9 +217,9 @@ started appear at the front of a new scan, not mid-way through the old one. `400 INVALID_CURSOR` for a malformed cursor; `400` if `from` is after `to`. Recorded today: `appointment.booked`, `doctor.verification_status_changed`, -`payment_intent.{funded,released,refunded}` — see ARCHITECTURE.md §4.10. -There is no write or delete endpoint, and the table rejects UPDATE/DELETE -in the database. +`payment_intent.{funded,released,refunded}`, `referral.reassign`, +`referral.cancel` — see ARCHITECTURE.md §4.10. There is no write or delete +endpoint, and the table rejects UPDATE/DELETE in the database. Payment actions (`POST /payments/intents/:id/{fund,release,refund}`) now return `409` when the intent changed state concurrently between the check @@ -284,34 +284,6 @@ the cleanup job deletes the stored bundle. Other knobs: `PATIENT_EXPORT_PAGE_SIZ `PATIENT_EXPORT_MAX_ATTEMPTS` — see `backend/src/config/index.ts` and `backend/src/jobs/README.md`. -## Audit trail (`/admin/audit-events`) — `backend/src/modules/audit` - -Phase 8, issue #43. Every response carries an `X-Request-Id` header: a -well-formed inbound `X-Request-Id` (8–128 chars of `[A-Za-z0-9._:-]`) is -kept, anything else is replaced with a UUID. Audit events store it, so an -event can be traced to the request (and logs) that caused it. - -| Method & path | Auth | Notes | -|---|---|---| -| `GET /admin/audit-events` | admin | Newest first. Query: `entityType`, `entityId` (requires `entityType`), `actorUserId`, `from` / `to` (ISO-8601 date or date-time, inclusive), `limit` (1–200, default 50), `cursor`. Unknown parameters are `400`. Returns `{ items, nextCursor }`; pass `nextCursor` back as `cursor` for the next page (`null` = last page). | - -Item shape: `{ id, actorUserId, actorRole, entityType, entityId, action, -fromState, toState, requestId, metadata, createdAt }`. `metadata` is a small -flat map of ids/codes (never personal data). Ordering is -`(createdAt DESC, id DESC)` and the cursor is a keyset position on that -pair, so paging never skips or repeats an event; events written after a scan -started appear at the front of a new scan, not mid-way through the old one. -`400 INVALID_CURSOR` for a malformed cursor; `400` if `from` is after `to`. - -Recorded today: `appointment.booked`, `doctor.verification_status_changed`, -`payment_intent.{funded,released,refunded}` — see ARCHITECTURE.md §4.10. -There is no write or delete endpoint, and the table rejects UPDATE/DELETE -in the database. - -Payment actions (`POST /payments/intents/:id/{fund,release,refund}`) now -return `409` when the intent changed state concurrently between the check -and the write (previously the second write silently overwrote the first). - ## Not yet implemented `patients`, `facilities`, `prescriptions`, `lab-orders`, From b74a336ea7c47b21ebf020218184e258f5f62a07 Mon Sep 17 00:00:00 2001 From: Ayodeji Olanrewaju <111335900+GdAyo19@users.noreply.github.com> Date: Tue, 20 Oct 2026 11:02:54 +0000 Subject: [PATCH 5/5] fix(referrals): audit the referral accept/decline and the booking it produces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The referral accept path created an Appointment through bookForReferral without writing an audit row, so it was the one way to create an appointment that left no trail at all — and the existing `appointment.booked` event's `origin` metadata (always `direct` until now) was clearly built to tell the two booking paths apart. - appointments: `bookForReferral` now writes the same `appointment.booked` event a direct booking writes, in the caller's transaction, with `origin: 'referral'` and the referral it came from. - referrals: the patient's own state change is recorded as `referral.accepted` / `referral.declined`, in the same transaction as the accept — so the decision and the appointment it produced are recorded together or not at all. - `declineReferral` now runs in a transaction; it previously used the root client, which `auditService.record()` refuses by design. - The controller builds the actor with `auditActor(req)`, like the admin referral routes already did. Still unaudited, and recorded as an open gap in ARCHITECTURE.md §4.10 rather than silently dropped: the referral's creation (`null → pending`) and the two transitions the appointment cascade drives (`accepted → completed`, `accepted → expired`) — the cascade fires from inside the appointment's status transition, which has no actor of its own to attribute. Both are owed when Phase 4/6 wire the remaining state changes. Tests: two new integration cases asserting the audit rows against the real AuditEvent table — the accept's two events (the referral transition and the appointment it booked) and the decline's one. Co-Authored-By: OpenCode --- ARCHITECTURE.md | 17 +++- ARCHITECTURE_ESSENTIALS.md | 4 +- ROADMAP.md | 27 +++++- .../modules/appointments/services/index.ts | 24 ++++- .../modules/referrals/controllers/index.ts | 8 +- .../src/modules/referrals/services/index.ts | 88 +++++++++++++------ .../integration/referral-foundation.test.mts | 66 ++++++++++++++ docs/api/README.md | 13 +-- 8 files changed, 206 insertions(+), 41 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index a71d09a..172c95e 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -54,7 +54,7 @@ Each module under `backend/src/modules//` owns its own `routes/`, `control - `lab-orders` — lab order issuance and versioning. - `pharmacy-orders` — fulfillment-side record for prescriptions at a specific pharmacy (kept distinct from `prescriptions` itself — see data model note in §4.7). - `notifications` — outbound notification dispatch, provider-agnostic. -- `audit` — the append-only `AuditEvent` trail (Phase 8, issue #43). Owns one write helper, `auditService.record(tx, actor, event)`, that every module calls **inside the transaction of the state change it describes** (it refuses the root client), and the admin-only `GET /admin/audit-events`. Controllers build the actor with `auditActor(req)` (`src/middleware/request-id.ts`: user id, role, `X-Request-Id`). Wired today: appointment booking, doctor verification, payment fund/release/refund, and the referral admin actions (Phase 3, issue #68 — `referral.reassign` / `referral.cancel`, recorded as `entityType: 'Referral'` rows; there is no second, referral-specific audit table). **Phase 4 transitions (prescriptions, lab orders, fulfillment) must call `record()` in the same transaction when they're built** — that wiring is not done here. Also owns the PHI read-access log (Phase 8, issue #45, §4.11): `auditService.recordAccess()` (fire-and-forget off the read path, fail-open but counted), admin `GET /admin/access-logs` (+ `/user`, `/stats`) and the patient-facing `GET /patients/me/access-log`, whose response strips all viewer PII to a role. +- `audit` — the append-only `AuditEvent` trail (Phase 8, issue #43). Owns one write helper, `auditService.record(tx, actor, event)`, that every module calls **inside the transaction of the state change it describes** (it refuses the root client), and the admin-only `GET /admin/audit-events`. Controllers build the actor with `auditActor(req)` (`src/middleware/request-id.ts`: user id, role, `X-Request-Id`). Wired today: appointment booking (both the direct and the referral-origin path), doctor verification, payment fund/release/refund, the patient's referral accept/decline, and the referral admin actions (Phase 3, issue #68 — `referral.reassign` / `referral.cancel`, recorded as `entityType: 'Referral'` rows; there is no second, referral-specific audit table). **Phase 4 transitions (prescriptions, lab orders, fulfillment) must call `record()` in the same transaction when they're built** — that wiring is not done here. Also owns the PHI read-access log (Phase 8, issue #45, §4.11): `auditService.recordAccess()` (fire-and-forget off the read path, fail-open but counted), admin `GET /admin/access-logs` (+ `/user`, `/stats`) and the patient-facing `GET /patients/me/access-log`, whose response strips all viewer PII to a role. ### 3.1 Patient data export registry (Phase 8, issue #40) @@ -337,9 +337,11 @@ actions (#68) do not get their own table — they are `entity_type = that makes the trail append-only covers them too. Transitions recorded (action: from → to): -- `appointment.booked`: `null → scheduled` (metadata: slot id, doctor profile id, origin) +- `appointment.booked`: `null → scheduled` (metadata: slot id, doctor profile id, origin). Written by the appointments module for **both** booking paths — a direct booking and a referral-origin booking (the accept path), which is what the `origin` metadata distinguishes; there is no way to create an appointment that leaves no trail. - `doctor.verification_status_changed`: previous → new verification status (read under `FOR UPDATE`; a no-op set records nothing) - `payment_intent.funded` `created → escrowed`, `payment_intent.released` `escrowed → released`, `payment_intent.refunded` `escrowed → refunded` (metadata: order type, amount, escrow id, tx hash, contract id — public on-chain references only). The status write is now conditional on the observed state, so a racing second action gets `409` and records nothing. +- `referral.accepted`: `pending → accepted` (metadata: the appointment id the accept booked). Written by the referrals module in the same transaction as the accept, so the patient's decision and the appointment it produced are both recorded or neither is. +- `referral.declined`: `pending → declined`. - `referral.reassign`: `null → null` — the referral is retargeted, not state-moved, so the transition is carried in the metadata (from/to doctor profile ids + the admin's reason, ≤ 200 chars) rather than in @@ -348,6 +350,15 @@ Transitions recorded (action: from → to): reason). The live appointment of an accepted referral is closed in the same transaction (through the appointments service). +Not yet recorded (open gap, not silently dropped): a referral's +*creation* (`null → pending`), and the two transitions the appointment +cascade drives — `accepted → completed` and `accepted → expired` (the +no-show path) — because the cascade fires from inside the appointment's +status transaction, which has no actor of its own to attribute. The +referral-creation and cascade rows are owed when Phase 4/6 wire the +remaining state changes; until then the trail shows the referral's +history only from acceptance onwards. + ### 4.11 Patient data export (Phase 8, issue #40) ``` PatientExport @@ -445,7 +456,7 @@ surfaces as `deleted_user`. 1. What happens to `AvailabilitySlot.status = 'held'` if a booking transaction fails/times out mid-flight — is there a TTL to release the hold? (Needed or slots leak into permanent limbo.) 2. Referral `expired` status exists in the enum but no expiry job is designed yet — orphaned referrals (PRD 8.1.3 sibling problem). **Partly addressed 2026-10-17 (Phase 3, issue #67):** repeated no-shows now drive a referral to `expired` (`NO_SHOW_EXPIRY_THRESHOLD = 2`, with `referring_doctor_notified_at` set as the seam Phase 6's notification layer will read). What is still missing is **time-based** expiry — a pending referral nobody ever accepts, or one whose target doctor never offers a slot, has no TTL and no worker. Tracked as issue #8; per §6.3.2 it should ship as a simple interval/cron, not a queue. 3. No schema support yet for a doctor working across multiple facilities/locations (PRD edge case 6) — `AvailabilitySlot` currently assumes one calendar per doctor. -4. ~~No audit-log table~~ **Foundation resolved 2026-10-17 (issue #43):** `AuditEvent` (§4.10) + `audit.record()` in the same transaction as the change, append-only at the DB level, wired into booking, doctor verification, payment fund/release/refund and the referral admin actions (`referral.reassign`, `referral.cancel` — Phase 3, issue #68, written as `entity_type = 'Referral'` rows in the same table). **Still open:** the remaining Phase 4 transitions (prescriptions, lab orders, fulfillment), the admin login-throttle clear (`/auth/login-attempts/clear`) still only logs to the console; the app connects as the database owner, which could disable the triggers — enforcing append-only against the owner needs a separate least-privilege DB role (deployment work, issue #22). PRD §7 auditability is therefore "a real mechanism exists" rather than "auditability is done"; remaining coverage stays an open gap. +4. ~~No audit-log table~~ **Foundation resolved 2026-10-17 (issue #43):** `AuditEvent` (§4.10) + `audit.record()` in the same transaction as the change, append-only at the DB level, wired into booking (both booking paths), doctor verification, payment fund/release/refund, the patient's referral accept/decline and the referral admin actions (`referral.reassign`, `referral.cancel` — Phase 3, issue #68, written as `entity_type = 'Referral'` rows in the same table). **Still open:** the referral's creation and the cascade-driven `completed`/`expired` transitions (no actor to attribute them to yet), the remaining Phase 4 transitions (prescriptions, lab orders, fulfillment), the admin login-throttle clear (`/auth/login-attempts/clear`) still only logs to the console; the app connects as the database owner, which could disable the triggers — enforcing append-only against the owner needs a separate least-privilege DB role (deployment work, issue #22). PRD §7 auditability is therefore "a real mechanism exists" rather than "auditability is done"; remaining coverage stays an open gap. 5. CSRF on the cookie-authenticated routes — the refresh cookie has to be `SameSite=None; Secure` (the Pages frontend is a different site — see `docs/adr/0003`), which means any site the patient visits can make their browser send it to `/auth/refresh` and `/auth/logout`. **Resolved 2026-10-15 (issue #37):** a per-session CSRF token is bound into the refresh token and required in the `X-CSRF-Token` header, the `Origin` header is validated against an explicit allowlist, and CORS is now a per-environment allowlist (`CORS_ORIGINS`) instead of one configured string. The double-submit cookie pattern — the usual answer — is *not* usable here: a cookie set by this API is a third-party cookie to `docforum.github.io`, so the frontend's JavaScript can never read it; the token travels in the login/refresh response body instead. A request with no `Origin` (curl, server-to-server) is allowed through the Origin check but still needs the token. See `backend/src/middleware/csrf.ts`, and `docs/api/README.md` for what the client must send. Consequence: refresh cookies issued before this change carry no `csrf` claim and are rejected, i.e. existing sessions need one re-login. ### 6.3 What's overengineered (architecture-level, for v1)? diff --git a/ARCHITECTURE_ESSENTIALS.md b/ARCHITECTURE_ESSENTIALS.md index a3f9c60..b5ec372 100644 --- a/ARCHITECTURE_ESSENTIALS.md +++ b/ARCHITECTURE_ESSENTIALS.md @@ -42,10 +42,10 @@ Quick-reference only. Full reasoning lives in `ARCHITECTURE.md`. Full product sc ## Known unresolved design gaps (do not silently "fix" without flagging in PR description) - No routing rule yet for a referral with no specific target doctor (which specialist gets it?). **v1 re-scope (2026-10-17): `target_doctor_id` is required, so unassigned referrals can't exist — but that makes PRD §8.2.2's "waiting for a specialist" state unreachable rather than implemented. ADR still owed (issue #6).** - No time-based expiry/TTL job for held slots or pending referrals — the referral *no-show* cascade (#67) now reaches `expired`, but nothing expires a referral (or a held slot) purely by age. Issue #8 / #19. -- `AuditEvent` exists (§4.10 of ARCHITECTURE.md) and records booking, doctor verification, payment fund/release/refund and the referral admin actions — auth (the admin login-throttle clear) and the remaining Phase 4 order transitions are still unaudited. PRD §7 is not satisfied yet. +- `AuditEvent` exists (§4.10 of ARCHITECTURE.md) and records booking (both booking paths), doctor verification, payment fund/release/refund, the patient's referral accept/decline and the referral admin actions — auth (the admin login-throttle clear), the referral's creation + cascade transitions, and the remaining Phase 4 order transitions are still unaudited. PRD §7 is not satisfied yet. - `Referral.snapshotIntakeId` has no column — `IntakeForm` doesn't exist yet (issue #1); the notes half of the snapshot rule is real and enforced. - Polymorphic `FulfillmentRecord.order_id` is a sketch, not final — may need splitting into two tables. -- `AuditEvent` foundation exists (Phase 8, issue #43 — ARCHITECTURE.md §4.10): append-only (DB triggers), written by `auditService.record(tx, …)` **inside the same transaction** as the change. Wired for booking, doctor verification, payment fund/release/refund and referral admin actions (`entity_type: 'Referral'`) — **Phase 4 transitions must call it when they're built.** There is one audit table: referral admin actions go into it, not into a second one. Never put PHI in `metadata`. +- `AuditEvent` foundation exists (Phase 8, issue #43 — ARCHITECTURE.md §4.10): append-only (DB triggers), written by `auditService.record(tx, …)` **inside the same transaction** as the change. Wired for booking (direct and referral-origin), doctor verification, payment fund/release/refund, referral accept/decline and referral admin actions (`entity_type: 'Referral'`) — **Phase 4 transitions must call it when they're built.** There is one audit table: referral actions go into it, not into a second one. Never put PHI in `metadata`. - No email/SMS provider integrated yet (Phase 6, issue #17) — email verification (issue #34) therefore can't deliver its link on a hosted deployment; `backend/scripts/mark-email-verified.mjs` is the interim operator path. - One doctor = one calendar assumed; multi-facility doctors not modeled. diff --git a/ROADMAP.md b/ROADMAP.md index 00e1b38..69a1af1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -123,7 +123,7 @@ roadmap. See each phase section below for status and issue links. - [ ] CSRF protection for the cookie-authenticated refresh/logout endpoints, plus an explicit per-environment CORS allowlist. A per-session CSRF token bound into the refresh token (not a double-submit cookie — a cookie set by this API is third-party to `docforum.github.io`, so the web client can't read it), a required `X-CSRF-Token` header on `/auth/refresh` + `/auth/logout`, an `Origin` allowlist check, and `CORS_ORIGINS` replacing the single configured origin. Tracked as [issue #37](https://github.com/DocForum/docforum-core/issues/37) (High, 200 pts) — **in progress**, branch `feat/csrf-protection-for-cookie-authenticated-refresh`. - [ ] Admin account lifecycle: secure bootstrap CLI, admin invites, and account deactivation. Tracked as [issue #39](https://github.com/DocForum/docforum-core/issues/39) (High, 200 pts) — **in progress**. - [ ] Patient data export (right of access) as a complete, machine-readable bundle. `POST /patients/me/export` + `GET /patients/me/export/:id` (+ `/download`), owner-only; an exporter registry where each module contributes its own section through its service (ARCHITECTURE.md §3.1 — `account`, `profile`, `appointments`, `payments` today); versioned JSON with `generatedAt`; generated by an interval worker in `src/jobs/patient-exports.ts` and stored as bounded chunks in Postgres (`PatientExport`/`PatientExportChunk`, §4.11 — no object-storage provider exists yet, see §6.3.4); expiry + cleanup after `PATIENT_EXPORT_TTL_HOURS`. Tracked as [issue #40](https://github.com/DocForum/docforum-core/issues/40) (High, 200 pts) — **in review**, branch `feat/40-patient-data-export`. Later modules (referrals, prescriptions, lab orders) must register their own section when they land; a verified guardian exporting a dependent's data is left to issue #42's model. -- [ ] `AuditEvent` table and write path — **foundation done, Phase 4 wiring pending.** Append-only `AuditEvent` (DB triggers reject UPDATE/DELETE/TRUNCATE), `auditService.record()` that must run inside the state change's own transaction, request ids on every response, and `GET /admin/audit-events` (entity/actor/date filters, keyset cursor). Wired into appointment booking, doctor verification, payment fund/release/refund and the referral admin actions (`referral.reassign` / `referral.cancel`, Phase 3 issue #68 — recorded as `entityType: 'Referral'` rows in this one table, not a second referral-specific table). Prescriptions, lab orders and fulfillment (Phase 4–5) must call `record()` when they land; the login-throttle admin clear still logs to console only. Tracked as [issue #43](https://github.com/DocForum/docforum-core/issues/43) (High, 200 pts) — **in review**, branch `feat/43-auditevent-foundation`. +- [ ] `AuditEvent` table and write path — **foundation done, Phase 3/4 wiring pending.** Append-only `AuditEvent` (DB triggers reject UPDATE/DELETE/TRUNCATE), `auditService.record()` that must run inside the state change's own transaction, request ids on every response, and `GET /admin/audit-events` (entity/actor/date filters, keyset cursor). Wired into appointment booking (both the direct and the referral-origin path), doctor verification, payment fund/release/refund, the patient's referral accept/decline and the referral admin actions (`referral.reassign` / `referral.cancel`, Phase 3 issue #68 — recorded as `entityType: 'Referral'` rows in this one table, not a second referral-specific table). Still unaudited: the referral's *creation* and the cascade-driven `completed`/`expired` transitions (no actor to attribute them to), the remaining Phase 4 order transitions, and the login-throttle admin clear (console-only). Tracked as [issue #43](https://github.com/DocForum/docforum-core/issues/43) (High, 200 pts) — **in review**, branch `feat/43-auditevent-foundation`. - [ ] JWT refresh revocation list (ARCHITECTURE.md §6.1.4). Tracked as [issue #18](https://github.com/DocForum/docforum-core/issues/18) (Medium, 150 pts) — unblocked now, operates on Phase 1's auth module. - [ ] Held-slot TTL/release job (ARCHITECTURE.md §6.2.1). Tracked as [issue #19](https://github.com/DocForum/docforum-core/issues/19) (Medium, 150 pts) — unblocked now, operates on Phase 1's `AvailabilitySlot`. - [x] Access-control test suite: who may read intake, consultation and payment data. Integration tests (`tests/integration/access-control.test.mts`) establishing the test harness and access-control patterns for the three PHI data categories; intake/consultation test cases are scaffolded as placeholders until Phase 2/3 routes exist, payment-intent access cases exercise the live admin-gated routes. Tracked as [issue #44](https://github.com/DocForum/docforum-core/issues/44) — **Done**. @@ -469,3 +469,28 @@ roadmap. See each phase section below for status and issue links. referral-specific table. One audit trail, one set of append-only triggers, one writer; `GET /referrals/admin/:id/audit-events` reads the same rows the admin-wide `GET /admin/audit-events` serves. + +- 2026-10-20 — Closed an audit gap the Phase 3 referral work had + introduced: the referral **accept** path created an `Appointment` + through `bookForReferral` without writing an audit row, so it was the + one way to create an appointment that left no trail — and the existing + `appointment.booked` event's `origin` metadata (always `direct` until + then) was clearly built to tell the two booking paths apart. Fixed by + having the appointments module write `appointment.booked` for the + referral-origin booking too (`origin: 'referral'`, plus the referral id + it came from), and by recording the patient's own state change on the + referral as `referral.accepted` / `referral.declined` — both inside the + accept's transaction, so the decision and the appointment it produced + are recorded together or not at all. `declineReferral` now runs in a + transaction (it previously used the root client, which the audit helper + refuses by design). + + Two referral transitions are still **not** audited, and are recorded as + an open gap in ARCHITECTURE.md §4.10 rather than silently dropped: the + referral's *creation* (`null → pending`) and the two the appointment + cascade drives (`accepted → completed`, `accepted → expired`), because + the cascade fires from inside the appointment's status transition, which + has no actor of its own to attribute. Both are owed when Phase 4/6 + wire the remaining state changes. Tests: two new integration cases + asserting the audit rows (the accept's two events, the decline's one) + against the real `AuditEvent` table. diff --git a/backend/src/modules/appointments/services/index.ts b/backend/src/modules/appointments/services/index.ts index 51b0503..1e249bd 100644 --- a/backend/src/modules/appointments/services/index.ts +++ b/backend/src/modules/appointments/services/index.ts @@ -130,6 +130,7 @@ export async function bookForReferral( availabilitySlotId: string; referralId: string; }, + actor: auditService.AuditActor, ) { const claimed = await availabilityService.claimOpenSlot(db, input.availabilitySlotId); if (!claimed) { @@ -144,13 +145,34 @@ export async function bookForReferral( throw new HttpError(409, 'That slot does not belong to the referral’s target doctor.'); } - return appointmentsRepo.createAppointment(db, { + 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; } /** diff --git a/backend/src/modules/referrals/controllers/index.ts b/backend/src/modules/referrals/controllers/index.ts index 533fa02..2ee2c1e 100644 --- a/backend/src/modules/referrals/controllers/index.ts +++ b/backend/src/modules/referrals/controllers/index.ts @@ -30,13 +30,17 @@ export async function createBatch(req: Request, res: Response) { export async function accept(req: Request, res: Response) { if (!req.user) throw new HttpError(401, 'Authentication required.'); const { availabilitySlotId } = acceptReferralSchema.parse(req.body); - const referral = await referralsService.acceptReferral(req.user.id, getParam(req, 'referralId'), availabilitySlotId); + const referral = await referralsService.acceptReferral( + auditActor(req), + getParam(req, 'referralId'), + availabilitySlotId, + ); res.status(200).json(referral); } export async function decline(req: Request, res: Response) { if (!req.user) throw new HttpError(401, 'Authentication required.'); - const referral = await referralsService.declineReferral(req.user.id, getParam(req, 'referralId')); + const referral = await referralsService.declineReferral(auditActor(req), getParam(req, 'referralId')); res.status(200).json(referral); } diff --git a/backend/src/modules/referrals/services/index.ts b/backend/src/modules/referrals/services/index.ts index 5abf8ff..bee832d 100644 --- a/backend/src/modules/referrals/services/index.ts +++ b/backend/src/modules/referrals/services/index.ts @@ -209,8 +209,13 @@ export async function createReferralBatch( * "pre-populated" in the FR-8 sense — no re-intake, the snapshot travels * on the referral — not in the sense of an auto-chosen time. */ -export async function acceptReferral(userId: string, referralId: string, availabilitySlotId: string) { - const patientProfile = await patientsService.getMyPatientProfile(userId); +export async function acceptReferral( + actor: auditService.AuditActor, + referralId: string, + availabilitySlotId: string, +) { + if (!actor.userId) throw new HttpError(401, 'Authentication required.'); + const patientProfile = await patientsService.getMyPatientProfile(actor.userId); return prisma.$transaction(async (tx) => { const referral = await referralsRepo.findReferralById(tx, referralId); @@ -234,12 +239,16 @@ export async function acceptReferral(userId: string, referralId: string, availab throw new HttpError(409, 'This referral is no longer pending.'); } - const appointment = await appointmentsService.bookForReferral(tx, { - patientProfileId: patientProfile.id, - doctorProfileId: referral.targetDoctorId, - availabilitySlotId, - referralId, - }); + const appointment = await appointmentsService.bookForReferral( + tx, + { + patientProfileId: patientProfile.id, + doctorProfileId: referral.targetDoctorId, + availabilitySlotId, + referralId, + }, + actor, + ); await referralsRepo.updateReferralConditional( tx, @@ -247,6 +256,19 @@ export async function acceptReferral(userId: string, referralId: string, availab { resultingAppointmentId: appointment.id }, ); + // The patient's own state change on the referral, recorded in the same + // transaction as the appointment it produced — so the trail shows who + // accepted, and the `appointment.booked` row the appointments module + // wrote, together or not at all. + await auditService.record(tx, actor, { + entityType: 'Referral', + entityId: referralId, + action: 'referral.accepted', + fromState: 'pending', + toState: 'accepted', + metadata: { appointmentId: appointment.id }, + }); + return referralsRepo.findReferralById(tx, referralId); }); } @@ -258,26 +280,38 @@ export async function acceptReferral(userId: string, referralId: string, availab * pending (re-bookable), from which declining is then possible. Keeps one * narrow transition instead of two overlapping exit paths. */ -export async function declineReferral(userId: string, referralId: string) { - const patientProfile = await patientsService.getMyPatientProfile(userId); - const referral = await referralsRepo.findReferralById(prisma, referralId); - if (!referral) throw new HttpError(404, 'Referral not found.'); - if (referral.patientProfileId !== patientProfile.id) { - throw new HttpError(403, 'This referral is not for you.'); - } - if (referral.status !== 'pending') { - throw new HttpError(409, 'Only a pending referral can be declined.'); - } +export async function declineReferral(actor: auditService.AuditActor, referralId: string) { + if (!actor.userId) throw new HttpError(401, 'Authentication required.'); + const patientProfile = await patientsService.getMyPatientProfile(actor.userId); + return prisma.$transaction(async (tx) => { + const referral = await referralsRepo.findReferralById(tx, referralId); + if (!referral) throw new HttpError(404, 'Referral not found.'); + if (referral.patientProfileId !== patientProfile.id) { + throw new HttpError(403, 'This referral is not for you.'); + } + if (referral.status !== 'pending') { + throw new HttpError(409, 'Only a pending referral can be declined.'); + } - const updated = await referralsRepo.updateReferralConditional( - prisma, - { id: referralId, status: 'pending', resultingAppointmentId: null }, - { status: 'declined' }, - ); - if (updated.count !== 1) { - throw new HttpError(409, 'This referral is no longer pending.'); - } - return referralsRepo.findReferralById(prisma, referralId); + const updated = await referralsRepo.updateReferralConditional( + tx, + { id: referralId, status: 'pending', resultingAppointmentId: null }, + { status: 'declined' }, + ); + if (updated.count !== 1) { + throw new HttpError(409, 'This referral is no longer pending.'); + } + + await auditService.record(tx, actor, { + entityType: 'Referral', + entityId: referralId, + action: 'referral.declined', + fromState: 'pending', + toState: 'declined', + }); + + return referralsRepo.findReferralById(tx, referralId); + }); } export async function listMine(userId: string) { diff --git a/backend/tests/integration/referral-foundation.test.mts b/backend/tests/integration/referral-foundation.test.mts index e8028c8..43ea34f 100644 --- a/backend/tests/integration/referral-foundation.test.mts +++ b/backend/tests/integration/referral-foundation.test.mts @@ -275,6 +275,72 @@ describe('referral foundation', () => { expect(again.status).toBe(409); }); + // The patient's accept/decline and the appointment the accept produces + // are state changes on accountable records, so both belong in the audit + // trail (Phase 8, issue #43) — the referral accept path used to be the + // one way to create an appointment with no trail at all. Asserted + // against the one AuditEvent table, written by the audit module's + // service, not by the referrals module touching the table. + it('audits the accept: the referral transition and the appointment it booked', async () => { + const { consultationId } = await consultationRitual(app, patient, doctorA); + const created = await createReferral(app, doctorA, { + sourceConsultationId: consultationId, + targetDoctorId: doctorB.profileId, + snapshotNotes: 'audited snapshot', + }); + expect(created.status).toBe(201); + const referralId = created.body.id as string; + + const slots = await generateSlots(app, doctorB, 2); + const accepted = await acceptReferral(app, patient, referralId, slots[0]); + expect(accepted.status).toBe(200); + const appointmentId = accepted.body.resultingAppointmentId as string; + + const referralEvents = await prisma.auditEvent.findMany({ + where: { entityType: 'Referral', entityId: referralId }, + }); + const acceptedEvent = referralEvents.find((event) => event.action === 'referral.accepted'); + expect(acceptedEvent).toBeDefined(); + expect(acceptedEvent?.fromState).toBe('pending'); + expect(acceptedEvent?.toState).toBe('accepted'); + expect(acceptedEvent?.actorUserId).toBe(patient.userId); + expect(acceptedEvent?.actorRole).toBe('patient'); + + // The appointment the accept booked is audited too, as an + // appointment — with `origin: 'referral'`, which is what tells the + // two booking paths apart in the trail. + const appointmentEvents = await prisma.auditEvent.findMany({ + where: { entityType: 'Appointment', entityId: appointmentId }, + }); + const bookedEvent = appointmentEvents.find((event) => event.action === 'appointment.booked'); + expect(bookedEvent).toBeDefined(); + expect(bookedEvent?.toState).toBe('scheduled'); + expect(bookedEvent?.actorUserId).toBe(patient.userId); + expect((bookedEvent?.metadata as { origin?: string } | null)?.origin).toBe('referral'); + }); + + it('audits the decline', async () => { + const { consultationId } = await consultationRitual(app, patient, doctorA); + const created = await createReferral(app, doctorA, { + sourceConsultationId: consultationId, + targetDoctorId: doctorB.profileId, + snapshotNotes: 'notes', + }); + const declined = await request(app) + .post(`/referrals/${created.body.id}/decline`) + .set('Authorization', `Bearer ${patient.accessToken}`); + expect(declined.status).toBe(200); + + const events = await prisma.auditEvent.findMany({ + where: { entityType: 'Referral', entityId: created.body.id as string }, + }); + const declinedEvent = events.find((event) => event.action === 'referral.declined'); + expect(declinedEvent).toBeDefined(); + expect(declinedEvent?.fromState).toBe('pending'); + expect(declinedEvent?.toState).toBe('declined'); + expect(declinedEvent?.actorUserId).toBe(patient.userId); + }); + it('lists the patient’s referrals grouped by source consultation', async () => { const list = await request(app) .get('/referrals/mine') diff --git a/docs/api/README.md b/docs/api/README.md index a805640..4df31f5 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -175,8 +175,8 @@ drift apart. See `docs/adr/0003` for why production needs `SameSite=None`. |---|---|---|---| | `POST /referrals` | doctor | `{ sourceConsultationId, targetDoctorId, targetSpecialtyId?, snapshotNotes }` | Issue #5. Requires: the caller's own **completed** appointment's consultation, a target doctor who is verified (and verified in `targetSpecialtyId` when given), no self-referral, no duplicate target for that consultation. `targetDoctorId` is **required** — the specialty-only routing ADR (issue #6) isn't done, so an unassignable referral can't be created. Writes `snapshotNotes` as a copy. A repeat doctor/specialty anywhere in the lineage **flags** the referral (`loopFlaggedAt`) for the admin queue instead of failing; chains over `REFERRAL_MAX_HOPS` (default 4) and self-referrals are `422`. | | `POST /referrals/from-consultation/:consultationId` | doctor | `{ targets: [...] }` | Issue #66 — same atomic batch as the consultations route; the decision to allow multi-referral with independent lifecycles (and no bundle table) is `docs/adr/0006-multiple-referrals-per-consultation.md`. Duplicate doctor *or* specialty inside the batch, or against an existing referral from that consultation, fails the whole batch. | -| `POST /referrals/:referralId/accept` | patient (the referred patient) | `{ availabilitySlotId }` | Issue #7. Conditional `pending → accepted` (exactly one concurrent accept wins → `409`), then books a real appointment against the **target doctor** through the same slot-claim path as `POST /appointments` — the patient picks the slot; no re-intake, the snapshot travels on the referral. `409` if the slot isn't the target doctor's. | -| `POST /referrals/:referralId/decline` | patient (the referred patient) | — | Pending-only, terminal (`409` otherwise). | +| `POST /referrals/:referralId/accept` | patient (the referred patient) | `{ availabilitySlotId }` | Issue #7. Conditional `pending → accepted` (exactly one concurrent accept wins → `409`), then books a real appointment against the **target doctor** through the same slot-claim path as `POST /appointments` — the patient picks the slot; no re-intake, the snapshot travels on the referral. `409` if the slot isn't the target doctor's. Writes two audit rows in the one append-only trail: `referral.accepted` (a `Referral`-typed row) and `appointment.booked` for the appointment it booked (`origin: 'referral'`), both inside the accept's own transaction. | +| `POST /referrals/:referralId/decline` | patient (the referred patient) | — | Pending-only, terminal (`409` otherwise). Writes a `referral.declined` audit row. | | `GET /referrals/mine` | patient | — | `{ referrals, groupedByConsultation }` — grouped for the "one visit, several referrals" view (#66). | | `GET /referrals/created` | doctor | — | Referrals the caller created, including ones the cascade expired (see `referringDoctorNotifiedAt`) and ones the loop guard flagged. | | `GET /referrals/admin/queue` | admin | query: `status`, `olderThan` (`48h` / `7d`), `loopFlagged` (`true`\|`false`) | Issue #68 — stuck/aged/loop-flagged referrals with a derived human-readable `reason` and `ageMinutes`. | @@ -216,9 +216,12 @@ pair, so paging never skips or repeats an event; events written after a scan started appear at the front of a new scan, not mid-way through the old one. `400 INVALID_CURSOR` for a malformed cursor; `400` if `from` is after `to`. -Recorded today: `appointment.booked`, `doctor.verification_status_changed`, -`payment_intent.{funded,released,refunded}`, `referral.reassign`, -`referral.cancel` — see ARCHITECTURE.md §4.10. There is no write or delete +Recorded today: `appointment.booked` (both the direct booking and the +referral-origin booking the accept produces — the `origin` metadata tells +them apart), `doctor.verification_status_changed`, +`payment_intent.{funded,released,refunded}`, `referral.accepted`, +`referral.declined`, `referral.reassign`, `referral.cancel` — see +ARCHITECTURE.md §4.10. There is no write or delete endpoint, and the table rejects UPDATE/DELETE in the database. Payment actions (`POST /payments/intents/:id/{fund,release,refund}`) now