diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index e56cbc1b..4fc40f8c 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 (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. +- `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), consultation closing (Phase 2, issue #4), 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 (fulfillment) must call `record()` in the same transaction when they're built** — prescriptions and lab orders already do. 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) @@ -173,18 +173,23 @@ Consultation 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) + outcome (enum: resolved|referred|follow_up_needed, nullable in the schema — + the close routes require it, so a closed record has one; see below) created_at, updated_at referrals: Referral[] # children — the lineage loop detection walks ``` +**Implemented 2026-10-20 (Phase 2, issue #4):** `appointment_id UNIQUE` is what makes a consultation closable **once** — a second concurrent close loses at the INSERT, not at an app-level check (see docs/adr/0009). There is no amendment/edit path in v1. `notes` is encrypted on write via `encryptField()` (requires `FIELD_ENCRYPTION_KEY_V1`), so the first writer to a clinical free-text column honours the rule below rather than starting a plaintext precedent; reads decrypt in the consultations service. + +The row has exactly one writer — `consultationsService.createConsultation` — reachable from three addresses so that no path can bypass the uniqueness, encryption or audit rules: `POST /consultations` (appointment in the body; Phase 3's referral slice, `outcome` optional so a record can exist before sign-off), `POST /appointments/:id/consultation` (Phase 2's close route, `outcome` + `notes` required), and the orders flow's consultation step (which adds its own verified-doctor gate and then delegates). All of them require the appointment to be `completed` and to belong to the calling doctor, and all of them write the same `consultation.closed` audit event in the same transaction. + **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. +Readable (`GET /consultations/:id`) only by the treating doctor and the +patient of the underlying appointment; anyone else gets `403`, and the +stored ciphertext never leaves the module. 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 ``` @@ -345,6 +350,7 @@ 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). 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. +- `consultation.closed`: `null → outcome` (metadata: appointment id; never the notes — that's PHI). Written by the consultations module for every consultation write, whichever route addressed it (`POST /consultations`, `POST /appointments/:id/consultation`, or the orders flow). - `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. diff --git a/ARCHITECTURE_ESSENTIALS.md b/ARCHITECTURE_ESSENTIALS.md index 3a79a193..36475c31 100644 --- a/ARCHITECTURE_ESSENTIALS.md +++ b/ARCHITECTURE_ESSENTIALS.md @@ -23,7 +23,7 @@ Quick-reference only. Full reasoning lives in `ARCHITECTURE.md`. Full product sc `User` (including `locale`, default `en`, Phase 9 #133) → `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` (`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` — routed at creation when only a specialty is named, ADR 0008; self-FK lineage `parent_referral_id`, no-show/loop/admin flags) → new `Appointment` +`Consultation` (`appointmentId` **UNIQUE** — closing a consultation is a create-once operation, `409` on a second attempt; one writer, `consultationsService.createConsultation`, reached from `POST /consultations`, `POST /appointments/:id/consultation` and the orders flow; see docs/adr/0009) → (outcome=referred) → `Referral` (snapshot! required `target_doctor_id` — routed at creation when only a specialty is named, ADR 0008; 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, consultation/referral mutations and payment mutations, never login/browsing). diff --git a/README.md b/README.md index 308316b7..00448a1c 100644 --- a/README.md +++ b/README.md @@ -157,7 +157,9 @@ the `embedded-postgres` devDependency instead, see below) or your own. git clone https://github.com/DocForum/docforum-core.git cd docforum-core/backend npm install -cp .env.example .env # fill in DATABASE_URL, JWT_ACCESS_SECRET, JWT_REFRESH_SECRET +cp .env.example .env # fill in DATABASE_URL, JWT_ACCESS_SECRET, JWT_REFRESH_SECRET, + # and FIELD_ENCRYPTION_KEY_V1 (base64, 32 bytes — closes + # consultations encrypt their notes; see scripts/README.md) npx prisma migrate deploy # applies backend/prisma/migrations/ npm run dev # → http://localhost:4000, GET /health to check ``` @@ -192,8 +194,11 @@ with a hop limit, multi-referral bundles from one consultation, and an admin queue with audited reassign/cancel, and specialty-only referrals routed at creation to the least-loaded eligible doctor (issue #6, `docs/adr/0008-referral-routing-without-target-doctor.md`). Still open -within Phase 3: a time-based expiry job (issue #8). Phase 2's remaining -pieces (intake, doctor's today view, specialty discovery) come next. +within Phase 3: a time-based expiry job (issue #8). Phase 2 is in +progress alongside it: the `Consultation` record and its close-once +guarantee (issue #4) are done, and the appointment lifecycle +(completed/cancelled/no_show) exists as Phase 3's prerequisite; intake, +the doctor's today view and specialty discovery are still ahead. Full phase breakdown: [`ROADMAP.md`](ROADMAP.md). diff --git a/ROADMAP.md b/ROADMAP.md index 65cebcbc..faa68144 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -44,11 +44,11 @@ roadmap. See each phase section below 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: 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.** +**Status: In progress — #3, #4 and #23 done; intake (#1/#2) 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. - [x] `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) — lifecycle transitions were already implemented; this change adds `GET /appointments/doctor/today`, limited to the caller's doctor profile and scheduled appointments for the server-local day. Admin-on-behalf transitions remain out of scope. -- [ ] `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. +- [x] `Consultation` record + outcome (`resolved | referred | follow_up_needed`). Tracked as [issue #4](https://github.com/DocForum/docforum-core/issues/4) (High, 200 pts) — **Done.** One writer (`consultationsService.createConsultation`) reached from three addresses — `POST /consultations` (Phase 3's slice), `POST /appointments/:id/consultation` (this issue's close route) and the orders flow's consultation step — all doctor-only, own- and `completed`-appointment-only, all requiring a verified email. `Consultation.appointmentId` `UNIQUE` is what makes "closed once" a DB constraint rather than app logic (`P2002` → `409`); notes are stored encrypted (issue #46) and decrypted on read; every write carries one `consultation.closed` audit event in the same transaction. The column/unique index/FK already exist from `20261020000000_order_payment_intents`, so this PR adds no migration. No amendment/edit path in v1 — that stays an open question for when amendment work lands (docs/adr/0009). Integration test: `backend/tests/integration/consultation.test.mts` (scheduled → `409`, closed once, second close `409` via either address, one row, decrypted read for doctor + patient, other-doctor read `403` / close `404`, patient close `403`). **Verification note:** the checked-in integration suites cannot run in this sandbox (embedded Postgres `initdb` is refused); they run in CI on the merge. - [x] Doctor discovery: `GET /specialties`, `PUT /doctors/me/specialties`, and public verified-only `GET /doctors` with specialty, typo-tolerant name/specialty search, and keyset pagination. Tracked as [issue #23](https://github.com/DocForum/docforum-core/issues/23) and extended by [issue #128](https://github.com/DocForum/docforum-core/issues/128). `docforum-web` issue #1 can fetch slot details separately by doctor id. - [ ] 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. diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 4ce7c99f..f19852ee 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -102,26 +102,26 @@ enum Urgency { // ========================================================================= model User { - id String @id @default(uuid()) - email String @unique - phone String? - passwordHash String - role Role + id String @id @default(uuid()) + email String @unique + phone String? + passwordHash String + role Role // Phase 8 (issue #34): null = never verified. Self-serve signup leaves // this null; existing rows were backfilled to `createdAt` in the // add_email_verification migration. Distinct from // DoctorProfile.verificationStatus, which is the separate, manual // admin verification of a doctor's credentials — do not conflate. - emailVerifiedAt DateTime? + emailVerifiedAt DateTime? // Phase 9, issue #133: server-generated text preference. Values are // normalized to a supported language tag at signup; unsupported tags // fall back to English. - locale String @default("en") + locale String @default("en") // Phase 8 (issue #39): null = active. Deactivating never deletes anything // — no clinical or payment record is keyed to this flag, and none of the // deactivation paths remove rows. Login, refresh and access tokens are all // refused while this is set. - deactivatedAt DateTime? + deactivatedAt DateTime? // Phase 8 (issue #39): bumped when an account is deactivated/reactivated, // so access tokens minted before that moment stop working immediately // instead of living out their 15-minute TTL. There is still no general @@ -133,9 +133,9 @@ model User { // (password reset, Phase 8 #33) invalidates every outstanding refresh // token for the user, since none of them carry the new value — the // interim kill switch issue #33 asked for while #18 doesn't exist yet. - tokenVersion Int @default(0) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + tokenVersion Int @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt patientProfile PatientProfile? doctorProfile DoctorProfile? @@ -144,10 +144,10 @@ model User { emailVerificationTokens EmailVerificationToken[] passwordResetTokens PasswordResetToken[] - adminInvitesSent AdminInvite[] + adminInvitesSent AdminInvite[] // Phase 8 (issue #45): PHI read-access log rows this user wrote (as viewer). // Opposite of AccessLog.accessedByUser. - accessLogs AccessLog[] + accessLogs AccessLog[] @@index([role]) } @@ -247,10 +247,10 @@ model RefreshSession { userId String user User @relation(fields: [userId], references: [id], onDelete: Cascade) - createdAt DateTime @default(now()) + createdAt DateTime @default(now()) // Updated on refresh only — never on every access-token-authenticated // request, so this doesn't become a write hot-spot on the User row. - lastUsedAt DateTime @default(now()) + lastUsedAt DateTime @default(now()) revokedAt DateTime? userAgent String? @@ -261,17 +261,17 @@ model RefreshSession { } model PatientProfile { - id String @id @default(uuid()) - userId String @unique - fullName String - dateOfBirth DateTime? + id String @id @default(uuid()) + userId String @unique + fullName String + dateOfBirth DateTime? // Nullable, no FK relation on purpose — the minor/dependent consent and // account-ownership model is an explicit open PRD question (§8.2.5), not // resolved here. Don't build on top of this field assuming it means // anything enforced yet. guardianUserId String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt user User @relation(fields: [userId], references: [id], onDelete: Cascade) appointments Appointment[] @@ -362,17 +362,17 @@ model DoctorProfile { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - specialties DoctorSpecialty[] - availabilitySlots AvailabilitySlot[] - appointments Appointment[] - prescriptions Prescription[] - labOrders LabOrder[] + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + specialties DoctorSpecialty[] + availabilitySlots AvailabilitySlot[] + 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") + referringReferrals Referral[] @relation("ReferralReferringDoctor") + targetedReferrals Referral[] @relation("ReferralTargetDoctor") @@index([verificationStatus]) } @@ -419,7 +419,7 @@ model FacilityCapability { capabilityType CapabilityType // e.g. a lab test category, or a medication class — free text on purpose, // no controlled vocabulary decided yet (out of Phase 1 scope). - detail String? + detail String? facilityProfile FacilityProfile @relation(fields: [facilityProfileId], references: [id], onDelete: Cascade) @@ -470,14 +470,17 @@ model Appointment { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - patientProfile PatientProfile @relation(fields: [patientProfileId], references: [id]) - doctorProfile DoctorProfile @relation(fields: [doctorProfileId], references: [id]) + patientProfile PatientProfile @relation(fields: [patientProfileId], references: [id]) + doctorProfile DoctorProfile @relation(fields: [doctorProfileId], references: [id]) availabilitySlot AvailabilitySlot? @relation(fields: [availabilitySlotId], references: [id]) - referral Referral? @relation("ReferralOriginatedAppointments", fields: [referralId], 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") + referralResultOf Referral? @relation("ReferralResultingAppointment") + // Phase 2 (issue #4): the appointment's consultation. 1:1 — the uniqueness + // is Consultation.appointmentId UNIQUE (docs/adr/0009), so this side is a + // plain back-relation. consultation Consultation? // TODO Phase 2: intakeId (FK -> IntakeForm). PRD FR-5 requires intake @@ -530,14 +533,23 @@ model IntakeForm { // 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 + id String @id @default(uuid()) + // unique = "a consultation is closed once per appointment", enforced by the + // database rather than by a read-then-write check (docs/adr/0009). + appointmentId String @unique + // Nullable so a consultation can exist before sign-off (Phase 3's referral + // slice); the close routes require it, so a closed record always has one. outcome ConsultationOutcome? + // Clinical free text: stored as an AES-256-GCM envelope by + // consultations/services (issue #46 — ARCHITECTURE.md §4.3), never plaintext. notes String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt - appointment Appointment @relation(fields: [appointmentId], references: [id]) + // RESTRICT (Prisma's default for a required relation) is what the existing + // migration created: a clinical record must not disappear because an + // appointment row was deleted. + appointment Appointment @relation(fields: [appointmentId], references: [id]) prescriptions Prescription[] labOrders LabOrder[] // Phase 3: the referrals that grew out of this consultation (the @@ -549,63 +561,63 @@ model Consultation { // 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) - patientProfileId String + 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 + sourceConsultationId String + referringDoctorId String // Always a concrete doctor: a "specialty, no specific doctor" referral // is routed to one at creation (least-loaded eligible doctor — issue // #6, docs/adr/0008). The column stays NOT NULL on purpose, so an // unassigned referral cannot exist; when nobody is eligible the // creation fails instead of writing a row. - targetDoctorId String + 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 + 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? + 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? + 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? + loopFlaggedAt DateTime? + loopFlagReason String? // Issue #68: admin-initiated cancellation carries its reason; the // audit trail proper is the AuditEvent row written alongside. - cancelledReason String? + 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]) + 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") + appointments Appointment[] @relation("ReferralOriginatedAppointments") @@index([patientProfileId]) @@index([referringDoctorId]) @@ -631,14 +643,14 @@ model Prescription { supersedesPrescriptionId String? createdAt DateTime @default(now()) - consultation Consultation @relation(fields: [consultationId], references: [id]) - patientProfile PatientProfile @relation(fields: [patientProfileId], references: [id]) - prescribingDoctor DoctorProfile @relation(fields: [prescribingDoctorId], references: [id]) - facilityProfile FacilityProfile @relation(fields: [facilityProfileId], references: [id]) - supersedes Prescription? @relation("PrescriptionCorrections", fields: [supersedesPrescriptionId], references: [id]) - correctedBy Prescription? @relation("PrescriptionCorrections") - items PrescriptionItem[] - paymentIntent PaymentIntent? + consultation Consultation @relation(fields: [consultationId], references: [id]) + patientProfile PatientProfile @relation(fields: [patientProfileId], references: [id]) + prescribingDoctor DoctorProfile @relation(fields: [prescribingDoctorId], references: [id]) + facilityProfile FacilityProfile @relation(fields: [facilityProfileId], references: [id]) + supersedes Prescription? @relation("PrescriptionCorrections", fields: [supersedesPrescriptionId], references: [id]) + correctedBy Prescription? @relation("PrescriptionCorrections") + items PrescriptionItem[] + paymentIntent PaymentIntent? @@unique([supersedesPrescriptionId]) @@index([patientProfileId, createdAt]) @@ -659,27 +671,27 @@ model PrescriptionItem { } model LabOrder { - id String @id @default(uuid()) - consultationId String - patientProfileId String - orderingDoctorId String - facilityProfileId String - amountStroops BigInt - testType String - urgency Urgency @default(routine) - notes String? - status OrderStatus @default(issued) + id String @id @default(uuid()) + consultationId String + patientProfileId String + orderingDoctorId String + facilityProfileId String + amountStroops BigInt + testType String + urgency Urgency @default(routine) + notes String? + status OrderStatus @default(issued) supersedesLabOrderId String? - createdAt DateTime @default(now()) + createdAt DateTime @default(now()) - consultation Consultation @relation(fields: [consultationId], references: [id]) - patientProfile PatientProfile @relation(fields: [patientProfileId], references: [id]) - orderingDoctor DoctorProfile @relation(fields: [orderingDoctorId], references: [id]) + consultation Consultation @relation(fields: [consultationId], references: [id]) + patientProfile PatientProfile @relation(fields: [patientProfileId], references: [id]) + orderingDoctor DoctorProfile @relation(fields: [orderingDoctorId], references: [id]) facilityProfile FacilityProfile @relation(fields: [facilityProfileId], references: [id]) - supersedes LabOrder? @relation("LabOrderCorrections", fields: [supersedesLabOrderId], references: [id]) - correctedBy LabOrder? @relation("LabOrderCorrections") - result LabResult? - paymentIntent PaymentIntent? + supersedes LabOrder? @relation("LabOrderCorrections", fields: [supersedesLabOrderId], references: [id]) + correctedBy LabOrder? @relation("LabOrderCorrections") + result LabResult? + paymentIntent PaymentIntent? @@unique([supersedesLabOrderId]) @@index([patientProfileId, createdAt]) @@ -712,11 +724,11 @@ model FulfillmentRecord { } model Notification { - id String @id @default(uuid()) + id String @id @default(uuid()) type String payload Json readAt DateTime? - createdAt DateTime @default(now()) + createdAt DateTime @default(now()) // TODO: userId } @@ -740,7 +752,6 @@ enum PaymentIntentStatus { failed } - model PaymentIntent { id String @id @default(uuid()) prescriptionId String? @unique @@ -751,7 +762,7 @@ model PaymentIntent { labOrder LabOrder? @relation(fields: [labOrderId], references: [id]) patientProfileId String - patientProfile PatientProfile @relation(fields: [patientProfileId], references: [id]) + patientProfile PatientProfile @relation(fields: [patientProfileId], references: [id]) facilityId String facility FacilityProfile @relation(fields: [facilityId], references: [id]) @@ -813,18 +824,18 @@ model EscrowEventCursor { } model EscrowChainEvent { - id String @id @default(uuid()) - eventId String - contractId String - ledger Int - txHash String? - escrowId BigInt? - eventType String - state EscrowChainEventState @default(pending) - paymentIntentId String? - payload Json? - detectedAt DateTime @default(now()) - alertedAt DateTime? + id String @id @default(uuid()) + eventId String + contractId String + ledger Int + txHash String? + escrowId BigInt? + eventType String + state EscrowChainEventState @default(pending) + paymentIntentId String? + payload Json? + detectedAt DateTime @default(now()) + alertedAt DateTime? @@unique([contractId, eventId]) @@index([state, detectedAt]) diff --git a/backend/src/app.ts b/backend/src/app.ts index 3ab9e514..97926f43 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -7,6 +7,7 @@ import { adminAuthRouter, authRouter } from './modules/auth/routes'; import { doctorsRouter, specialtiesRouter } from './modules/doctors/routes'; import { availabilityRouter } from './modules/availability/routes'; import { appointmentsRouter } from './modules/appointments/routes'; +import { appointmentConsultationsRouter, consultationsRouter } from './modules/consultations/routes'; import { ordersRouter } from './modules/orders/routes'; import { paymentsRouter } from './modules/payments/routes'; import { patientsRouter } from './modules/patients/routes'; @@ -16,7 +17,6 @@ import { auditRouter, adminAuditRouter, patientAuditRouter } from './modules/aud 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(); @@ -49,6 +49,10 @@ export function createApp() { app.use('/specialties', specialtiesRouter); app.use('/availability', availabilityRouter); app.use('/appointments', appointmentsRouter); + // Closing a consultation is addressable from the appointment it belongs to + // (POST /appointments/:appointmentId/consultation) as well as from + // /consultations below — same service, one write path. + app.use('/appointments', appointmentConsultationsRouter); app.use('/orders', ordersRouter); app.use('/payments', paymentsRouter); app.use('/patients', patientsRouter); diff --git a/backend/src/modules/appointments/services/index.ts b/backend/src/modules/appointments/services/index.ts index b27a5b8d..ae5c701e 100644 --- a/backend/src/modules/appointments/services/index.ts +++ b/backend/src/modules/appointments/services/index.ts @@ -71,6 +71,22 @@ export async function listMyAppointments(userId: string) { return appointmentsRepo.findAppointmentsForPatient(patientProfile.id); } +/** + * Cross-module entry point for the consultations module: return the + * appointment a doctor may act on, or null if the appointment doesn't exist + * or isn't theirs. The consultations module never queries `Appointment` + * directly — it calls this service instead (AGENTS.md hard rule 4). + */ +export async function getAppointmentForDoctor(doctorUserId: string, appointmentId: string) { + const appointment = await appointmentsRepo.findAppointmentById(prisma, appointmentId); + if (!appointment) return null; + + const doctorProfile = await doctorsService.getDoctorProfileById(appointment.doctorProfileId); + if (doctorProfile.userId !== doctorUserId) return null; + + return appointment; +} + /** Doctor's own scheduled appointments whose booked slot falls on the * current server-local calendar day. */ export async function listDoctorAppointmentsToday(userId: string) { diff --git a/backend/src/modules/consultations/controllers/index.ts b/backend/src/modules/consultations/controllers/index.ts index 53e98e66..3588f15f 100644 --- a/backend/src/modules/consultations/controllers/index.ts +++ b/backend/src/modules/consultations/controllers/index.ts @@ -1,13 +1,32 @@ import type { Request, Response } from 'express'; import * as consultationsService from '../services'; -import { createConsultationSchema, createReferralsSchema } from '../validation'; +import { closeConsultationSchema, createConsultationSchema, createReferralsSchema } from '../validation'; import { HttpError } from '../../../http-error'; +import { auditActor } from '../../../middleware/request-id'; import { getParam } from '../../../request-params'; +/** POST /appointments/:appointmentId/consultation — close (create) the + * consultation for the appointment in the path. */ +export async function closeConsultation(req: Request, res: Response) { + if (!req.user) throw new HttpError(401, 'Authentication required.'); + const appointmentId = getParam(req, 'appointmentId'); + const { outcome, notes } = closeConsultationSchema.parse(req.body); + + const { consultation, appointment } = await consultationsService.createConsultation(auditActor(req), { + appointmentId, + outcome, + notes, + }); + + res.status(201).json({ consultation, appointment }); +} + +/** POST /consultations — the same write, with the appointment in the body. + * Kept because Phase 3's referral flow and its tests address it this way. */ 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); + const { consultation } = await consultationsService.createConsultation(auditActor(req), input); res.status(201).json(consultation); } diff --git a/backend/src/modules/consultations/repositories/index.ts b/backend/src/modules/consultations/repositories/index.ts index 3ab84048..a92d9b53 100644 --- a/backend/src/modules/consultations/repositories/index.ts +++ b/backend/src/modules/consultations/repositories/index.ts @@ -1,22 +1,27 @@ import { prisma } from '../../../db/prisma-client'; -import type { ConsultationOutcome, Prisma } from '@prisma/client'; +import type { Consultation, ConsultationOutcome, Prisma } from '@prisma/client'; type DbClient = typeof prisma | Prisma.TransactionClient; export function createConsultation( db: DbClient, - data: { appointmentId: string; outcome?: ConsultationOutcome; notes?: string }, -) { + data: { appointmentId: string; outcome: ConsultationOutcome | null; notes: string | null }, +): Promise { return db.consultation.create({ data }); } +/** One consultation per appointment (the unique index is the guarantee — + * this read is only for callers that need the row without a write). */ +export function findConsultationByAppointmentId( + db: DbClient, + appointmentId: string, +): Promise { + return db.consultation.findUnique({ where: { appointmentId } }); +} + 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 9b10d302..d2227f43 100644 --- a/backend/src/modules/consultations/routes/index.ts +++ b/backend/src/modules/consultations/routes/index.ts @@ -2,11 +2,32 @@ import { Router } from 'express'; import { authenticate, requireRole, requireVerifiedEmail } from '../../../middleware/auth'; import * as consultationsController from '../controllers'; +/** + * Appointment-scoped router. Mounted at /appointments in src/app.ts, so the + * full path is POST /appointments/:appointmentId/consultation. It is a + * separate router from `consultationsRouter` because this module's other + * routes live under /consultations — mounting one router at both prefixes + * would also publish `/appointments/:consultationId`. + * + * Closing is doctor-only and restricted to the doctor's own appointment via + * the service layer; the one-per-appointment guarantee is enforced at the DB + * level (`Consultation.appointmentId UNIQUE`). Verified-address bar, same as + * booking and the create route below — closing a consultation is what + * referrals and orders hang off. + */ +export const appointmentConsultationsRouter = Router(); + +appointmentConsultationsRouter.post( + '/:appointmentId/consultation', + authenticate, + requireRole('doctor'), + requireVerifiedEmail, + consultationsController.closeConsultation, +); + +/** Mounted at /consultations. */ 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, diff --git a/backend/src/modules/consultations/services/index.ts b/backend/src/modules/consultations/services/index.ts index 6ed8e8c0..76acf384 100644 --- a/backend/src/modules/consultations/services/index.ts +++ b/backend/src/modules/consultations/services/index.ts @@ -1,57 +1,131 @@ -// 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. +// Consultations services — one write path for the Consultation record. +// +// Phase 2, issue #4 ("close a consultation exactly once") and Phase 3's +// minimal slice (issue #5's lineage host + issue #66's multi-referral +// endpoint) both create this row, so they share `createConsultation` below +// rather than each writing the table their own way. The routes that reach it +// (`POST /consultations`, `POST /appointments/:id/consultation`, and the +// orders module's consultation step) are three addresses for one operation, +// not three implementations. +import { Prisma, type ConsultationOutcome } from '@prisma/client'; 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 { decryptField, encryptField } from '../../../utils/field-encryption'; +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 * as consultationsRepo from '../repositories'; +import * as referralsService from '../../referrals/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); +export interface ConsultationInput { + appointmentId: string; + /** Required by the close routes (`closeConsultationSchema`); optional here + * only so the create route can open a record before sign-off (Phase 3's + * referral slice, matching `POST /consultations`). */ + outcome?: ConsultationOutcome | null; + notes?: string | null; +} - 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, +/** + * Create (close) the consultation for the caller's own appointment, exactly + * once. + * + * - The one-consultation-per-appointment guarantee is enforced at the DB + * level by `Consultation.appointmentId UNIQUE`, not by a check-then-write + * here. Two concurrent closes both reach the INSERT; exactly one commits, + * the loser gets P2002 and a 409 (docs/adr/0009). + * - "Doctor-only, own-appointment-only" is enforced by reading the + * Appointment through appointmentsService (the only legal cross-module + * seam per AGENTS.md hard rule 4): that service returns null for an + * appointment that doesn't exist or isn't the caller's, and this service + * answers 404 either way so it never reveals that someone else's + * appointment exists. + * - A consultation is recorded against a `completed` appointment — the visit + * that produced it has to have happened (Phase 3's referral precondition). + * - Single-use in v1: no amendments/edits. That is an explicit open + * question (ROADMAP.md Phase 2), not an assumption we silently bake in. + * - `notes` is clinical free text and is stored as an AES-256-GCM envelope + * (ARCHITECTURE.md §4.3 / §2 — Phase 8 issue #46), never plaintext, so + * every writer goes through here. The ciphertext is unsearchable by + * design; reads decrypt in the app layer. + */ +export async function createConsultation(actor: auditService.AuditActor, input: ConsultationInput) { + if (!actor.userId) throw new HttpError(401, 'Authentication required.'); + + const appointment = await appointmentsService.getAppointmentForDoctor(actor.userId, input.appointmentId); + if (!appointment) { + throw new HttpError(404, 'Appointment not found.'); + } + if (appointment.status !== 'completed') { + throw new HttpError(409, 'A consultation is recorded against a completed appointment.'); + } + + const notes = input.notes ?? null; + + let consultation; + try { + consultation = await prisma.$transaction(async (tx) => { + const record = await consultationsRepo.createConsultation(tx, { + appointmentId: input.appointmentId, + outcome: input.outcome ?? null, + notes: notes === null ? null : (encryptField(notes) ?? null), + }); + + // Phase 8 audit trail (issue #43): the state change and its event commit + // together, so an event exists iff the consultation was actually closed. + await auditService.record(tx, actor, { + entityType: 'Consultation', + entityId: record.id, + action: 'consultation.closed', + fromState: null, + toState: record.outcome, + metadata: { appointmentId: record.appointmentId }, + }); + + return record; }); - }); + } catch (err) { + // The DB unique constraint is the actual guarantee; translate the race's + // loser into a 409 rather than a 500. + if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') { + throw new HttpError(409, 'A consultation for this appointment has already been closed.'); + } + throw err; + } + + // The caller just wrote these notes, so hand them back in plaintext rather + // than leaking the stored ciphertext envelope. + return { consultation: { ...consultation, notes }, appointment }; +} + +/** Service-to-service read for modules that hang records off a consultation + * (orders: prescriptions, lab orders). Returns the stored row as-is — callers + * outside this module never touch `Consultation` themselves (AGENTS.md hard + * rule 4). */ +export async function getConsultationRecord(consultationId: string) { + const consultation = await consultationsRepo.findConsultationById(prisma, consultationId); + if (!consultation) throw new HttpError(404, 'Consultation not found.'); + return consultation; } /** Visible to the treating doctor and the patient of the underlying - * appointment — nothing wider in v1. */ + * appointment — nothing wider in v1. `notes` is decrypted for the reader; + * the stored ciphertext never leaves this module. */ 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; + if (doctor && doctor.id === consultation.appointment.doctorProfileId) { + return { ...consultation, notes: decryptField(consultation.notes) }; + } const patient = await patientsService.getMyPatientProfile(userId).catch(() => null); - if (patient && patient.id === consultation.appointment.patientProfileId) return consultation; + if (patient && patient.id === consultation.appointment.patientProfileId) { + return { ...consultation, notes: decryptField(consultation.notes) }; + } } throw new HttpError(403, 'You do not have access to this consultation.'); } diff --git a/backend/src/modules/consultations/validation.ts b/backend/src/modules/consultations/validation.ts index fc47e986..b3f71f14 100644 --- a/backend/src/modules/consultations/validation.ts +++ b/backend/src/modules/consultations/validation.ts @@ -1,6 +1,14 @@ import { z } from 'zod'; import { referralTargetSchema } from '../referrals/validation'; +/** Close route: the outcome is the point of closing, so it is required, and + * notes are the clinical record itself (empty notes are not a record). */ +export const closeConsultationSchema = z.object({ + outcome: z.enum(['resolved', 'referred', 'follow_up_needed']), + notes: z.string().min(1).max(20000), +}); + +/** Create route (Phase 3's slice): the same row, addressable before sign-off. */ export const createConsultationSchema = z.object({ appointmentId: z.string().min(1), outcome: z.enum(['resolved', 'referred', 'follow_up_needed']).optional(), diff --git a/backend/src/modules/orders/repositories/index.ts b/backend/src/modules/orders/repositories/index.ts index 9861fcdb..1422a515 100644 --- a/backend/src/modules/orders/repositories/index.ts +++ b/backend/src/modules/orders/repositories/index.ts @@ -3,13 +3,8 @@ import type { Prisma } from '@prisma/client'; type DbClient = typeof prisma | Prisma.TransactionClient; -export function createConsultation(db: DbClient, input: { appointmentId: string; outcome: 'resolved' | 'referred' | 'follow_up_needed' | null; notes: string | null }) { - return db.consultation.create({ data: input }); -} - -export function findConsultation(id: string, db: DbClient = prisma) { - return db.consultation.findUnique({ where: { id } }); -} +// Consultation rows are owned by the consultations module; this module goes +// through its services (see orders/services) rather than writing them here. export function findPrescriptionForCorrection(id: string, db: DbClient = prisma) { return db.prescription.findUnique({ where: { id }, include: { correctedBy: true } }); diff --git a/backend/src/modules/orders/services/index.ts b/backend/src/modules/orders/services/index.ts index 63ba541f..bb27b96d 100644 --- a/backend/src/modules/orders/services/index.ts +++ b/backend/src/modules/orders/services/index.ts @@ -1,6 +1,7 @@ import { prisma } from '../../../db/prisma-client'; import { HttpError } from '../../../http-error'; import * as appointmentsService from '../../appointments/services'; +import * as consultationsService from '../../consultations/services'; import * as paymentsService from '../../payments/services'; import * as auditService from '../../audit/services'; import { encryptField, decryptField } from '../../../utils/field-encryption'; @@ -8,34 +9,27 @@ import * as ordersRepo from '../repositories'; import type { Prisma } from '@prisma/client'; import { paginate, type PatientExportSection } from '../../patients/services'; +/** The consultation step of the ordering flow. + * + * The Consultation row is owned by the consultations module, which is also + * where "one per appointment", the completed-appointment rule, note + * encryption, the `consultation.closed` audit event and the P2002 → 409 race + * handling live. This function adds only the stricter gate this module already + * had (a **verified** doctor profile, via getDoctorAppointmentContext) and then + * delegates — it must never write the table itself. */ export async function createConsultation(actor: auditService.AuditActor, input: { appointmentId: string; outcome: 'resolved' | 'referred' | 'follow_up_needed' | null; notes: string | null; }) { if (!actor.userId) throw new HttpError(401, 'Authentication required.'); - const appointment = await appointmentsService.getDoctorAppointmentContext(actor.userId, input.appointmentId); - try { - return await prisma.$transaction(async (tx) => { - const consultation = await ordersRepo.createConsultation(tx, { - ...input, - notes: encryptField(input.notes) ?? null, - }); - await auditService.record(tx, actor, { - entityType: 'Consultation', entityId: consultation.id, action: 'consultation.created', - fromState: null, toState: consultation.outcome, - metadata: { appointmentId: appointment.id }, - }); - return { ...consultation, notes: input.notes }; - }); - } catch (error) { - if (isUniqueViolation(error)) throw new HttpError(409, 'A consultation already exists for this appointment.'); - throw error; - } + await appointmentsService.getDoctorAppointmentContext(actor.userId, input.appointmentId); + const { consultation } = await consultationsService.createConsultation(actor, input); + return consultation; } async function consultationContext(actor: auditService.AuditActor, id: string) { if (!actor.userId) throw new HttpError(401, 'Authentication required.'); - const consultation = await ordersRepo.findConsultation(id); - if (!consultation) throw new HttpError(404, 'Consultation not found.'); + // Read through the consultations service, never its table (AGENTS.md hard rule 4). + const consultation = await consultationsService.getConsultationRecord(id); const appointment = await appointmentsService.getDoctorAppointmentContext(actor.userId, consultation.appointmentId); return { consultation, appointment }; } diff --git a/backend/tests/integration/access-control.test.mts b/backend/tests/integration/access-control.test.mts index b89cf3b4..a90c953b 100644 --- a/backend/tests/integration/access-control.test.mts +++ b/backend/tests/integration/access-control.test.mts @@ -55,6 +55,9 @@ const KNOWN_ROUTES = [ 'POST /availability/doctors/:doctorProfileId/slots/generate', 'POST /appointments', 'GET /appointments/mine', + 'POST /appointments/:appointmentId/consultation', + 'POST /consultations', + 'GET /consultations/:consultationId', 'POST /payments/wallet-link', 'GET /payments/wallet-link', 'POST /orders/consultations', @@ -107,6 +110,15 @@ const ACCESS_MATRIX: MatrixRow[] = [ { method: 'POST', path: '/appointments', expected: { anon: 401, patient: 403, doctor: 403, facility: 403, admin: 403 } }, // patient unverified → 403 EMAIL_NOT_VERIFIED { method: 'GET', path: '/appointments/mine', expected: { anon: 401, patient: 200, doctor: 403, facility: 403, admin: 403 } }, + // Consultations (issue #4) — closing is doctor-only, verified-email gated + // and only for the doctor's own appointment. The seeded doctor is + // unverified, so the email gate answers 403 before the service can answer + // 404; the own-appointment 404 (never a 403 that would confirm the + // appointment exists) is asserted in consultation.test.mts. + { method: 'POST', path: '/appointments/:appointmentId/consultation', expected: { anon: 401, patient: 403, doctor: 403, facility: 403, admin: 403 } }, + { method: 'POST', path: '/consultations', expected: { anon: 401, patient: 403, doctor: 403, facility: 403, admin: 403 } }, + { method: 'GET', path: '/consultations/:consultationId', expected: { anon: 401, patient: 404, doctor: 404, facility: 404, admin: 404 } }, + // Orders: doctor-only and verified-email gated. { method: 'POST', path: '/orders/consultations', expected: { anon: 401, patient: 403, doctor: 403, facility: 403, admin: 403 } }, { method: 'POST', path: '/orders/consultations/:consultationId/prescriptions', expected: { anon: 401, patient: 403, doctor: 403, facility: 403, admin: 403 } }, @@ -247,9 +259,15 @@ describe('Access-control test suite: who may read intake, consultation and payme expect(true).toBe(true); }); - it('non-participating doctors cannot read a consultation (IDOR guard)', async () => { - // TODO(issue #3/#4): once consultation routes exist, seed two doctors - // and assert doctor B gets 403/404 on doctor A's consultation. + it('non-participating doctors cannot act on a consultation (IDOR guard)', async () => { + // The consultation write paths are covered by the matrix rows above (an + // unverified doctor is stopped by the email gate; a verified non-owner + // gets 404, never a 403 that would confirm the appointment exists). Both + // are asserted end-to-end — including a real second doctor being refused + // on doctor A's appointment — in tests/integration/consultation.test.mts. + // The read route (`GET /consultations/:id`) is in the matrix for + // unauthenticated access only; its owner/patient scoping needs seeded + // rows, which consultation.test.mts also covers. expect(true).toBe(true); }); }); diff --git a/backend/tests/integration/consultation.test.mts b/backend/tests/integration/consultation.test.mts new file mode 100644 index 00000000..9781c415 --- /dev/null +++ b/backend/tests/integration/consultation.test.mts @@ -0,0 +1,268 @@ +// Integration tests for issue #4: closing a consultation, exactly once. +// (ROADMAP.md Phase 2 — "`Consultation` record + outcome".) +// +// The correctness-critical assertions here are: +// 1. the owning doctor can close their appointment's consultation once, +// and only after the appointment is `completed`; +// 2. a second close is refused AND the database still holds exactly one +// row — the unique constraint on Consultation.appointmentId is the +// guarantee, not the service's pre-check. The two addresses for that +// write (`POST /appointments/:id/consultation` and `POST /consultations`) +// are the same operation, so neither can slip a second row past it; +// 3. another doctor cannot close it (404, not 403 — no existence leak). +// +// Self-contained like the other integration suites: spins up its own +// throwaway Postgres (embedded-postgres, prebuilt binary) and applies the +// committed migrations, so no pre-existing DB/Docker is needed. The port has +// to be unique per integration file — two embedded clusters on one port hang +// each other's beforeAll until the hook timeout kills the file. Taken today: +// 5433 booking, 5435 email-verification, 5436 login-throttle, 5437 csrf, +// 5438 access-control, 5439 admin-lifecycle, 5440 payments-bigint, +// 5441 audit, 5442 patient-export, 5443 referral-loops, 5444 admin-referrals, +// 5445 referral-foundation, 5446 referral-cascade, 5447 referral-multi, +// 5451 sessions, 5452 this file. +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'; + +const BACKEND_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const PORT = 5452; +const runId = Date.now(); + +const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'docforum-consult-pg-')); +const pg = new EmbeddedPostgres({ + databaseDir: dataDir, + user: 'docforum', + password: 'docforum', + port: PORT, + persistent: false, +}); + +// Matches vitest.config.mts's DATABASE_URL convention: the client built at +// import time in src/db/prisma-client.ts connects lazily with this URL. +const databaseUrl = `postgresql://docforum:docforum@localhost:${PORT}/docforum_test`; +// vitest.config.mts points DATABASE_URL at booking.test.mts's cluster +// (5433); this file runs its own on 5452, so it has to say so before +// src/db/prisma-client.ts constructs the client at import time. Without +// this the app talks to whatever is on 5433 — nothing when the file runs +// alone (ECONNREFUSED), and *booking's* database when the suite runs in +// parallel, which would make these assertions pass against the wrong DB. +process.env.DATABASE_URL = databaseUrl; + +/** Booking requires a verified email (Phase 8, issue #34), so the suite + * installs its own email sender and reads the verification token out of the + * message — the DB only stores the token's hash. */ +const emailedTokens = new Map(); + +type App = ReturnType; + +async function signupAndVerify(app: App, role: 'doctor' | 'patient', email: string, fullName: string) { + const res = await request(app).post('/auth/signup').send({ role, email, password: 'longenough123', fullName }); + expect(res.status, JSON.stringify(res.body)).toBe(200); + + const token = emailedTokens.get(email); + expect(token, `no verification email captured for ${email}`).toBeTruthy(); + const verified = await request(app).post('/auth/verify-email').send({ token }); + expect(verified.status).toBe(200); + + return res.body.accessToken as string; +} + +describe('consultation close-once guarantee', () => { + let app: App; + let prisma: typeof import('../../src/db/prisma-client.js').prisma; + let owningDoctorToken: string; + let otherDoctorToken: string; + let patientToken: string; + let appointmentId: 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', + }); + + // Imported dynamically, after migrations exist: src/app.ts's import chain + // constructs PrismaClient at module-load time. + const appModule = await import('../../src/app.js'); + const dbModule = await import('../../src/db/prisma-client.js'); + const authServices = await import('../../src/modules/auth/services/index.js'); + app = appModule.createApp(); + prisma = dbModule.prisma; + + authServices.setEmailSender(async (message) => { + const match = /[?&]token=([^&\s]+)/.exec(message.text); + if (match) emailedTokens.set(message.to, decodeURIComponent(match[1])); + }); + + owningDoctorToken = await signupAndVerify(app, 'doctor', `consult-doctor-${runId}@example.com`, 'Owning Doctor'); + otherDoctorToken = await signupAndVerify(app, 'doctor', `consult-other-${runId}@example.com`, 'Other Doctor'); + patientToken = await signupAndVerify(app, 'patient', `consult-patient-${runId}@example.com`, 'Test Patient'); + + const doctorMe = await request(app).get('/doctors/me').set('Authorization', `Bearer ${owningDoctorToken}`); + expect(doctorMe.status).toBe(200); + const doctorProfileId = doctorMe.body.id as string; + + const tomorrow = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString().slice(0, 10); + const generate = await request(app) + .post(`/availability/doctors/${doctorProfileId}/slots/generate`) + .set('Authorization', `Bearer ${owningDoctorToken}`) + .send({ date: tomorrow, startHour: 9, endHour: 10, slotLengthMinutes: 30 }); + expect(generate.status, JSON.stringify(generate.body)).toBe(201); + expect(generate.body.created).toBeGreaterThan(0); + + const slots = await request(app).get(`/availability/doctors/${doctorProfileId}/slots`); + expect(slots.body.length).toBeGreaterThan(0); + + const book = await request(app) + .post('/appointments') + .set('Authorization', `Bearer ${patientToken}`) + .send({ availabilitySlotId: slots.body[0].id }); + expect(book.status, JSON.stringify(book.body)).toBe(201); + appointmentId = book.body.id as string; + }); + + afterAll(async () => { + const authServices = await import('../../src/modules/auth/services/index.js'); + authServices.resetEmailSender(); + await prisma?.$disconnect(); + await pg.stop(); + fs.rmSync(dataDir, { recursive: true, force: true }); + }); + + it('refuses to close a consultation before the appointment is completed', async () => { + const res = await request(app) + .post(`/appointments/${appointmentId}/consultation`) + .set('Authorization', `Bearer ${owningDoctorToken}`) + .send({ outcome: 'resolved', notes: 'Too early — the visit has not happened.' }); + + expect(res.status, JSON.stringify(res.body)).toBe(409); + expect(res.body.message.toLowerCase()).toContain('completed'); + expect(await prisma.consultation.count({ where: { appointmentId } })).toBe(0); + }); + + it('lets the treating doctor complete the appointment', async () => { + const res = await request(app) + .patch(`/appointments/${appointmentId}/status`) + .set('Authorization', `Bearer ${owningDoctorToken}`) + .send({ status: 'completed' }); + + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(res.body.status).toBe('completed'); + }); + + it('lets the owning doctor close the appointment consultation once', async () => { + const res = await request(app) + .post(`/appointments/${appointmentId}/consultation`) + .set('Authorization', `Bearer ${owningDoctorToken}`) + .send({ outcome: 'resolved', notes: 'Reviewed results; no follow-up needed.' }); + + expect(res.status, JSON.stringify(res.body)).toBe(201); + expect(res.body.consultation).toMatchObject({ + id: expect.any(String), + appointmentId, + outcome: 'resolved', + }); + // The caller gets their own notes back in plaintext… + expect(res.body.consultation.notes).toBe('Reviewed results; no follow-up needed.'); + + // …while the column itself only ever holds the AES-GCM envelope + // (issue #46), never the clinical text. + const stored = await prisma.consultation.findUniqueOrThrow({ where: { appointmentId } }); + expect(stored.notes).toMatch(/^v1:[A-Za-z0-9+/=]+:[A-Za-z0-9+/=]+:[A-Za-z0-9+/=]+$/); + expect(stored.notes).not.toContain('Reviewed results'); + + // The close is audited, in the same transaction (Phase 8, issue #43). + const events = await prisma.auditEvent.findMany({ + where: { entityType: 'Consultation', entityId: res.body.consultation.id }, + }); + expect(events).toHaveLength(1); + expect(events[0].action).toBe('consultation.closed'); + expect(events[0].toState).toBe('resolved'); + }); + + it('refuses a second close of the same appointment', async () => { + const res = await request(app) + .post(`/appointments/${appointmentId}/consultation`) + .set('Authorization', `Bearer ${owningDoctorToken}`) + .send({ outcome: 'follow_up_needed', notes: 'Second attempt — must be rejected.' }); + + expect(res.status).toBe(409); + expect(res.body.message.toLowerCase()).toContain('already'); + }); + + it('still holds exactly one consultation row for the appointment', async () => { + const rows = await prisma.consultation.findMany({ where: { appointmentId } }); + expect(rows).toHaveLength(1); + // The rejected second attempt did not overwrite the first outcome. + expect(rows[0].outcome).toBe('resolved'); + }); + + it('refuses a second close through the /consultations address (same row)', async () => { + const res = await request(app) + .post('/consultations') + .set('Authorization', `Bearer ${owningDoctorToken}`) + .send({ appointmentId, outcome: 'resolved', notes: 'Same appointment, other address.' }); + + expect(res.status, JSON.stringify(res.body)).toBe(409); + expect(await prisma.consultation.count({ where: { appointmentId } })).toBe(1); + }); + + it('returns the decrypted notes to the treating doctor and the patient, and 403 to anyone else', async () => { + const consultationId = (await prisma.consultation.findUniqueOrThrow({ where: { appointmentId } })).id; + + const asDoctor = await request(app) + .get(`/consultations/${consultationId}`) + .set('Authorization', `Bearer ${owningDoctorToken}`); + expect(asDoctor.status).toBe(200); + expect(asDoctor.body.notes).toBe('Reviewed results; no follow-up needed.'); + + const asPatient = await request(app) + .get(`/consultations/${consultationId}`) + .set('Authorization', `Bearer ${patientToken}`); + expect(asPatient.status).toBe(200); + expect(asPatient.body.notes).toBe('Reviewed results; no follow-up needed.'); + + const asOtherDoctor = await request(app) + .get(`/consultations/${consultationId}`) + .set('Authorization', `Bearer ${otherDoctorToken}`); + expect(asOtherDoctor.status).toBe(403); + }); + + it('refuses a doctor who does not own the appointment (no existence leak)', async () => { + const res = await request(app) + .post(`/appointments/${appointmentId}/consultation`) + .set('Authorization', `Bearer ${otherDoctorToken}`) + .send({ outcome: 'resolved', notes: 'Not my appointment.' }); + + expect(res.status).toBe(404); + }); + + it('refuses a patient closing a consultation (doctor-only route)', async () => { + const res = await request(app) + .post(`/appointments/${appointmentId}/consultation`) + .set('Authorization', `Bearer ${patientToken}`) + .send({ outcome: 'resolved', notes: 'Patient attempt.' }); + + expect(res.status).toBe(403); + }); + + it('rejects an unknown outcome', async () => { + const res = await request(app) + .post(`/appointments/${appointmentId}/consultation`) + .set('Authorization', `Bearer ${owningDoctorToken}`) + .send({ outcome: 'cured', notes: 'Invalid outcome value.' }); + + expect(res.status).toBe(400); + }); +}); diff --git a/backend/vitest.config.mts b/backend/vitest.config.mts index b184413f..ee6260e2 100644 --- a/backend/vitest.config.mts +++ b/backend/vitest.config.mts @@ -16,6 +16,11 @@ export default defineConfig({ env: { JWT_ACCESS_SECRET: 'test-access-secret', JWT_REFRESH_SECRET: 'test-refresh-secret', + // Clinical free-text fields are encrypted at write time (issue #46). + // A fixed 32-byte test key keeps ciphertext deterministic across runs + // without touching the real key, which only production/dev envs hold. + FIELD_ENCRYPTION_KEY_V1: 'BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc=', + FIELD_ENCRYPTION_ACTIVE_VERSION: 'v1', // Port 5433, not 5432 — tests/integration/booking.test.mts spins up // its own throwaway Postgres cluster on this port so the suite is // self-contained (no pre-existing DB required). A distinct port diff --git a/changelog.d/176-consultation-close-once.md b/changelog.d/176-consultation-close-once.md new file mode 100644 index 00000000..c6e1a800 --- /dev/null +++ b/changelog.d/176-consultation-close-once.md @@ -0,0 +1,13 @@ +Phase 2 (issue #4): an appointment's consultation can be closed exactly +once. `Consultation` is one-to-one with its `Appointment` +(`Consultation.appointmentId` UNIQUE, `docs/adr/0009`), so "closed once" is +a database constraint (`P2002` → `409`) rather than an app-level +read-then-write check. All three writers — `POST /consultations`, +`POST /appointments/:appointmentId/consultation`, and the orders flow's +consultation step — now go through one service +(`consultationsService.createConsultation`), so note encryption +(AES-256-GCM, issue #46), the own-`completed`-appointment rule and the +single `consultation.closed` audit event apply to every address. +`outcome` stays nullable in the schema (a record can exist before +sign-off); the close route requires it at the boundary. Integration test: +`backend/tests/integration/consultation.test.mts`. diff --git a/docs/adr/0009-consultation-close-once.md b/docs/adr/0009-consultation-close-once.md new file mode 100644 index 00000000..25bb7e23 --- /dev/null +++ b/docs/adr/0009-consultation-close-once.md @@ -0,0 +1,107 @@ +# ADR 0009: Consultation is close-once, enforced by a unique appointment FK + +Status: Accepted +Date: 2026-10-20 + +## Context + +Phase 2, issue #4 — "`Consultation` record + outcome +(`resolved | referred | follow_up_needed`)". The `Consultation` table has +existed since the Phase 0 scaffold as `(id, outcome?, notes?, createdAt)` with +nothing relating it to an appointment, and nothing ever wrote to it. + +The decisions that need recording are not really about columns: + +1. **How is "a consultation can only be closed once" enforced?** The repo + already has one precedent for this class of guarantee — booking (§5.1) is + settled by a DB-level conditional `UPDATE`, not by a read-then-write check, + because "check then write" is the exact bug class the architecture was + built to avoid (ARCHITECTURE_ESSENTIALS.md hard rule). +2. **What does "closed" mean for the record?** A consultation is a clinical + record of what happened. `Prescription`/`LabOrder` are explicitly + never-mutated-after-issuance with corrections as new rows; consultation + notes are the same category of data. +3. **Do amendments exist in v1?** PRD §8 leaves "can a consultation be + corrected/amended" unresolved. The tempting move is to add + `supersedes_consultation_id` "just in case". That would be inventing a + resolution to an open question. + +## Decision + +- **`Consultation.appointmentId` is a `NOT NULL`, `UNIQUE` FK to + `Appointment`** (`ON DELETE RESTRICT` — a clinical record must not + disappear as a cascade side effect of an appointment row; the same rule + `Prescription`/`LabOrder` already follow). The unique index *is* the + close-once guarantee: two concurrent closes both reach the INSERT, exactly + one commits, the loser gets Prisma `P2002` → `409`. There is deliberately + no read-then-write pre-check in the service; the constraint is what makes + it safe, so the code has nothing to get wrong. +- **`outcome` is nullable in the schema, required at the close routes** + (`resolved | referred | follow_up_needed`, PRD FR-6). Phase 3's referral + slice opened `POST /consultations` with `outcome` optional so a record can + exist before sign-off; a **closed** record still always carries an outcome + because `closeConsultationSchema` requires it. `notes` stays nullable, + capped at 20k chars at the API boundary. +- **`notes` is encrypted on write** with the existing AES-256-GCM field + encryption (issue #46) and decrypted only for the caller who just wrote it. + ARCHITECTURE.md §2/§4.3 already list `Consultation.notes` as an encrypted + clinical field; this is the first write path, so it sets the precedent + rather than starting a plaintext one. Consequence: the column is + unsearchable by design, and environments must set + `FIELD_ENCRYPTION_KEY_V1`. +- **One writer, three addresses.** `consultationsService.createConsultation` + is the only code that inserts this row; `POST /consultations`, + `POST /appointments/:id/consultation` and the orders flow's consultation + step all delegate to it, so none of them can bypass the uniqueness, + encryption or audit rules. A consultation is recorded only against a + `completed` appointment the caller owns. +- **No amendment path in v1.** Closing twice is a `409`, full stop. When the + amendment question is actually answered (Phase 3 referral/consultation + work), the answer should be a new, reviewed migration — for example an + append-only history keyed by `supersedes_*` — not a quiet relaxation of the + unique constraint. +- **Access is doctor-only, own-appointment-only.** The consultations module + never queries `Appointment`; it asks + `appointmentsService.getAppointmentForDoctor(userId, appointmentId)`, which + returns `null` for someone else's appointment. The route answers `404` for + both "doesn't exist" and "not yours", so it never confirms the existence of + another doctor's appointment. This mirrors §5.1's rule and AGENTS.md hard + rule 4 (no cross-module repository imports). +- **Every close writes a `consultation.closed` audit event in the same + transaction** (issue #43), with `metadata: { appointmentId }` — never the + notes, which are PHI. + +## Consequences + +- The guarantee survives races and process crashes; there is no window where + two consultations exist for one appointment. +- Deleting an appointment that has a consultation is refused by the FK + (`RESTRICT`), matching the rest of the Phase 1 model (nothing clinical is + user-deletable yet). That is re-examined when the account-purge/retention + work lands, since a clinical record with retention rules should not + disappear as a cascade side effect. +- The encrypted `notes` column cannot back search, sorting or reporting. A + future "search my consultations" feature must decrypt in the app layer or + maintain a separate index — accepted, documented in §4.3. +- If `FIELD_ENCRYPTION_KEY_V1` is missing, closing a consultation fails loudly + (`500`) with the util's own "key not configured" error instead of silently + storing plaintext PHI. There is no fallback-to-plaintext path and there must + not be one. +- The column, unique index and FK are created by + `20261020000000_order_payment_intents` (issue #9, before this feature had a + writer). That migration assumes the scaffold `Consultation` table is empty + and fails loudly rather than backfilling an invented appointment. + +## Alternatives considered + +- **Check-then-write in the service only.** Rejected: the exact bug class + (two requests pass the check, both insert) the architecture exists to + prevent. +- **`status` enum on `Consultation` (`open | closed`) instead of create-on- + close.** Rejected for v1: there is no "open consultation" state to model — + the appointment is the thing that exists before the visit; a consultation is + created when it is closed. Adding an `open` state now would be modelling a + flow (intake → in-progress notes → close) that Phase 2 hasn't designed. +- **Nullable `appointmentId` + `supersedes_consultation_id` for amendments.** + Rejected: it resolves an open PRD question by accident, and a nullable + unique FK would allow orphan consultations. diff --git a/docs/api/README.md b/docs/api/README.md index 57e7286f..ce32fa28 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -12,7 +12,7 @@ Base URL: `http://localhost:4000` locally (`PORT` in `.env`). No OpenAPI spec ye | Method & path | Auth | Body | Notes | |---|---|---|---| -| `POST /auth/signup` | none | `{ role: 'patient'|'doctor', email, password, fullName }` | **`role` is restricted to `patient`/`doctor` server-side, not just in docforum-web's UI** — facility accounts are admin-invited only (PRD OQ-2). Returns `{ user, accessToken }`; sets an httpOnly refresh cookie. Stores the best supported language from `Accept-Language` (`en` fallback) as `user.locale`. | +| `POST /auth/signup` | none | `{ role: 'patient'\|'doctor', email, password, fullName }` | **`role` is restricted to `patient`/`doctor` server-side, not just in docforum-web's UI** — facility accounts are admin-invited only (PRD OQ-2). Returns `{ user, accessToken }`; sets an httpOnly refresh cookie. Stores the best supported language from `Accept-Language` (`en` fallback) as `user.locale`. | | `POST /auth/login` | none | `{ email, password }` | Same response shape as signup. **Throttled** (Phase 8, issue #35) — see below. | | `POST /auth/refresh` | refresh cookie + `X-CSRF-Token` | — | Reads the httpOnly cookie (not the body). Returns a fresh `{ user, accessToken, csrfToken }` and rotates the cookie **and** the CSRF token. `403` if the CSRF token is missing/wrong or the `Origin` isn't allowlisted. | | `POST /auth/logout` | `X-CSRF-Token` | — | Clears the refresh cookie. `204 No Content`; `403` without the CSRF header or from a disallowed `Origin`. | @@ -96,21 +96,32 @@ need one re-login. ### Login throttling (Phase 8, issue #35) `POST /auth/login` is rate-limited **per normalised email and per client -IP**, with state in Postgres (so it survives restarts and doesn't assume +IP**, with state in Postgres (so it survives restarts and doesn't assume a single instance). Bucket size is capped by the first limit reached: | Phase | Response | |---|---| | Below `LOGIN_THROTTLE_BACKOFF_AFTER` failures (default 3) in the window | normal `401 { message: 'Invalid email or password.' }` | -| At/after it | `429` with a `Retry-After: ``'header, growing exponentially (`LOGIN_THROTTLE_BACKOFF_BASE_SECONDS` × 2ʐ, capped at `LOGIN_THROTTLE_BACKOFF_MAX_SECONDS`) | +| At/after it | `429` with a `Retry-After: ` header, growing exponentially (`LOGIN_THROTTLE_BACKOFF_BASE_SECONDS` × 2ⁿ, capped at `LOGIN_THROTTLE_BACKOFF_MAX_SECONDS`) | | At `LOGIN_THROTTLE_LOCK_AFTER` failures (default 10) | `429` locked for `LOGIN_THROTTLE_LOCK_SECONDS` (default 15 min). The lock is set once per window and is not extended by further attempts | | Window (`LOGIN_THROTTLE_WINDOW_SECONDS`, default 15 min) | New window restarts the count at 1 and clears the lock | -A successful login clears the ** per-account** counter. The per-IP bucket is left alone, so one valid login can't reset a shared-IP budget. +A successful login clears the **per-account** counter. The per-IP bucket is +left alone, so one valid login can't reset a shared-IP budget. -Clients should honour `Retry-After` and must **not** treat a $429$ as "account locked permanently". The response is identical for a real and a non-existent address, and for a locked and a backing-off bucket — it deliberately reveals nothing about whether the account exists (an absent account also pays bcrypt's comparison cost, so timing doesn't leak either). +Clients should honour `Retry-After` and must **not** treat a `429` as +"account locked permanently". The response is identical for a real and a +non-existent address, and for a locked and a backing-off bucket — it +deliberately reveals nothing about whether the account exists (an absent +account also pays bcrypt's comparison cost, so timing doesn't leak either). -`POST /auth/login-attempts/clear` (admin-only) clears a lock a legitimate 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 — 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. +`POST /auth/login-attempts/clear` (admin-only) clears a lock a legitimate +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 +— 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 @@ -121,10 +132,10 @@ buckets whose last failure is older than Access tokens: `Authorization: Bearer `, 15-minute TTL, no revocation list yet (see `ARCHITECTURE_ESSENTIALS.md` known gaps — -RWT refresh rotation without a revocation list). Refresh tokens: httpOnly +JWT refresh rotation without a revocation list). Refresh tokens: httpOnly cookie, 30-day TTL, `SameSite=None; Secure` in production and -`Lax``/insecure locally — the attributes live in `config.cookie` -`backend/src/config/index.ts`) so the refresh and CSRF cookies can never +`Lax`/insecure locally — the attributes live in `config.cookie` +(`backend/src/config/index.ts`) so the refresh and CSRF cookies can never drift apart. See `docs/adr/0003` for why production needs `SameSite=None`. Every token carries a `tv` claim (the user's `tokenVersion` at issue time). @@ -142,7 +153,7 @@ against `tokenVersion` on every `POST /auth/refresh`. | `GET /doctors` | none | Query: optional `specialty` slug, typo-tolerant `q`, `limit` (1–50, default 20), and keyset `cursor`. Returns `{ items: [{ id, fullName, specialties: [{ id, name, slug }] }], nextCursor }`; only verified profiles and public fields are returned. One/two-character queries use prefix matching; longer queries use `pg_trgm` similarity. The frontend fetches slot details separately. | | `GET /doctors/me` | doctor | Returns the caller's own `DoctorProfile`. | | `PUT /doctors/me/specialties` | doctor | Body `{ specialtyIds: string[] }`; replaces the caller's specialty assignments. Unknown ids return `400`. | -| `PATCH /doctors/:doctorProfileId/verification` | admin | Body `{ status: 'pending'|'verified'|'rejected' }`. Manual, admin-gated per PRD FR-3 — there is no automated verification pipeline. | +| `PATCH /doctors/:doctorProfileId/verification` | admin | Body `{ status: 'pending'\|'verified'\|'rejected' }`. Manual, admin-gated per PRD FR-3 — there is no automated verification pipeline. | ## Specialties (`/specialties`) — `backend/src/modules/doctors` @@ -164,16 +175,41 @@ against `tokenVersion` on every `POST /auth/refresh`. | `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. Load-tested under contention (Phase 9, issue #86) — see `backend/tests/load/README.md`. | | `GET /appointments/mine` | patient | — | Caller's own appointments, most recent first. | | `GET /appointments/doctor/today` | doctor | — | Caller's scheduled appointments for the current server-local calendar day, ordered by slot start time. | -| `PATCH /appointments/:appointmentId/status` | doctor (treating doctor only) | `{ status: 'completed'|'cancelled'|'no_show' }` | Issue #3's lifecycle, and the trigger for the referral cascade (#67): if the appointment came from a referral, the referral moves in the same transaction (completed → referral completed; cancelled → referral back to `pending` and the slot released; no-show → count++, pending until `NO_SHOW_EXPIRY_THRESHOLD` (2) then `expired`). `409` unless the appointment is still `scheduled`. | +| `PATCH /appointments/:appointmentId/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` +## Consultations (`/consultations` + `/appointments`) — `backend/src/modules/consultations` + +Phase 2, issue #4 (close-once) and Phase 3's referral slice. The module owns +the `Consultation` row: **one writer**, `consultationsService.createConsultation`, +reached through three addresses — the appointment-scoped close route, the +`/consultations` create route, and the orders flow's consultation step (which +adds its own verified-doctor gate and delegates). Same guarantees regardless +of the address. | 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\lfollow_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. | +| `POST /appointments/:appointmentId/consultation` | doctor (own **completed** appointment, verified email) | `{ outcome: 'resolved' \| 'referred' \| 'follow_up_needed', notes }` | Closes the appointment's consultation. `201` returns `{ consultation, appointment }`; `404` if the appointment doesn't exist **or isn't the caller's** (no existence leak); `409` if the appointment isn't `completed` or its consultation was already closed; `400` on an unknown outcome or empty/over-20k notes. | +| `POST /consultations` | doctor (own completed appointment, verified email) | `{ appointmentId, outcome?, notes? }` | The same write with the appointment in the body — Phase 3's address for it. `201` returns the consultation. `outcome` is optional here so a record can exist before sign-off; the close route above requires it. | +| `GET /consultations/:consultationId` | doctor (treating) or patient (own visit) | — | Anyone else gets `403`; unknown id `404`. `notes` comes back decrypted. | +| `POST /consultations/:consultationId/referrals` | doctor (treating, verified email) | `{ 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, so a specialty-only target is routed at creation exactly as that row describes (issue #6). | + +Once-only is a database guarantee, not an app-level check: +`Consultation.appointmentId` is `UNIQUE`, so two concurrent closes can't both +commit — the loser gets `409`. There is no amendment/edit path in v1 (an +explicit open question, not an assumption). + +`notes` is clinical free text and is stored **encrypted** (AES-256-GCM +envelope, `v1:::` — ARCHITECTURE.md §4.3, issue #46); it is +returned in plaintext to the caller who just wrote it and to a permitted +reader. This requires `FIELD_ENCRYPTION_KEY_V1` (32 bytes, base64) and +optionally `FIELD_ENCRYPTION_ACTIVE_VERSION` (default `v1`) to be set; a +missing key is a `500` on the write, not a silent plaintext write. Encrypted +columns cannot be searched or indexed. + +Every write also records an `AuditEvent` (`consultation.closed`) in the same +transaction, so the event exists iff the consultation was written. + ## Referrals (`/referrals`) — `backend/src/modules/referrals` @@ -189,3 +225,119 @@ against `tokenVersion` on every `POST /auth/refresh`. | `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` + +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` + +Verified doctors can create a consultation with `POST /orders/consultations` (`appointmentId`, optional `outcome` and `notes`) — the same write as `POST /consultations` plus the verified-doctor gate, so all of its one-per-appointment/encryption/audit rules apply. Issue a prescription with `POST /orders/consultations/:consultationId/prescriptions` (`facilityProfileId`, positive integer `amountStroops`, and `items`), or a lab order with `POST /orders/consultations/:consultationId/lab-orders` (`facilityProfileId`, `amountStroops`, `testType`, optional `urgency` and `notes`). Issuance creates its payment intent in the same transaction. Corrections create superseding immutable orders: a created intent moves to the correction; an escrowed intent remains linked to the original and is handled by the existing admin refund action. + +## 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` (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}`, `consultation.closed`, +`prescription.issued`, `lab_order.issued`, `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 +return `409` when the intent changed state concurrently between the check +and the write (previously the second write silently overwrote the first). +## Patients (`/patients`) — `backend/src/modules/patients` + +### Data export — right of access (Phase 8, issue #40) + +| Method & path | Auth | Notes | +|---|---|---| +| `POST /patients/me/export` | patient | Starts an export of the **caller's own** data. `202` with the export status object and a `Location: /patients/me/export/:id` header. If one is already `pending`/`processing`, returns that one instead of queuing a second. No body is read — there is no way to name another patient. | +| `GET /patients/me/export/:id` | patient (owner) | Status object (below). `404` for an id that isn't the caller's — the same response as one that doesn't exist. | +| `GET /patients/me/export/:id/download` | patient (owner) | Streams the bundle as `application/json` (`Content-Disposition: attachment`, `Cache-Control: no-store`). `409 EXPORT_NOT_READY` while pending/processing/failed; `410 EXPORT_EXPIRED` at or after `expiresAt`, even before the cleanup job has run. | + +Doctors and admins get `403` on all three — there is no on-behalf-of export. + +Status object: + +```json +{ + "id": "uuid", + "status": "pending | processing | ready | failed | expired", + "schemaVersion": 1, + "createdAt": "…", "startedAt": "…", "generatedAt": "…", "expiresAt": "…", + "sizeBytes": 12345, + "failure": null, + "downloadUrl": "/patients/me/export//download" +} +``` + +`downloadUrl` and `sizeBytes` are non-null only while `ready`. A failed +export reports `failure: { code: 'SECTION_FAILED', section: '' }` (or +`GENERATION_FAILED` / `GENERATION_TIMEOUT`, with `section: null`) — request a +new export to retry. Generation is asynchronous; poll the status object. + +Bundle (`schemaVersion` 1): + +```json +{ + "format": "docforum.patient-export", + "schemaVersion": 1, + "exportId": "uuid", + "generatedAt": "ISO-8601", + "sections": { + "account": { "version": 1, "items": [{ "id", "email", "phone", "role", "emailVerifiedAt", "createdAt", "updatedAt" }] }, + "appointments": { "version": 1, "items": [{ "id", "status", "origin", "slot": { "startTime", "endTime" }, "doctor": { "id", "fullName" }, "createdAt", "updatedAt" }] }, + "payments": { "version": 1, "items": [{ "id", "prescriptionId", "labOrderId", "status", "amountStroops", "facility": { "id", "name" }, "escrowId", "stellarTxHash", "sorobanContractId", "createdAt", "updatedAt" }] }, + "profile": { "version": 1, "items": [{ "id", "fullName", "dateOfBirth", "createdAt", "updatedAt" }] } + } +} +``` + +Sections are sorted by name; new modules add sections (ARCHITECTURE.md §3.1) +without changing the envelope. `amountStroops`/`escrowId` are strings +(bigint). No password hash, tokens, throttle state or platform secrets are +ever included. + +Exports stay downloadable for `PATIENT_EXPORT_TTL_HOURS` (default 24), then +the cleanup job deletes the stored bundle. Other knobs: `PATIENT_EXPORT_PAGE_SIZE`, +`PATIENT_EXPORT_CHUNK_BYTES`, `PATIENT_EXPORT_WORKER_INTERVAL_SECONDS`, +`PATIENT_EXPORT_CLEANUP_INTERVAL_SECONDS`, `PATIENT_EXPORT_STALE_AFTER_SECONDS`, +`PATIENT_EXPORT_MAX_ATTEMPTS` — see `backend/src/config/index.ts` and +`backend/src/jobs/README.md`. + +## 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–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.