diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index f9c5b4ab..200fbe70 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,7 +1,7 @@ # ARCHITECTURE.md — DocForum Status: Draft v0.1 -Last updated: 2026-09-09 +Last updated: 2026-10-17 > **Contribution rule:** Any PR that changes a data model, a service boundary, or the tech stack must update this file in the same PR, and reflect the essentials in `ARCHITECTURE_ESSENTIALS.md`. Also update `ROADMAP.md` status if the change closes/opens roadmap work. @@ -336,6 +336,9 @@ is a no-op (issue #67, integration-tested). Prescription id, consultation_id (FK), patient_profile_id (FK), prescribing_doctor_id (FK) facility_profile_id (FK to pharmacy), amount_stroops (clinician-entered XLM total) + status (enum: issued|partially_fulfilled|fulfilled|expired|superseded) + # `partially_fulfilled` added Phase 5 (issue #73): the status is + # DERIVED from item-level fulfillment rows (see §4.7), never asserted. supersedes_prescription_id (nullable, unique self-FK), created_at # Corrections create a child row and never update the issued row. @@ -349,6 +352,9 @@ LabOrder id, consultation_id (FK), patient_profile_id (FK), ordering_doctor_id (FK) facility_profile_id (FK to lab), amount_stroops (clinician-entered XLM total) test_type, urgency (enum: routine|urgent), notes [ENCRYPTED] + status (enum: issued|partially_fulfilled|fulfilled|expired|superseded) + # `partially_fulfilled` is unreachable for a lab order — it is a + # single-item order, so issue #73's derivation is binary (§4.7). supersedes_lab_order_id (nullable, unique self-FK), created_at LabResult @@ -369,16 +375,58 @@ the original and is refunded through the existing admin refund action. ### 4.7 Fulfillment (facility-side) ``` FulfillmentRecord - id, prescription_id (FK, nullable, unique), lab_order_id (FK, nullable, unique) - facility_id (FK), status (enum: pending|in_progress|fulfilled|rejected) + id, prescription_id (FK, nullable), lab_order_id (FK, nullable) + facility_id (FK), status (enum: pending|in_progress|partially_fulfilled|fulfilled|rejected) created_at, updated_at + items: FulfillmentItem[] # Exactly one of the two order FKs is set — enforced by a PostgreSQL CHECK, - not an application guard. One row per order, enforced by the unique on - each FK column (Postgres keeps NULLs, so each is at most one non-null). + not an application guard. One row per (order, facility): the UNIQUE is on + each (order FK, facility_id) pair (Postgres keeps NULLs, so each index only + constrains the rows its own FK covers). ADR 0012 originally put the UNIQUE + on the order FK alone — issue #73's split routing (remaining items go to a + second facility) needs several records per order, so uniqueness moved down + one level; see the ADR's 2026-10-26 amendment. # All three FKs are RESTRICT: deleting an order or a facility account cannot erase the fulfillment trail (docs/adr/0010's retention rule). # Replaces the Phase 0 polymorphic (order_type, order_id) pair. Decision + reasoning: docs/adr/0012 (issue #13). + # facility_id is the OWNER: a facility may only ever write rows it owns + # (hard rule 5). `status` is derived from the FulfillmentItem rows below — + # `fulfilled` is never settable by the facility directly. It is the + # facility the patient ROUTED to, which for a split order is not the + # order's own `facility_profile_id` (issue #73). + +FulfillmentItem (Phase 5, issue #73 — item-level / partial fulfillment) + id, fulfillment_record_id (FK, cascade) + order_type (prescription|lab_order), order_item_id + # PrescriptionItem.id, or LabOrder.id itself for a lab order + # (a lab order is a single-item order) + quantity_ordered (nullable int — Phase 4 item models have no quantity + # column yet; null = "not tracked", never fake a zero) + quantity_dispensed (int, default 0) + status (enum: pending|dispensed|unfulfilled) + dispensed_key (nullable) — equals order_item_id exactly when + # status = 'dispensed', NULL otherwise. This is how the hard + # constraint "one item can never be dispensed by two facilities" is + # expressed as a plain partial unique index + # (order_type, dispensed_key): NULLs don't collide in Postgres, two + # non-NULL dispenses of the same item violate the index. A real + # `WHERE status = 'dispensed'` partial index would be equivalent SQL + # but Prisma cannot represent it in schema.prisma and `migrate diff` + # would try to drop one it finds in the database. + dispensed_at (nullable), created_at, updated_at + uniqueness: (fulfillment_record_id, order_type, order_item_id) — the + # same item can't be routed twice under one record + +Order-status derivation (issue #73): + dispensed_items == items_of_order -> order.status = fulfilled + 0 < dispensed_items < items_of_order -> order.status = partially_fulfilled + no dispensed items -> unchanged (issued) + Applied by the prescriptions/lab-orders module services (forward-only, + conditional update — an expired/superseded order is never resurrected) + called from pharmacy-orders AFTER its item rows commit. Field updates on + an issued order's lifecycle `status` are the ONE sanctioned mutation of + an issued order (§5.3 step 5); clinical content stays append-only. ``` Design note: kept separate from `Prescription`/`LabOrder` so a facility's operational status doesn't need write access to the clinical order record itself — it only writes to its own fulfillment row. Reduces blast radius of a compromised/misbehaving facility account. @@ -610,10 +658,32 @@ T5, enforced as a curated config policy. ### 5.3 Order issuance and fulfillment 1. Specialist issues `Prescription` and/or `LabOrder` from a `Consultation`. -2. Patient views eligible facilities filtered by `FacilityCapability` matching the order. -3. Patient selects a facility → `FulfillmentRecord` created (`pending`). -4. Facility updates its own `FulfillmentRecord` status; for labs, attaches `LabResult`. -5. Order's own `status` field is updated to `fulfilled` only via this facility-confirmed path — patient/doctor cannot self-mark it. +2. Patient views eligible facilities filtered by `FacilityCapability` matching the order — optionally sorted by distance from a patient-supplied point (§5.4). +3. Patient selects a facility → `FulfillmentRecord` created (`pending`) with one `FulfillmentItem` per routed item. Splitting a partially dispensed order across a second facility is the same call scoped to the remaining items (issue #73). +4. Facility updates its own `FulfillmentRecord` status; for labs, attaches `LabResult`. An item becomes `dispensed` only through `PATCH /pharmacy-orders/fulfillments/:id/items/:itemId`, a one-shot conditional update backed by the `(orderType, dispensedKey)` unique index — a second facility dispensing the same item is a constraint violation (409), even under concurrency. +5. Order's own `status` field is updated to `fulfilled` only via this facility-confirmed path — patient/doctor cannot self-mark it. Since issue #73 this includes `partially_fulfilled`, and the write happens as a post-commit derivation in `pharmacy-orders/services` through the prescriptions/lab-orders module services (rules 4 and 5): facilities never touch `Prescription`/`LabOrder` rows, pharmacy-orders never writes those tables directly, and the update is conditional and forward-only. + +**Payment release rule under partial fulfillment (issue #73 — decided):** +partial fulfillment **never** releases escrow. Release eligibility requires +the order to derive `fulfilled` — every item of the order dispensed, +wherever it was routed. A `partially_fulfilled` order holds the escrow no +matter how many items the dispensing facility already delivered. Rationale: +v1 has one `PaymentIntent` per order (ADR 0004), so releasing on partial +state would either over-pay a single facility for goods not delivered or +need a proration model that doesn't exist yet. Consequence: a patient who +walks away from the deliberately-un-routed remainder keeps the escrow held +until the admin refund path (`POST /payments/intents/:id/refund`) resolves +it. Prorated/partial release is an open question, logged in §6.2 — the +rule is "no release until the decided rule is met", and the decided rule +is *all items dispensed*. (Release itself remains admin-triggered in v1 +per ADR 0004; this rule is what makes that action's eligibility check +honest once wired.) + +### 5.4 Facility discovery by distance (Phase 5, issue #74) +1. Patient calls `GET /facilities/eligible?capabilityType=…&near=lat,lng&radiusKm=…`. +2. The repository applies an **indexed bounding box** prefilter (`(latitude, longitude)` composite index) that is provably a superset of the radius circle, then computes exact haversine distance in SQL over those rows and sorts ascending (`NULLS LAST` — facilities without coordinates stay discoverable, just last). +3. The patient's `near` point never leaves the request: it is only ever a bind parameter in that statement — not stored, not logged, not echoed back (the response carries per-facility `distanceKm` only). +4. **Why not PostGIS:** it needs an extension on every deployment (CI's throwaway DBs, the Render free-tier preview, bare local Postgres) to order one column, which is exactly the ceremony §6.3 warns against; bounding-box + haversine is exact here because the box is a superset. Measured in `backend/tests/integration/facility-distance.test.mts` (p50 ≈ 5 ms over ~3 000 facilities). ### 5.4 Mutable-record optimistic concurrency (Phase 8, issue #49) Slot booking (§5.1) is protected by a conditional update, but nothing else was: a @@ -663,7 +733,7 @@ class of bug, and **new mutable-resource routes must follow it.** 1. Naive slot booking without the DB-level conditional update in §5.1 — the most likely first bug filed against this repo. **Resolved 2026-09-14:** implemented exactly as designed in §5.1 — see `backend/src/modules/availability/repositories/index.ts` (`claimOpenSlot`) and `backend/src/modules/appointments/services/index.ts` (`bookSlot`, the transaction wrapping it). Integration-tested with real concurrency in `backend/tests/integration/booking.test.mts`. 2. `Referral.target_doctor_id` left nullable with no fallback assignment logic — referrals to "a specialty" with no specific doctor need a real routing rule (round robin? load-based?) or they'll pile up unassigned. **Resolved 2026-10-21 (Phase 3, issue #6 — `docs/adr/0008-referral-routing-without-target-doctor.md`):** the column stays **required** and the fallback logic now exists at creation — a specialty-only target is routed to the **least-loaded eligible doctor** (verified, holds the specialty, not the referring doctor, active account; fewest `pending`+`accepted` referrals, ties by doctor id) inside the creation transaction. Zero eligible doctors fails the call with `422 REFERRAL_NO_ELIGIBLE_TARGET` instead of writing an unowned row, so the pile-up this item predicts remains impossible. Still deliberately absent: a patient-facing "waiting for a specialist" state (it requires a nullable target), availability-aware routing (needs doctor-side availability/discovery), and a per-doctor "accepting referrals" switch — all deferred in the ADR with revisit triggers. 3. Polymorphic `FulfillmentRecord.order_id` (§4.7) — polymorphic FKs are a known footgun for referential integrity in Postgres. Needs either two separate tables (`PrescriptionFulfillment`, `LabOrderFulfillment`) or a check-constraint + application-level guard. **Current schema sketch is a simplification that should be revisited before implementation**, not treated as final. **Resolved 2026-10-22 (Phase 5, issue #13 — `docs/adr/0012-fulfillment-record-typed-order-fks.md`):** neither option exactly as posed — `FulfillmentRecord` stays **one table**, but the `(order_type, order_id)` pair is gone. It is replaced by two real foreign keys (`prescription_id`, `lab_order_id`, each `UNIQUE`) with a PostgreSQL `CHECK` requiring exactly one, plus the `facility_id` FK the sketch left as a TODO. Referential integrity is now the database's job, and the CHECK only chooses between two already-validated columns — which is what an `order_type`+`order_id` guard could never do, since Postgres cannot make a bare `TEXT` conditionally reference one of two tables. Keeping one table (rather than splitting) is what keeps §5.3 step 5 a single transition implementation and the facility's assigned-orders queue a single indexed query; the discriminator mechanism is the one `PaymentIntent` already uses (issue #85). Deferred by that decision rather than invented around: re-routing an order to a different facility after a `rejected` fulfillment would need a partial unique index, so in v1 a `rejected` row is terminal for that order — a corrected order is a new `supersedes_*_id` row and gets its own fulfillment. -4. JWT refresh rotation without a revocation list — a stolen refresh token has no clean kill switch until this is added. + Issue #73's `FulfillmentItem` rows hang off this same single table (`fulfillment_record_id` FK), so item-level fulfillment inherits the ADR 0012 shape instead of re-opening it — only `FulfillmentItem.order_type`/`order_item_id` keep a discriminator, and those name an item inside the record's own order, not a second polymorphic reference.4. JWT refresh rotation without a revocation list — a stolen refresh token has no clean kill switch until this is added. 5. **Discovered 2026-10-15 while building email verification (Phase 8, issue #34):** the email/SMS provider is still undecided (Phase 6, issue #17), so verification links can't actually be delivered in a hosted environment yet — `backend/src/modules/auth/services/email-dispatcher.ts` is a no-op seam until a provider is integrated. Until then `backend/scripts/mark-email-verified.mjs` is the documented out-of-band path for an operator with DB access. Not a reason to skip the guard: the guard is what makes the flow safe once delivery exists, and shipping it locked-off would just mean shipping it untested. +6. **Resolved 2026-10-19 (issue #49):** silent last-write-wins on every mutable record that wasn't the slot claim — a doctor in one tab and an admin in another, or two tabs editing one appointment, each wrote and the second erased the first with no signal to either. §5.4 now makes the compare and the write one conditional statement behind an `If-Match` precondition. **Still open:** the convention is only *enforced* where a route opts in, so a future mutable route that forgets `requireIfMatch` silently opts out — there is no lint or schema-level way to catch that yet, and ESLint itself is still untracked (issue #28). diff --git a/ROADMAP.md b/ROADMAP.md index 1da940a1..da34274a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -78,12 +78,14 @@ and issue links. - [x] Facility fulfillment proof & patient confirmation/dispute workflow. Tracked as issue #76. Facility fulfillment requires reference number and optional attachment. Patient confirm-receipt/dispute are mutually exclusive via DB conditional update. Derived `confirmedSignal` boolean. Idempotent expiry job for unconfirmed orders. Admin dispute queue and append-only resolution logs. ## Phase 5 — Facilities & fulfillment -**Status: Not started (blocked by Phase 4). Scoped into issues #13–#16.** +**Status: In progress — #13 (typed `FulfillmentRecord` order FKs, ADR 0012) and #14 (admin-only facility creation + fail-closed capability matching) are done; #15 (facility fulfillment UI/transition) and #16 (lab result attachment) are still open. Two independently-scoped items added later — #73 (partial fulfillment) and #74 (facility distance sorting) — have landed ahead of them, built on ADR 0012's `FulfillmentRecord` shape.** - [x] Resolve the polymorphic `FulfillmentRecord` design smell flagged in ARCHITECTURE.md §6.1.3 **before** building this phase — decide: keep polymorphic with constraints, or split into `PrescriptionFulfillment` / `LabOrderFulfillment`. Record the decision as an ADR in `docs/adr/`. Tracked as [issue #13](https://github.com/DocForum/docforum-core/issues/13) (High, 200 pts) — the prerequisite for the rest of this phase. **Done — decision: one table, no polymorphism** (`docs/adr/0012-fulfillment-record-typed-order-fks.md`): `order_type`/`order_id` are replaced by real `prescription_id`/`lab_order_id` FKs (each `UNIQUE`, a PostgreSQL `CHECK` requires exactly one — the mechanism `PaymentIntent` got in #85) plus the `facility_id` FK §4.7 left as a TODO, all three `RESTRICT`. Splitting was weighed and rejected: two tables mean two implementations of the §5.3 step-5 transition and a cross-table union for a facility's queue. Schema + migration landed; no code reads the row yet — #14 creates it, #15 owns the transition, #16 attaches `LabResult` through its existing table. - [x] Facility account creation (admin-invited only per PRD OQ-2 — no self-serve facility signup in v1) + `FacilityCapability` matching (patient sees only qualified facilities for their specific order, PRD FR-13). Tracked as [issue #14](https://github.com/DocForum/docforum-core/issues/14) (Medium, 150 pts) — depends on #13. **Done —** admin-only `POST /facilities` (User + profile + capabilities + `facility.created` audit row, one transaction; `POST /auth/signup` still refuses the role) and fail-closed capability matching in `GET /facilities/eligible` + `listEligibleFacilitiesForOrderKind`: facilities missing/mismatching capability data show for nothing. ADR 0012's "`#14 creates the row at selection`" is **not** in this issue — no selection endpoint exists yet; the `FulfillmentRecord` write and the `→ fulfilled` transition land with #15. See `changelog.d/14-facility-creation-capability-matching.md`. - [ ] Facility fulfillment UI: view assigned orders, mark in-progress/fulfilled/rejected, enforcing that `status` transitions to `fulfilled` **only** via the facility-confirmed path (ARCHITECTURE.md §5.3 step 5 — patient/doctor cannot self-mark). Tracked as [issue #15](https://github.com/DocForum/docforum-core/issues/15) (Medium, 150 pts) — depends on #13/#14. - [x] Lab result attachment (object storage integration — ARCHITECTURE.md §2 File/result storage row). Tracked as [issue #16](https://github.com/DocForum/docforum-core/issues/16) (Medium, 150 pts) — depends on #13. **Done —** provider: Amazon S3, private SSE bucket (`docs/adr/0014`), behind `utils/object-storage.ts`. `POST /orders/lab-orders/:labOrderId/result` lets only the order's own facility attach, once. `GET` on the same path serves the file to the patient, the ordering doctor and the fulfilling facility only, with no public or presigned URL. The response is access-logged and audited. The `LabOrder` and its `FulfillmentRecord` are untouched (`fulfilled` stays #15's). New scope noted in ARCHITECTURE.md §6.2.6: the export doesn't include the file bytes, and guardians can't read results. See `changelog.d/16-lab-result-attachment.md`. - [ ] Address PRD §8.1.6 (facility fulfillment integrity) — at minimum, restrict which facilities can be selected to the curated/admin-invited list; document what's still unresolved if full anti-fraud isn't in scope for v1 — addressed by issue #14's admin-only gating; document any remaining gap in that issue's PR. +- [x] **Partial fulfillment: item-level dispensing for multi-item prescriptions.** Tracked as [issue #73](https://github.com/DocForum/docforum-core/issues/73) — **Done**. `FulfillmentItem` rows under a `FulfillmentRecord` now carry dispensed quantity + status per routed item; order status derives from items (`partially_fulfilled` / `fulfilled`) via a forward-only conditional update applied through the prescriptions/lab-orders module services; the patient can route remaining items to a second facility; a DB unique index on `(orderType, dispensedKey)` makes double-dispense by two facilities a constraint-level 409 (proven under concurrency in `backend/tests/integration/fulfillment.test.mts`). Payment-release rule documented in ARCHITECTURE.md §5.3: no release until the whole order derives `fulfilled`. Re-seated onto #13's resolved ADR 0012 shape (typed order FKs, not the old polymorphic pair); the ADR's "one record per order" UNIQUE became one per **(order, facility)** so a split order can hold a record at each facility — see the ADR's 2026-10-26 amendment. +- [x] **Facility location and distance-sorted facility selection.** Tracked as [issue #74](https://github.com/DocForum/docforum-core/issues/74) — **Done**. `FacilityProfile.address/latitude/longitude` (nullable) + composite index; `GET /facilities/eligible?near=lat,lng&radiusKm=` with an indexed bounding box + haversine (PostGIS rejected — justification and the privacy/no-storage rule in ARCHITECTURE.md §5.4); facilities without coordinates appear, sorted last; seeded fictional coordinates via `npx prisma db seed`; measured over ~3 000 facilities in `backend/tests/integration/facility-distance.test.mts` (single-digit-millisecond p50/p95, well inside the test's 500 ms ceiling). ## Phase 5.5 — Payments (consumer side) **Status: Payment order linkage, automatic intent issuance, configuration hardening, and platform signer abstraction are implemented; fulfillment-triggered release remains pending.** See docs/adr/0004-custodial-payments-v1.md. Verified against **live testnet**, not mocked — `npm run test:integration:payments-testnet` (3/3 passing): fund→release, fund→refund, and reject-when-no-wallet-linked. diff --git a/backend/prisma/migrations/20261017061200_phase5_partial_fulfillment_and_facility_location/migration.sql b/backend/prisma/migrations/20261017061200_phase5_partial_fulfillment_and_facility_location/migration.sql new file mode 100644 index 00000000..2d3414e1 --- /dev/null +++ b/backend/prisma/migrations/20261017061200_phase5_partial_fulfillment_and_facility_location/migration.sql @@ -0,0 +1,63 @@ +-- Phase 5 (issues #73 + #74): item-level fulfillment and facility location. +-- +-- Deliberately adds ONLY what no other migration creates. When this branch +-- was merged with main, two later migrations turned out to own the columns +-- this one originally also added, and `migrate deploy` failed on the second +-- definition (P3018 / 42701: column already exists): +-- +-- * `PrescriptionItem.prescriptionId` (+ its index and FK) is added by +-- `20261020000000_order_payment_intents`. +-- * `FulfillmentRecord.facilityId` (+ its index and FK) and the drop of +-- the polymorphic `orderType`/`orderId` pair belong to +-- `20261022090000_fulfillment_record_typed_order_fks` (ADR 0012), which +-- runs after this one and expects to add them itself. +-- +-- So the schema-side work those two own stays with them; this file keeps the +-- enum values, the `FulfillmentItem` table and the facility location columns +-- that only Phase 5 introduces. + +-- CreateEnum +CREATE TYPE "FulfillmentItemStatus" AS ENUM ('pending', 'dispensed', 'unfulfilled'); + +-- AlterEnum +ALTER TYPE "FulfillmentStatus" ADD VALUE 'partially_fulfilled'; + +-- AlterEnum +ALTER TYPE "OrderStatus" ADD VALUE 'partially_fulfilled'; + +-- AlterTable +ALTER TABLE "FacilityProfile" ADD COLUMN "address" TEXT, +ADD COLUMN "latitude" DOUBLE PRECISION, +ADD COLUMN "longitude" DOUBLE PRECISION; + +-- CreateTable +CREATE TABLE "FulfillmentItem" ( + "id" TEXT NOT NULL, + "fulfillmentRecordId" TEXT NOT NULL, + "orderType" TEXT NOT NULL, + "orderItemId" TEXT NOT NULL, + "quantityOrdered" INTEGER, + "quantityDispensed" INTEGER NOT NULL DEFAULT 0, + "status" "FulfillmentItemStatus" NOT NULL DEFAULT 'pending', + "dispensedKey" TEXT, + "dispensedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "FulfillmentItem_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "FulfillmentItem_orderType_orderItemId_idx" ON "FulfillmentItem"("orderType", "orderItemId"); + +-- CreateIndex +CREATE UNIQUE INDEX "FulfillmentItem_fulfillmentRecordId_orderType_orderItemId_key" ON "FulfillmentItem"("fulfillmentRecordId", "orderType", "orderItemId"); + +-- CreateIndex +CREATE UNIQUE INDEX "FulfillmentItem_orderType_dispensedKey_key" ON "FulfillmentItem"("orderType", "dispensedKey"); + +-- CreateIndex +CREATE INDEX "FacilityProfile_latitude_longitude_idx" ON "FacilityProfile"("latitude", "longitude"); + +-- AddForeignKey +ALTER TABLE "FulfillmentItem" ADD CONSTRAINT "FulfillmentItem_fulfillmentRecordId_fkey" FOREIGN KEY ("fulfillmentRecordId") REFERENCES "FulfillmentRecord"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/backend/prisma/migrations/20261026000000_fulfillment_record_per_order_facility/migration.sql b/backend/prisma/migrations/20261026000000_fulfillment_record_per_order_facility/migration.sql new file mode 100644 index 00000000..7ded84b3 --- /dev/null +++ b/backend/prisma/migrations/20261026000000_fulfillment_record_per_order_facility/migration.sql @@ -0,0 +1,28 @@ +-- Amendment to ADR 0012 (docs/adr/0012-fulfillment-record-typed-order-fks.md), +-- driven by issue #73 (item-level partial fulfillment). +-- +-- ADR 0012 put a UNIQUE on each order FK — "one fulfillment per order, +-- permanently". Issue #73's split routing requires the patient to route the +-- items one facility could not dispense to a SECOND facility, as "a new +-- fulfillment record for those items only", so one order legitimately owns +-- several records: one per facility that has been asked to dispense part of +-- it. +-- +-- The guarantee ADR 0012 was actually buying — no duplicated fulfillment of +-- the same order at the same facility — survives as a composite UNIQUE on +-- (order FK, facility). The per-column UNIQUE is dropped here rather than +-- invented around; a `rejected` record still cannot be swapped for another +-- record at the same facility, and re-routing to a *different* facility is +-- exactly issue #73's documented split flow. + +-- DropIndex +DROP INDEX "FulfillmentRecord_prescriptionId_key"; + +-- DropIndex +DROP INDEX "FulfillmentRecord_labOrderId_key"; + +-- CreateIndex +CREATE UNIQUE INDEX "FulfillmentRecord_prescriptionId_facilityId_key" ON "FulfillmentRecord"("prescriptionId", "facilityId"); + +-- CreateIndex +CREATE UNIQUE INDEX "FulfillmentRecord_labOrderId_facilityId_key" ON "FulfillmentRecord"("labOrderId", "facilityId"); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 5bf344ca..56f87b66 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -91,6 +91,12 @@ enum ReferralExpiryReason { enum OrderStatus { issued + // Phase 5 (issue #73): order-level status is DERIVED from item-level + // fulfillment rows, so a partially dispensed multi-item prescription + // doesn't have to lie ('fulfilled') or refuse the whole order. Written + // only by this order's own module service after a fulfillment item + // changes — never by a facility directly (hard rule 5). + partially_fulfilled fulfilled expired superseded @@ -109,7 +115,14 @@ enum DisputeStatus { enum FulfillmentStatus { pending in_progress + // Phase 5 (issue #73): every item under this record was dispensed. fulfilled + // Every item under this record reached a terminal state, at least one + // was dispensed, and at least one could not be dispensed. The order as + // a whole may still reach `fulfilled` if the patient routes the + // remaining items to a second facility. + partially_fulfilled + // Facility rejected the whole record without dispensing anything. rejected } @@ -496,6 +509,14 @@ model FacilityProfile { userId String @unique name String type FacilityType + // Phase 5 (issue #74): physical location so patients can find the + // nearest qualified facility (PRD FR-13). All three nullable on + // purpose — a facility that hasn't filled them in still appears in + // eligible-facility queries, just sorted last (see + // facilities/repositories). + address String? + latitude Float? + longitude Float? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -514,6 +535,13 @@ model FacilityProfile { prices FacilityPrice[] orders Order[] fulfillmentProofs FulfillmentProof[] + + // Composite b-tree index backing the "indexed bounding box" prefilter + // of the distance query (issue #74). Chosen over PostGIS: no extension + // dependency on every deployment (incl. the Render free-tier preview), + // and a bounding box + haversine is exact for the ORDER BY once the box + // is a superset of the radius. See ARCHITECTURE.md §5.4. + @@index([latitude, longitude]) } model FacilityCapability { @@ -876,7 +904,9 @@ model Prescription { correctedBy Prescription? @relation("PrescriptionCorrections") items PrescriptionItem[] paymentIntent PaymentIntent? - fulfillment FulfillmentRecord? + // One order → many fulfillment records (issue #73 split routing: one per + // facility the patient routes items to). + fulfillments FulfillmentRecord[] @@unique([supersedesPrescriptionId]) @@index([patientProfileId, createdAt]) @@ -919,7 +949,7 @@ model LabOrder { correctedBy LabOrder? @relation("LabOrderCorrections") result LabResult? paymentIntent PaymentIntent? - fulfillment FulfillmentRecord? + fulfillments FulfillmentRecord[] @@unique([supersedesLabOrderId]) @@index([patientProfileId, createdAt]) @@ -963,8 +993,8 @@ model LabResult { /// facility writes. model FulfillmentRecord { id String @id @default(uuid()) - prescriptionId String? @unique - labOrderId String? @unique + prescriptionId String? + labOrderId String? facilityId String status FulfillmentStatus @default(pending) createdAt DateTime @default(now()) @@ -973,7 +1003,19 @@ model FulfillmentRecord { prescription Prescription? @relation(fields: [prescriptionId], references: [id], onDelete: Restrict) labOrder LabOrder? @relation(fields: [labOrderId], references: [id], onDelete: Restrict) facility FacilityProfile @relation(fields: [facilityId], references: [id], onDelete: Restrict) - + // Phase 5 (issue #73): one row per routed order item (issue #73). + items FulfillmentItem[] + + // ADR 0012 required at most one record per order (a UNIQUE on each order + // FK). Issue #73's split routing — patient routes the items one facility + // couldn't dispense to a *second* facility — needs several records for + // one order, so the uniqueness moved down one level: at most one record + // per (order, facility), which is what actually prevents a duplicated + // routing. NULLs don't collide in Postgres, so each index only constrains + // the rows whose order FK it covers — same mechanism ADR 0012 relied on. + // See docs/adr/0012 amendment, 2026-10-26. + @@unique([prescriptionId, facilityId]) + @@unique([labOrderId, facilityId]) @@index([facilityId, status]) } diff --git a/backend/src/app.ts b/backend/src/app.ts index 030a109b..0ae3d663 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -14,8 +14,8 @@ import { metricsRouter } from './modules/metrics/routes'; import { pharmacyOrdersRouter } from './modules/pharmacy-orders/routes'; import { labOrdersRouter } from './modules/lab-orders/routes'; import { paymentsRouter } from './modules/payments/routes'; -import { patientsRouter } from './modules/patients/routes'; import { facilitiesRouter } from './modules/facilities/routes'; +import { patientsRouter } from './modules/patients/routes'; import { adminRouter } from './modules/users/routes'; import { registerPatientExportSections } from './export-sections'; import { auditRouter, adminAuditRouter, patientAuditRouter } from './modules/audit/routes'; @@ -82,8 +82,10 @@ export function createApp() { app.use('/admin', adminOrdersRouter); app.use('/admin', metricsRouter); app.use('/payments', paymentsRouter); - app.use('/patients', patientsRouter); + // Phase 5 (issues #73/#74): /facilities and /pharmacy-orders mounts + // (/pharmacy-orders is mounted above, with the order routes). app.use('/facilities', facilitiesRouter); + app.use('/patients', patientsRouter); // Phase 8, issue #39 — admin account lifecycle (invites, deactivate/ // reactivate) alongside the audit read path. Different paths, no overlap. app.use('/admin', adminRouter); diff --git a/backend/src/modules/facilities/controllers/index.ts b/backend/src/modules/facilities/controllers/index.ts index 665c21a9..fe76c74f 100644 --- a/backend/src/modules/facilities/controllers/index.ts +++ b/backend/src/modules/facilities/controllers/index.ts @@ -37,6 +37,10 @@ function serializeEligible(facility: facilitiesService.EligibleFacility) { facilityId: facility.facilityId, name: facility.name, type: facility.type, + // Issue #74: address + per-facility distance. The patient's query + // point is never part of this payload. + address: facility.address, + distanceKm: facility.distanceKm, priceOnRequest: facility.priceOnRequest, price: facility.price ? { diff --git a/backend/src/modules/facilities/repositories/index.ts b/backend/src/modules/facilities/repositories/index.ts index 363357a7..dc2d08d4 100644 --- a/backend/src/modules/facilities/repositories/index.ts +++ b/backend/src/modules/facilities/repositories/index.ts @@ -2,8 +2,13 @@ // from here directly; go through services/. Reads that the payments module // needs inside its own transaction take a Prisma client so the price snapshot // and the intent write commit (or roll back) together. +// +// The distance query (issue #74) lives here: the patient's `near` +// coordinates are used as bind parameters and nothing else — never stored, +// never echoed back. import { prisma } from '../../../db/prisma-client'; -import type { CapabilityType, FacilityType, Prisma } from '@prisma/client'; +import { Prisma } from '@prisma/client'; +import type { CapabilityType, FacilityType } from '@prisma/client'; /** Any Prisma client: the shared one, or a transaction's. */ export type Db = Prisma.TransactionClient | typeof prisma; @@ -82,17 +87,143 @@ export function createPriceVersion( return db.facilityPrice.create({ data }); } +// --- Eligible-facility query (issues #14 + #74) --------------------------- + +export interface EligibleFacilityRow { + id: string; + name: string; + type: FacilityType; + address: string | null; + distanceKm: number | null; +} + +export interface EligibleFacilityFilters { + /** Facility kind (issue #14) — omit to search every kind. */ + type?: FacilityType; + /** Capability the facility must hold (fail closed: no capability row, + * no result). The service derives it from `type` when only that is given. */ + capabilityType?: CapabilityType; + detail?: string; + /** Patient's query point — bind parameters only, never stored (issue #74). */ + near?: { lat: number; lng: number }; + radiusKm?: number; + limit: number; +} + +/** Mean km per degree of latitude — slightly less than the equatorial + * 110.574 so the bounding box is always a superset of the true radius + * circle. */ +const KM_PER_DEG_LAT = 110.574; +/** Max km per degree of longitude (at the equator) — using the maximum + * makes the longitude box a superset at every latitude. */ +const KM_PER_DEG_LNG = 111.32; +/** Earth radius, km. Same value the SQL haversine below uses. */ +const EARTH_RADIUS_KM = 6371; + /** - * Facilities qualified for an order kind (issue #14): the facility's type - * *and* a matching `FacilityCapability` row are both required — a facility - * with no capability data is never returned (fail closed, PRD FR-13/§8.1.6). + * Qualified facilities, optionally sorted by distance from `near` + * (PRD FR-13 / issue #74). + * + * Why an indexed bounding box + in-SQL haversine and not PostGIS: + * Postgres without the PostGIS extension (the Render free-tier preview, + * any bare local DB, `prisma migrate deploy` on CI) can't rely on + * geometry types, and adding an extension to every deployment for one + * ORDER BY is exactly the kind of infra ceremony ARCHITECTURE.md §6.3 + * warns against. The composite index on (latitude, longitude) bounds the + * scan to the ~box rows; haversine over those rows is exact, so the + * result is identical to a true-radius query — the box only has to be a + * superset of the circle, which the constants above guarantee. + * + * Facilities WITHOUT coordinates are never excluded by the spatial + * filter — they appear, sorted last (issue #74 edge case). NULL sorts + * last via `NULLS LAST`. */ -export function listQualifiedFacilityProfiles(type: FacilityType, capabilityType: CapabilityType, db: Db = prisma) { - return db.facilityProfile.findMany({ - where: { type, capabilities: { some: { capabilityType } } }, - select: { id: true, name: true, type: true }, - orderBy: { name: 'asc' }, - }); +export function listEligibleFacilityProfiles( + filters: EligibleFacilityFilters, +): Promise { + const type = filters.type ?? null; + const capabilityType = filters.capabilityType ?? null; + const detail = filters.detail ?? null; + + const typeFilter = Prisma.sql`( + ${type}::text IS NULL + OR fp."type"::text = ${type}::text + )`; + + const capabilityFilter = Prisma.sql`( + ${capabilityType}::text IS NULL + OR EXISTS ( + SELECT 1 FROM "FacilityCapability" fc + WHERE fc."facilityProfileId" = fp."id" + AND fc."capabilityType"::text = ${capabilityType} + AND (${detail}::text IS NULL OR lower(fc."detail") = lower(${detail}::text)) + ) + )`; + + if (!filters.near) { + return prisma.$queryRaw(Prisma.sql` + SELECT + fp."id", fp."name", fp."type"::text AS "type", fp."address", + NULL::float8 AS "distanceKm" + FROM "FacilityProfile" fp + WHERE ${typeFilter} AND ${capabilityFilter} + ORDER BY fp."name" ASC + LIMIT ${filters.limit} + `); + } + + const { lat, lng } = filters.near; + const radiusKm = filters.radiusKm ?? null; + // NOTE (Prisma 7 / driver adapters): bound params travel with an + // unspecified type OID, so Postgres does the type inference. Expressions + // like `${lat} - ${boxLat}` are `unknown - unknown` — an operator Postgres + // refuses as ambiguous (42725). So: the box bounds are arithmetically + // precomputed here in JS, and every numeric param that reaches the SQL + // carries an explicit `::float8`. + // Bounding-box half-extents in degrees. Guarded against division by + // ~0 near the poles (box just becomes enormous — still a superset). + const boxLat = (radiusKm ?? 0) / KM_PER_DEG_LAT; + const boxLng = (radiusKm ?? 0) / Math.max(Math.abs(Math.cos((lat * Math.PI) / 180)), 1e-9) / KM_PER_DEG_LNG; + + // When no radius is given the box can't be prefiltered either — the + // full scan still computes exact distances and sorts, which is fine at + // the scale this v1 targets (thousands of facilities, measured in the + // integration test). + const boxFilter = + radiusKm === null + ? Prisma.empty + : Prisma.sql`AND ( + fp."latitude" IS NULL OR fp."longitude" IS NULL + OR ( + fp."latitude" BETWEEN ${lat - boxLat}::float8 AND ${lat + boxLat}::float8 + AND fp."longitude" BETWEEN ${lng - boxLng}::float8 AND ${lng + boxLng}::float8 + ) + )`; + + const radiusFilter = + radiusKm === null ? Prisma.empty : Prisma.sql`WHERE "distanceKm" IS NULL OR "distanceKm" <= ${radiusKm}::float8`; + + return prisma.$queryRaw(Prisma.sql` + WITH scored AS ( + SELECT + fp."id", fp."name", fp."type"::text AS "type", fp."address", + CASE + WHEN fp."latitude" IS NULL OR fp."longitude" IS NULL THEN NULL + ELSE 2 * ${EARTH_RADIUS_KM}::float8 * asin(least(1.0, sqrt( + power(sin(radians(fp."latitude" - ${lat}::float8) / 2), 2) + + cos(radians(${lat}::float8)) * cos(radians(fp."latitude")) + * power(sin(radians(fp."longitude" - ${lng}::float8) / 2), 2) + ))) + END AS "distanceKm" + FROM "FacilityProfile" fp + WHERE ${typeFilter} AND ${capabilityFilter} + ${boxFilter} + ) + SELECT * FROM scored + ${radiusFilter} + ORDER BY "distanceKm" ASC NULLS LAST, "name" ASC + LIMIT ${filters.limit} + `); } // --- Account creation (issue #14, admin-only) ----------------------------- diff --git a/backend/src/modules/facilities/services/index.ts b/backend/src/modules/facilities/services/index.ts index cba146f7..ce643fbd 100644 --- a/backend/src/modules/facilities/services/index.ts +++ b/backend/src/modules/facilities/services/index.ts @@ -10,6 +10,9 @@ // (issue #14, PRD OQ-2 / FR-13): facilities are curated, never // self-serve, and a patient only ever sees facilities qualified for // their order type. +// 4. the eligible list can be distance-sorted from a patient-supplied +// point (issue #74) — the point is a bind parameter and nothing else: +// never stored, never logged, never echoed back. import { prisma } from '../../../db/prisma-client'; import { HttpError } from '../../../http-error'; import * as auditService from '../../audit/services'; @@ -31,6 +34,12 @@ const MAX_CAPABILITY_DETAIL_LENGTH = 100; /** Mirrors auth signup's minimum — an admin-set initial password must not * be weaker than a self-chosen one. */ const MIN_FACILITY_PASSWORD_LENGTH = 8; +/** Default search radius when `near` is given but `radiusKm` isn't — a + * city-scale default rather than "sort everything in the country" + * (issue #74). Only applies when a point was given at all. */ +export const DEFAULT_RADIUS_KM = 50; +/** Row cap for the eligible query when the caller doesn't pick one. */ +const DEFAULT_LIMIT = 50; export const DEFAULT_PRICE_ASSET = NATIVE_PRICE_ASSET; @@ -191,6 +200,14 @@ export interface EligibleFacility { facilityId: string; name: string; type: 'lab' | 'pharmacy'; + /** Registered street address, or null when the facility hasn't set one + * (issue #74 — it still appears in the list). */ + address: string | null; + /** Straight-line km from the caller's `near` point, or null when no + * point was given or the facility registered no coordinates. Those rows + * still appear, sorted last (issue #74 edge case). The patient's own + * point is never part of the response — only per-facility distances. */ + distanceKm: number | null; /** Current price in smallest units, or null → "price on request". */ price: { amount: bigint; effectiveFrom: Date } | null; priceOnRequest: boolean; @@ -262,26 +279,60 @@ export async function requireFacilityForOrderKind(db: Db, facilityProfileId: str * concern PRD §8.1.6 names; hiding an under-documented one is merely an * operator fix. */ +/** + * Facilities qualified for a query — capability matching (issue #14, PRD + * FR-13), optionally distance-sorted from the patient's point (issue #74), + * each with the price in force at `at` for the optional `catalogItemCode` + * (issue #127). + * + * Fail closed (PRD §8.1.6): a facility appears only if it matches the + * qualification filter — of `type` *and* holding that type's capability, or + * of the explicitly asked `capabilityType`. A facility with no capability + * rows, or only rows of the wrong capability type (malformed data), shows + * for nothing: hiding an under-documented facility is an operator fix, + * showing an unqualified one is the fraud risk the PRD names. + * + * Privacy (issue #74): `near` lives on this call stack and nothing else. + * It is never persisted, never written to a log line, and never echoed + * back — the response carries per-facility `distanceKm` only. + */ export async function listEligibleFacilities(input: { - type: 'lab' | 'pharmacy'; + type?: 'lab' | 'pharmacy'; + capabilityType?: CapabilityType; + detail?: string; + near?: { lat: number; lng: number }; + radiusKm?: number; + limit?: number; catalogItemCode?: string; asset?: string; at?: Date; }): Promise { const at = input.at ?? new Date(); - const facilities = await facilitiesRepo.listQualifiedFacilityProfiles( - input.type, - capabilityForFacilityType(input.type), - ); - if (!input.catalogItemCode) { - return facilities.map((facility) => ({ - facilityId: facility.id, - name: facility.name, - type: facility.type, - price: null, - priceOnRequest: true, - })); - } + // A caller that names only the facility type gets the capability that + // type exists to provide — one map, so creation and matching can't drift. + const capabilityType = + input.capabilityType ?? (input.type ? capabilityForFacilityType(input.type) : undefined); + + const facilities = await facilitiesRepo.listEligibleFacilityProfiles({ + type: input.type, + capabilityType, + detail: input.detail, + near: input.near, + radiusKm: input.near ? (input.radiusKm ?? DEFAULT_RADIUS_KM) : undefined, + limit: input.limit ?? DEFAULT_LIMIT, + }); + + const located = facilities.map((facility) => ({ + facilityId: facility.id, + name: facility.name, + type: facility.type, + address: facility.address, + distanceKm: facility.distanceKm === null ? null : Number(facility.distanceKm.toFixed(2)), + price: null as { amount: bigint; effectiveFrom: Date } | null, + priceOnRequest: true, + })); + + if (!input.catalogItemCode) return located; const catalogItemCode = assertCatalogItemCode(input.catalogItemCode); const asset = assertAsset(input.asset); @@ -297,18 +348,16 @@ export async function listEligibleFacilities(input: { if (!byFacility.has(row.facilityId)) byFacility.set(row.facilityId, row); } - return facilities.map((facility) => { - const row = byFacility.get(facility.id); + return located.map((facility) => { + const row = byFacility.get(facility.facilityId); + if (!row) return facility; return { - facilityId: facility.id, - name: facility.name, - type: facility.type, - price: row ? { amount: row.amount, effectiveFrom: row.effectiveFrom } : null, - priceOnRequest: !row, + ...facility, + price: { amount: row.amount, effectiveFrom: row.effectiveFrom }, + priceOnRequest: false, }; }); } - /** * The matching query in the issue's terms: *given an order's kind*, return * only the facilities qualified to fulfill it (PRD FR-13). Cross-module diff --git a/backend/src/modules/facilities/validation.ts b/backend/src/modules/facilities/validation.ts index 86269654..992c3e04 100644 --- a/backend/src/modules/facilities/validation.ts +++ b/backend/src/modules/facilities/validation.ts @@ -1,5 +1,18 @@ import { z } from 'zod'; +/** `?near=lat,lng` — a patient-supplied point. Used in-memory as query + * parameters only: never persisted, never logged (issue #74 hard + * constraint). */ +const nearSchema = z + .string() + .regex(/^-?\d+(?:\.\d+)?,-?\d+(?:\.\d+)?$/, 'near must be "lat,lng", e.g. "6.5244,3.3792".') + .transform((value) => { + const [lat, lng] = value.split(',').map(Number); + return { lat, lng }; + }) + .refine((point) => point.lat >= -90 && point.lat <= 90, 'near latitude must be between -90 and 90.') + .refine((point) => point.lng >= -180 && point.lng <= 180, 'near longitude must be between -180 and 180.'); + /** `effectiveFrom` accepts any ISO-8601 string the Date constructor parses; * the service rejects garbage so the error is a 400, not a 500. */ export const createPriceVersionSchema = z.object({ @@ -16,12 +29,37 @@ export const priceFiltersSchema = z.object({ asset: z.string().min(1).max(120).optional(), }); -export const eligibleFacilitiesQuerySchema = z.object({ - type: z.enum(['lab', 'pharmacy']), - catalogItemCode: z.string().min(1).max(100).optional(), - asset: z.string().min(1).max(120).optional(), - at: z.string().min(1).optional(), -}); +/** + * GET /facilities/eligible — the one patient-facing discovery query. + * + * Two families of filter, both qualification (issue #14 fails closed: at + * least one of `type` / `capabilityType` is required, so an unqualified + * directory can never be listed) plus the optional distance sort of issue + * #74. `type` is the facility kind; `capabilityType` is narrower and + * carries an optional exact `detail`. + */ +export const eligibleFacilitiesQuerySchema = z + .object({ + type: z.enum(['lab', 'pharmacy']).optional(), + catalogItemCode: z.string().min(1).max(100).optional(), + asset: z.string().min(1).max(120).optional(), + at: z.string().min(1).optional(), + capabilityType: z.enum(['lab_test', 'medication_dispense']).optional(), + detail: z.string().min(1).max(200).optional(), + near: nearSchema.optional(), + radiusKm: z.coerce.number().positive().max(20000).optional(), + limit: z.coerce.number().int().min(1).max(200).optional(), + }) + .refine((query) => query.type !== undefined || query.capabilityType !== undefined, { + message: 'type or capabilityType is required.', + path: ['type'], + }) + .refine((query) => query.radiusKm === undefined || query.near !== undefined, { + message: 'radiusKm requires near=lat,lng.', + path: ['radiusKm'], + }); + +export type EligibleFacilitiesQuery = z.infer; export type CreatePriceVersionBody = z.infer; diff --git a/backend/src/modules/lab-orders/repositories/index.ts b/backend/src/modules/lab-orders/repositories/index.ts index 9a36a9db..1b10100a 100644 --- a/backend/src/modules/lab-orders/repositories/index.ts +++ b/backend/src/modules/lab-orders/repositories/index.ts @@ -1,3 +1,30 @@ +// lab-orders repositories — LabOrder data access only. Cross-module +// callers (pharmacy-orders) must go through ../services, never import +// this file — see AGENTS.md hard rule 4. +import { prisma } from '../../../db/prisma-client'; +import type { OrderStatus } from '@prisma/client'; + +export function findLabOrderById(id: string) { + return prisma.labOrder.findUnique({ where: { id } }); +} + +/** + * Conditional, forward-only lifecycle update driven by item-level + * fulfillment (issue #73) — same shape as prescriptions' + * `applyOrderStatusIf`: allowed current statuses are in the WHERE clause, + * so an `expired`/`superseded` order can never be walked back into a live + * state, and concurrent derivations can't downgrade each other. + */ +export function applyOrderStatusIf( + id: string, + allowedFrom: OrderStatus[], + to: OrderStatus, +): Promise { + return prisma.labOrder + .updateMany({ where: { id, status: { in: allowedFrom } }, data: { status: to } }) + .then((result) => result.count === 1); +} + export { findOrderById, createOrder, diff --git a/backend/src/modules/lab-orders/services/index.ts b/backend/src/modules/lab-orders/services/index.ts index 25bf9384..d64729c0 100644 --- a/backend/src/modules/lab-orders/services/index.ts +++ b/backend/src/modules/lab-orders/services/index.ts @@ -1,13 +1,39 @@ +// lab-orders services — the only surface other modules may use to read or +// lifecycle-update a LabOrder (AGENTS.md hard rule 4). +// +// A lab order is a single-item order: its fulfillment "item" is the order +// itself (orderItemId = LabOrder.id in FulfillmentItem), so its derived +// status is binary — issued → fulfilled, no partially_fulfilled middle +// state. See ARCHITECTURE.md §5.3. import { prisma } from '../../../db/prisma-client'; import { HttpError } from '../../../http-error'; import { storageService } from '../../../utils/storage'; import { record, type AuditActor } from '../../audit/services'; +import type { OrderStatus } from '@prisma/client'; import * as repo from '../repositories'; import { formatOrderResponse, type FulfillOrderInput, } from '../../pharmacy-orders/services'; +export async function getLabOrder(id: string) { + const labOrder = await repo.findLabOrderById(id); + if (!labOrder) throw new HttpError(404, 'Lab order not found.'); + return labOrder; +} + +/** + * Applies the fulfillment-derived order status. Called by pharmacy-orders + * AFTER its own item rows have been written — a facility never writes a + * LabOrder row directly (hard rule 5). + */ +export async function applyFulfillmentStatus( + labOrderId: string, + status: Extract, +): Promise { + return repo.applyOrderStatusIf(labOrderId, ['issued', 'partially_fulfilled'], status); +} + export async function createLabOrder(data: { patientProfileId?: string; orderingDoctorId?: string; diff --git a/backend/src/modules/pharmacy-orders/controllers/index.ts b/backend/src/modules/pharmacy-orders/controllers/index.ts index bbd0beb8..2d356705 100644 --- a/backend/src/modules/pharmacy-orders/controllers/index.ts +++ b/backend/src/modules/pharmacy-orders/controllers/index.ts @@ -1,10 +1,68 @@ import type { Request, Response } from 'express'; +import { HttpError } from '../../../http-error'; +import { getParam } from '../../../request-params'; import { auditActor } from '../../../middleware/request-id'; -import * as services from '../services'; +import * as pharmacyOrdersService from '../services'; +import { + createFulfillmentSchema, + orderFulfillmentStatusQuerySchema, + updateFulfillmentItemSchema, + updateFulfillmentRecordSchema, +} from '../validation'; + +function requireUser(req: Request): pharmacyOrdersService.Requester { + if (!req.user) throw new HttpError(401, 'Authentication required.'); + return { userId: req.user.id, role: req.user.role }; +} + +// --- Issue #73: item-level (partial) fulfillment ------------------------- + +export async function createFulfillment(req: Request, res: Response) { + const input = createFulfillmentSchema.parse(req.body); + const record = await pharmacyOrdersService.createFulfillment(input, requireUser(req)); + res.status(201).json(record); +} + +export async function getFulfillment(req: Request, res: Response) { + const record = await pharmacyOrdersService.getFulfillment(getParam(req, 'id'), requireUser(req)); + res.status(200).json(record); +} + +export async function updateFulfillmentRecord(req: Request, res: Response) { + const input = updateFulfillmentRecordSchema.parse(req.body); + const record = await pharmacyOrdersService.updateFulfillmentRecord( + getParam(req, 'id'), + input, + requireUser(req), + ); + res.status(200).json(record); +} + +export async function updateFulfillmentItem(req: Request, res: Response) { + const input = updateFulfillmentItemSchema.parse(req.body); + const result = await pharmacyOrdersService.updateFulfillmentItem( + getParam(req, 'id'), + getParam(req, 'itemId'), + input, + requireUser(req), + ); + res.status(200).json(result); +} + +export async function getOrderFulfillmentStatus(req: Request, res: Response) { + const { orderType } = orderFulfillmentStatusQuerySchema.parse(req.query); + const summary = await pharmacyOrdersService.getOrderFulfillmentSummary( + orderType, + getParam(req, 'orderId'), + ); + res.status(200).json(summary); +} + +// --- Pharmacy order creation / fulfillment proof ------------------------ export async function createPharmacyOrder(req: Request, res: Response) { const { patientProfileId, prescribingDoctorId, details } = req.body || {}; - const order = await services.createPharmacyOrder({ + const order = await pharmacyOrdersService.createPharmacyOrder({ patientProfileId, prescribingDoctorId, details, @@ -17,7 +75,7 @@ export async function fulfillPharmacyOrder(req: Request, res: Response) { const { referenceNumber, attachment, notes } = req.body || {}; const actor = auditActor(req); - const order = await services.fulfillPharmacyOrder( + const order = await pharmacyOrdersService.fulfillPharmacyOrder( id, req.user!.id, { referenceNumber, attachment, notes }, @@ -28,6 +86,6 @@ export async function fulfillPharmacyOrder(req: Request, res: Response) { export async function getPharmacyOrder(req: Request, res: Response) { const id = req.params.id as string; - const order = await services.getPharmacyOrderById(id); + const order = await pharmacyOrdersService.getPharmacyOrderById(id); res.status(200).json(order); } diff --git a/backend/src/modules/pharmacy-orders/repositories/index.ts b/backend/src/modules/pharmacy-orders/repositories/index.ts index 70106b73..c175919a 100644 --- a/backend/src/modules/pharmacy-orders/repositories/index.ts +++ b/backend/src/modules/pharmacy-orders/repositories/index.ts @@ -1,4 +1,109 @@ -import type { OrderType, Prisma } from '@prisma/client'; +// pharmacy-orders repositories — FulfillmentRecord / FulfillmentItem data +// access only. Facilities write ONLY through these paths (hard rule 5): +// never to Prescription.status / LabOrder.status directly. +import { prisma } from '../../../db/prisma-client'; +import type { FulfillmentStatus, OrderType, Prisma } from '@prisma/client'; + +type DbClient = typeof prisma | Prisma.TransactionClient; + +/** Reads FacilityProfile directly — a Phase 1 core identity model, not + * something the (still-placeholder) facilities module exclusively owns. + * (Same precedent as payments/repositories.) */ +export function findFacilityProfileByUserId(userId: string) { + return prisma.facilityProfile.findUnique({ where: { userId } }); +} + +export function findFacilityProfileById(id: string) { + return prisma.facilityProfile.findUnique({ where: { id } }); +} + +export function findFulfillmentRecordById(id: string) { + return prisma.fulfillmentRecord.findUnique({ + where: { id }, + include: { items: { orderBy: { createdAt: 'asc' } }, facility: { select: { id: true, name: true } } }, + }); +} + +/** Item ids of this order (across ALL facilities' records) that were + * already dispensed — used to reject routing an item a second time. */ +export function listDispensedOrderItemIds(orderType: string, orderItemIds: string[]) { + if (orderItemIds.length === 0) return Promise.resolve([]); + return prisma.fulfillmentItem + .findMany({ + where: { orderType, orderItemId: { in: orderItemIds }, status: 'dispensed' }, + select: { orderItemId: true }, + }) + .then((rows) => rows.map((row) => row.orderItemId)); +} + +export function countDispensedOrderItems(orderType: string, orderItemIds: string[]) { + if (orderItemIds.length === 0) return Promise.resolve(0); + return prisma.fulfillmentItem.count({ + where: { orderType, orderItemId: { in: orderItemIds }, status: 'dispensed' }, + }); +} + +export function createFulfillmentRecord( + db: DbClient, + data: { + orderType: 'prescription' | 'lab_order'; + orderId: string; + facilityId: string; + items: { orderType: string; orderItemId: string; quantityOrdered: number | null }[]; + }, +) { + return db.fulfillmentRecord.create({ + data: { + // Typed order FKs (docs/adr/0012): exactly one of the two is set — + // the record still addresses one order, it just does so through the + // real relation instead of an (orderType, orderId) string pair. + prescriptionId: data.orderType === 'prescription' ? data.orderId : null, + labOrderId: data.orderType === 'lab_order' ? data.orderId : null, + facilityId: data.facilityId, + items: { create: data.items }, + }, + include: { items: { orderBy: { createdAt: 'asc' } } }, + }); +} + +/** + * One-shot conditional transition of a routed item to a terminal state — + * `UPDATE ... WHERE status = 'pending'`, atomic at the DB level, exactly + * like booking's claimOpenSlot. Two concurrent updates of the SAME row + * can never both win. + * + * For dispenses, `dispensedKey` (= orderItemId) additionally trips the + * (orderType, dispensedKey) unique index if another facility's record + * holds a dispense of the same order item — that race is caught by the + * constraint itself, not by application timing. Callers should map the + * resulting P2002 to a 409. + */ +export function claimPendingItem( + db: DbClient, + itemId: string, + fulfillmentRecordId: string, + data: + | { status: 'dispensed'; dispensedKey: string; quantityDispensed: number; dispensedAt: Date } + | { status: 'unfulfilled' }, +) { + return db.fulfillmentItem + .updateMany({ + where: { id: itemId, fulfillmentRecordId, status: 'pending' }, + data, + }) + .then((result) => result.count === 1); +} + +export function updateRecordStatus(db: DbClient, id: string, status: FulfillmentStatus) { + return db.fulfillmentRecord.update({ where: { id }, data: { status } }); +} + +export function listRecordItems(db: DbClient, fulfillmentRecordId: string) { + return db.fulfillmentItem.findMany({ + where: { fulfillmentRecordId }, + orderBy: { createdAt: 'asc' }, + }); +} export async function findOrderById(tx: Prisma.TransactionClient, orderId: string, orderType?: OrderType) { return tx.order.findFirst({ diff --git a/backend/src/modules/pharmacy-orders/routes/index.ts b/backend/src/modules/pharmacy-orders/routes/index.ts index dfee5807..ebf66bdd 100644 --- a/backend/src/modules/pharmacy-orders/routes/index.ts +++ b/backend/src/modules/pharmacy-orders/routes/index.ts @@ -1,9 +1,58 @@ import { Router } from 'express'; -import { authenticate, requireRole } from '../../../middleware/auth'; -import * as controllers from '../controllers'; +import { authenticate, requireRole, requireVerifiedEmail } from '../../../middleware/auth'; +import * as pharmacyOrdersController from '../controllers'; export const pharmacyOrdersRouter = Router(); -pharmacyOrdersRouter.post('/', authenticate, controllers.createPharmacyOrder); -pharmacyOrdersRouter.get('/:id', authenticate, controllers.getPharmacyOrder); -pharmacyOrdersRouter.post('/:id/fulfill', authenticate, requireRole('facility'), controllers.fulfillPharmacyOrder); +// Patient (or admin) routes order items to a facility — one record per +// route; the partial-fulfillment split is the same call with only the +// remaining items. Verified email required (Phase 8, issue #34): routing +// drives payment release eligibility (ARCHITECTURE.md §5.3). +pharmacyOrdersRouter.post( + '/fulfillments', + authenticate, + requireRole('patient', 'admin'), + requireVerifiedEmail, + pharmacyOrdersController.createFulfillment, +); + +// Owning facility or admin. A patient reading their order's fulfillment +// is blocked on Phase 4 linking patients to orders — documented gap, +// ARCHITECTURE.md §6.2. +pharmacyOrdersRouter.get('/fulfillments/:id', authenticate, pharmacyOrdersController.getFulfillment); + +// Facility-only writes to its own records (hard rule 5 — the ownership +// check lives in the service). `fulfilled` is never settable here: it is +// derived from the record's dispensed items (issue #73). +pharmacyOrdersRouter.patch( + '/fulfillments/:id', + authenticate, + requireRole('facility'), + requireVerifiedEmail, + pharmacyOrdersController.updateFulfillmentRecord, +); +pharmacyOrdersRouter.patch( + '/fulfillments/:id/items/:itemId', + authenticate, + requireRole('facility'), + requireVerifiedEmail, + pharmacyOrdersController.updateFulfillmentItem, +); + +// Derived order-level fulfillment state (issued | partially_fulfilled | +// fulfilled) — computed from item rows, never a hand-set flag. +pharmacyOrdersRouter.get( + '/orders/:orderId/fulfillment-status', + authenticate, + pharmacyOrdersController.getOrderFulfillmentStatus, +); + +// Generic pharmacy order (Order model) creation and fulfillment proof. +pharmacyOrdersRouter.post('/', authenticate, pharmacyOrdersController.createPharmacyOrder); +pharmacyOrdersRouter.get('/:id', authenticate, pharmacyOrdersController.getPharmacyOrder); +pharmacyOrdersRouter.post( + '/:id/fulfill', + authenticate, + requireRole('facility'), + pharmacyOrdersController.fulfillPharmacyOrder, +); diff --git a/backend/src/modules/pharmacy-orders/services/index.ts b/backend/src/modules/pharmacy-orders/services/index.ts index 7f870316..428529ea 100644 --- a/backend/src/modules/pharmacy-orders/services/index.ts +++ b/backend/src/modules/pharmacy-orders/services/index.ts @@ -1,9 +1,299 @@ +// pharmacy-orders services — item-level (partial) fulfillment, issue #73. +// +// A prescription with three items often gets partially dispensed: one drug +// is out of stock. Item-level fulfillment rows (FulfillmentItem) under a +// FulfillmentRecord let the patient get what's available now and route the +// rest elsewhere, and the order-level status is DERIVED from those items +// (all dispensed → fulfilled; some → partially_fulfilled) instead of a +// pharmacy having to lie or refuse the whole order. +// +// Hard rules in play: +// - Rule 4: Prescription/LabOrder rows are only lifecycle-updated via the +// prescriptions/lab-orders module services, never written here. +// - Rule 5: a facility writes only its OWN FulfillmentRecord/FulfillmentItem +// rows — enforced in `requireOwnedRecord` before any write. +// - "A single item can't be dispensed by two facilities" is a DB-level +// constraint (unique index on (orderType, dispensedKey)), not app timing. +import { Prisma } from '@prisma/client'; +import type { FulfillmentItem, FulfillmentStatus, Role } from '@prisma/client'; import { prisma } from '../../../db/prisma-client'; import { HttpError } from '../../../http-error'; import { storageService, type UploadAttachmentOptions } from '../../../utils/storage'; import { computeConfirmedSignal, formatOrderResponse } from '../../../utils/order-utils'; -import { record, type AuditActor } from '../../audit/services'; +import { record as recordAudit, type AuditActor } from '../../audit/services'; import * as repo from '../repositories'; +import * as prescriptionsService from '../../prescriptions/services'; +import * as labOrdersService from '../../lab-orders/services'; + +export type OrderType = 'prescription' | 'lab_order'; +export type DerivedOrderStatus = 'issued' | 'partially_fulfilled' | 'fulfilled'; + +export interface Requester { + userId: string; + role: Role; +} + +interface OrderItemInfo { + id: string; + label: string; + quantityOrdered: number | null; +} + +/** Item universe of the order (issue #73's derivation denominator). */ +async function getOrderItems(orderType: OrderType, orderId: string): Promise { + if (orderType === 'prescription') { + return prescriptionsService.getItemsForFulfillment(orderId); + } + const labOrder = await labOrdersService.getLabOrder(orderId); + return [{ id: labOrder.id, label: labOrder.testType, quantityOrdered: null }]; +} + +/** The caller's own FacilityProfile id, or null for non-facility roles. */ +async function getOwnFacilityId(requester: Requester): Promise { + if (requester.role !== 'facility') return null; + const profile = await repo.findFacilityProfileByUserId(requester.userId); + return profile?.id ?? null; +} + +/** Hard rule 5: a facility can only ever touch records it owns. */ +async function requireOwnedRecord(recordId: string, requester: Requester) { + const record = await repo.findFulfillmentRecordById(recordId); + if (!record) throw new HttpError(404, 'Fulfillment record not found.'); + if (requester.role === 'admin') return record; + const facilityId = await getOwnFacilityId(requester); + if (!facilityId || record.facilityId !== facilityId) { + throw new HttpError(403, 'This fulfillment record belongs to a different facility.'); + } + return record; +} + +/** deriveRecordStatus — see ARCHITECTURE.md §5.3. */ +function deriveRecordStatus(items: Pick[]): FulfillmentStatus { + const dispensed = items.filter((item) => item.status === 'dispensed').length; + const terminal = items.every((item) => item.status !== 'pending'); + if (items.length > 0 && dispensed === items.length) return 'fulfilled'; + if (terminal && dispensed > 0) return 'partially_fulfilled'; + if (terminal) return 'rejected'; + return dispensed > 0 || items.some((item) => item.status !== 'pending') ? 'in_progress' : 'pending'; +} + +/** + * Recomputes the ORDER-level status from every dispensed item across all + * facilities' records for this order, and applies it through the owning + * order module (hard rule 4/5). Runs after the fulfillment transaction has + * committed, so it reads committed state only. + */ +async function applyOrderStatus(orderType: OrderType, orderId: string): Promise { + if (orderType === 'prescription') { + const items = await prescriptionsService.getItemsForFulfillment(orderId); + const dispensed = await repo.countDispensedOrderItems( + 'prescription', + items.map((item) => item.id), + ); + if (dispensed === items.length) { + await prescriptionsService.applyFulfillmentStatus(orderId, 'fulfilled'); + return 'fulfilled'; + } + if (dispensed > 0) { + await prescriptionsService.applyFulfillmentStatus(orderId, 'partially_fulfilled'); + return 'partially_fulfilled'; + } + return 'issued'; + } + // A lab order is a single-item order — its fulfillment item IS the order. + const dispensed = await repo.countDispensedOrderItems('lab_order', [orderId]); + if (dispensed > 0) { + await labOrdersService.applyFulfillmentStatus(orderId, 'fulfilled'); + return 'fulfilled'; + } + return 'issued'; +} + +/** + * Patient (or admin) routes order items to a facility: one new + * FulfillmentRecord + one FulfillmentItem per routed item. The split flow + * of issue #73 is this same call with `orderItemIds` limited to the + * items that could not be dispensed elsewhere. + */ +export async function createFulfillment( + input: { orderType: OrderType; orderId: string; facilityId: string; orderItemIds?: string[] }, + requester: Requester, +) { + if (requester.role !== 'patient' && requester.role !== 'admin') { + throw new HttpError(403, 'You do not have permission to perform this action.'); + } + const facility = await repo.findFacilityProfileById(input.facilityId); + if (!facility) throw new HttpError(404, 'Facility not found.'); + + const orderItems = await getOrderItems(input.orderType, input.orderId); + const requestedIds = input.orderItemIds ?? orderItems.map((item) => item.id); + + const duplicates = requestedIds.filter((id, index) => requestedIds.indexOf(id) !== index); + if (duplicates.length > 0) { + throw new HttpError(400, `Duplicate items in request: ${[...new Set(duplicates)].join(', ')}.`); + } + const knownIds = new Set(orderItems.map((item) => item.id)); + const unknownIds = requestedIds.filter((id) => !knownIds.has(id)); + if (unknownIds.length > 0) { + throw new HttpError(400, `These items do not belong to this ${input.orderType}: ${unknownIds.join(', ')}.`); + } + + const alreadyDispensed = await repo.listDispensedOrderItemIds(input.orderType, requestedIds); + if (alreadyDispensed.length > 0) { + throw new HttpError( + 409, + `These items have already been dispensed and can't be routed again: ${alreadyDispensed.join(', ')}.`, + ); + } + + const quantityById = new Map(orderItems.map((item) => [item.id, item.quantityOrdered])); + return repo.createFulfillmentRecord(prisma, { + orderType: input.orderType, + orderId: input.orderId, + facilityId: input.facilityId, + items: requestedIds.map((orderItemId) => ({ + orderType: input.orderType, + orderItemId, + quantityOrdered: quantityById.get(orderItemId) ?? null, + })), + }); +} + +/** + * Facility marks one of ITS routed items dispensed or unfulfilled — the + * only path to `dispensed` that exists (issue #73). One-shot: a terminal + * item can never change again. + */ +export async function updateFulfillmentItem( + recordId: string, + itemId: string, + input: { status: 'dispensed' | 'unfulfilled'; quantityDispensed?: number }, + requester: Requester, +) { + const record = await requireOwnedRecord(recordId, requester); + if (requester.role !== 'facility') { + // Dispensing is the facility's own confirmation (§5.3 step 5) — an + // admin may inspect records but must not dispense on a facility's + // behalf. + throw new HttpError(403, 'Only the owning facility can update fulfillment items.'); + } + if (record.status === 'rejected') { + throw new HttpError(409, 'This fulfillment record was rejected and can no longer be updated.'); + } + const item = record.items.find((candidate) => candidate.id === itemId); + if (!item) throw new HttpError(404, 'This item is not routed under this fulfillment record.'); + if (item.status !== 'pending') { + throw new HttpError(409, `This item was already marked ${item.status}.`); + } + const quantityDispensed = + input.status === 'dispensed' ? (input.quantityDispensed ?? item.quantityOrdered ?? 1) : 0; + if (item.quantityOrdered !== null && quantityDispensed > item.quantityOrdered) { + throw new HttpError(400, 'quantityDispensed exceeds the quantity the order asked for.'); + } + + const claimed = await prisma + .$transaction(async (tx) => { + try { + const ok = await repo.claimPendingItem(tx, itemId, recordId, + input.status === 'dispensed' + ? { + status: 'dispensed', + dispensedKey: item.orderItemId, + quantityDispensed, + dispensedAt: new Date(), + } + : { status: 'unfulfilled' }, + ); + if (!ok) return false; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') { + // The (orderType, dispensedKey) unique index fired: another + // facility's record already dispensed this order item. The DB + // constraint is what makes this race-safe — not app timing. + throw new HttpError(409, 'This item has already been dispensed by another facility.'); + } + throw error; + } + const items = await repo.listRecordItems(tx, recordId); + await repo.updateRecordStatus(tx, recordId, deriveRecordStatus(items)); + return true; + }) + .catch((error) => { + if (error instanceof HttpError) throw error; + if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') { + throw new HttpError(409, 'This item has already been dispensed by another facility.'); + } + throw error; + }); + + if (!claimed) throw new HttpError(409, 'This item was already updated by the facility.'); + + // Typed order FKs (docs/adr/0012): exactly one of prescriptionId / + // labOrderId is set, which is the record's order reference. + const orderType: OrderType = record.prescriptionId ? 'prescription' : 'lab_order'; + const orderId = record.prescriptionId ?? record.labOrderId; + if (!orderId) throw new HttpError(500, 'Fulfillment record has no order reference.'); + const orderStatus = await applyOrderStatus(orderType, orderId); + return { + record: await repo.findFulfillmentRecordById(recordId), + orderStatus, + }; +} + +/** Facility moves its own record pending → in_progress, or rejects it + * wholesale (no items dispensed). `fulfilled` is deliberately not + * settable — it's derived from dispensed items. */ +export async function updateFulfillmentRecord( + recordId: string, + input: { status: 'in_progress' | 'rejected' }, + requester: Requester, +) { + const record = await requireOwnedRecord(recordId, requester); + if (requester.role !== 'facility') { + throw new HttpError(403, 'Only the owning facility can update fulfillment records.'); + } + if (input.status === 'rejected' && record.items.some((item) => item.status === 'dispensed')) { + throw new HttpError(409, 'Items were already dispensed under this record — it can only be marked partially fulfilled.'); + } + const updated = await prisma.$transaction(async (tx) => { + const claimed = await tx.fulfillmentRecord + .updateMany({ + where: { id: recordId, status: { in: ['pending', 'in_progress'] } }, + data: { status: input.status }, + }) + .then((result) => result.count === 1); + if (!claimed) return null; + return repo.findFulfillmentRecordById(recordId); + }); + if (!updated) throw new HttpError(409, 'This fulfillment record has already reached a terminal state.'); + return updated; +} + +/** Read a record with its item rows. Owner facility or admin only — a + * patient's access to their own order's fulfillment is blocked on Phase 4 + * linking PatientProfile to orders (documented gap, ARCHITECTURE.md §6.2). */ +export async function getFulfillment(recordId: string, requester: Requester) { + return requireOwnedRecord(recordId, requester); +} + +/** Derived order-level fulfillment state WITHOUT writing it back (read + * path for the patient/doctor UI). */ +export async function getOrderFulfillmentSummary( + orderType: OrderType, + orderId: string, +): Promise<{ orderType: OrderType; orderId: string; totalItems: number; dispensedItems: number; status: DerivedOrderStatus }> { + const items = await getOrderItems(orderType, orderId); + const dispensedItems = + orderType === 'prescription' + ? await repo.countDispensedOrderItems( + 'prescription', + items.map((item) => item.id), + ) + : await repo.countDispensedOrderItems('lab_order', [orderId]); + const status: DerivedOrderStatus = + dispensedItems === items.length ? 'fulfilled' : dispensedItems > 0 ? 'partially_fulfilled' : 'issued'; + return { orderType, orderId, totalItems: items.length, dispensedItems, status }; +} export { computeConfirmedSignal, formatOrderResponse }; @@ -97,7 +387,7 @@ export async function fulfillPharmacyOrder( notes: input.notes, }); - await record(tx, actor, { + await recordAudit(tx, actor, { entityType: 'Order', entityId: orderId, action: 'order.fulfilled', diff --git a/backend/src/modules/pharmacy-orders/validation.ts b/backend/src/modules/pharmacy-orders/validation.ts new file mode 100644 index 00000000..eb3bd3fa --- /dev/null +++ b/backend/src/modules/pharmacy-orders/validation.ts @@ -0,0 +1,37 @@ +import { z } from 'zod'; + +export const createFulfillmentSchema = z.object({ + orderType: z.enum(['prescription', 'lab_order']), + orderId: z.string().min(1), + facilityId: z.string().min(1), + // Which items of the order to route under this record. Omit to route + // every not-yet-dispensed item (the common case). Issue #73's split + // flow passes only the remaining items here, creating a second record + // for the same order at a different facility. + orderItemIds: z.array(z.string().min(1)).min(1).optional(), +}); + +export const updateFulfillmentItemSchema = z + .object({ + // `pending` → `dispensed` | `unfulfilled`. A terminal item can never + // change again (one-shot, enforced in the service via a conditional + // update, same shape as booking's claimOpenSlot). + status: z.enum(['dispensed', 'unfulfilled']), + // Required when dispensing an item whose quantity isn't tracked by + // the order (always, until Phase 4 items grow a quantity field). + quantityDispensed: z.number().int().min(1).optional(), + }) + .refine((value) => value.status === 'unfulfilled' || (value.quantityDispensed ?? 0) > 0, { + message: 'quantityDispensed (a positive integer) is required when status is "dispensed".', + path: ['quantityDispensed'], + }); + +export const updateFulfillmentRecordSchema = z.object({ + // `fulfilled` is deliberately NOT settable: it is derived from the + // record's dispensed items (issue #73), never asserted by the facility. + status: z.enum(['in_progress', 'rejected']), +}); + +export const orderFulfillmentStatusQuerySchema = z.object({ + orderType: z.enum(['prescription', 'lab_order']), +}); diff --git a/backend/src/modules/prescriptions/repositories/index.ts b/backend/src/modules/prescriptions/repositories/index.ts index bdf158ca..29da5d4b 100644 --- a/backend/src/modules/prescriptions/repositories/index.ts +++ b/backend/src/modules/prescriptions/repositories/index.ts @@ -1,3 +1,36 @@ -// prescriptions repositories — placeholder. Data access only. Not to be imported -// by other modules directly — go through services/ instead. -export {}; +// prescriptions repositories — Prescription/PrescriptionItem data access +// only. Cross-module callers (pharmacy-orders) must go through +// ../services, never import this file — see AGENTS.md hard rule 4. +import { prisma } from '../../../db/prisma-client'; +import type { OrderStatus } from '@prisma/client'; + +export function findPrescriptionById(id: string) { + return prisma.prescription.findUnique({ where: { id } }); +} + +export function listItems(prescriptionId: string) { + // No orderBy: PrescriptionItem has no created_at (ARCHITECTURE.md §4.5), + // and the item universe is order-insensitive for the derivation. + return prisma.prescriptionItem.findMany({ + where: { prescriptionId }, + select: { id: true, drugName: true }, + }); +} + +/** + * Conditional, forward-only lifecycle update driven by item-level + * fulfillment (issue #73). Same shape as availability's `claimOpenSlot`: + * the allowed current statuses are part of the WHERE clause, so this can + * never resurrect an `expired`/`superseded` order and can never race a + * concurrent derivation into a downgrade. Returns whether a row actually + * transitioned. + */ +export function applyOrderStatusIf( + id: string, + allowedFrom: OrderStatus[], + to: OrderStatus, +): Promise { + return prisma.prescription + .updateMany({ where: { id, status: { in: allowedFrom } }, data: { status: to } }) + .then((result) => result.count === 1); +} diff --git a/backend/src/modules/prescriptions/services/index.ts b/backend/src/modules/prescriptions/services/index.ts index adba388d..20cb384e 100644 --- a/backend/src/modules/prescriptions/services/index.ts +++ b/backend/src/modules/prescriptions/services/index.ts @@ -1,3 +1,51 @@ -// prescriptions services — placeholder. Other modules must only import from here, -// never from repositories/ directly. See AGENTS.md "Non-negotiable rules". -export {}; +// prescriptions services — the only surface other modules may use to read +// or lifecycle-update a Prescription (AGENTS.md hard rule 4). +import { HttpError } from '../../../http-error'; +import * as prescriptionsRepo from '../repositories'; +import type { OrderStatus } from '@prisma/client'; + +export async function getPrescription(id: string) { + const prescription = await prescriptionsRepo.findPrescriptionById(id); + if (!prescription) throw new HttpError(404, 'Prescription not found.'); + return prescription; +} + +export interface PrescriptionFulfillmentItem { + id: string; + label: string; + quantityOrdered: number | null; +} + +/** The item universe of a prescription — what "all items fulfilled" in + * issue #73's order-status derivation is measured against. */ +export async function getItemsForFulfillment(prescriptionId: string): Promise { + await getPrescription(prescriptionId); + const items = await prescriptionsRepo.listItems(prescriptionId); + if (items.length === 0) { + throw new HttpError(400, 'This prescription has no items to fulfill.'); + } + // quantityOrdered stays null until the Phase 4 item models grow a + // quantity field — flagged, not faked. See schema.prisma + // FulfillmentItem.quantityOrdered. + return items.map((item) => ({ id: item.id, label: item.drugName, quantityOrdered: null })); +} + +/** + * Applies the fulfillment-derived order status (issue #73: "all items + * fulfilled → fulfilled; some → partially_fulfilled"). Called by + * pharmacy-orders AFTER its own item rows have been written — a facility + * never writes a Prescription row directly (hard rule 5), and this module + * never reaches into pharmacy-orders (hard rule 4). + * + * Forward-only: `partially_fulfilled` may only be applied from `issued`, + * `fulfilled` only from `issued`/`partially_fulfilled`, so an `expired` or + * `superseded` order can't be walked backwards into a live state. + */ +export async function applyFulfillmentStatus( + prescriptionId: string, + status: Extract, +): Promise { + const allowedFrom: OrderStatus[] = + status === 'fulfilled' ? ['issued', 'partially_fulfilled'] : ['issued']; + return prescriptionsRepo.applyOrderStatusIf(prescriptionId, allowedFrom, status); +} diff --git a/backend/tests/integration/facility-distance.test.mts b/backend/tests/integration/facility-distance.test.mts new file mode 100644 index 00000000..61571000 --- /dev/null +++ b/backend/tests/integration/facility-distance.test.mts @@ -0,0 +1,278 @@ +// Integration tests for issue #74 — facility location + distance-sorted +// eligible facility selection (PRD FR-13). +// +// Self-contained embedded Postgres on its own port (5439) so it runs +// alongside the other integration files. +// +// Covers: +// - capability-filtered listing sorted ascending by distance, correct to +// the haversine formula; +// - facilities WITHOUT coordinates still appear, sorted last; +// - radius filtering, and `radiusKm` without `near` rejecting with 400; +// - privacy: the patient's query coordinates are never echoed back; +// - performance: the same query measured against a few thousand +// facilities, with the numbers printed for the PR (issue #74 DoD). +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 = 5439; +const runId = Date.now(); + +const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'docforum-distance-pg-')); +const pg = new EmbeddedPostgres({ + databaseDir: dataDir, + user: 'docforum', + password: 'docforum', + port: PORT, + persistent: false, +}); + +const databaseUrl = `postgresql://docforum:docforum@localhost:${PORT}/docforum_test`; + +type App = ReturnType; +type Prisma = typeof import('../../src/db/prisma-client.js').prisma; + +// Fictional facilities, coordinates spread around central Lagos. Expected +// distances below are verified against the haversine formula in the test. +const FACILITIES = [ + { key: 'near', name: 'Near Lab', lat: 6.53, lng: 3.38, capability: 'lab_test', detail: 'malaria' }, + { key: 'mid', name: 'Mid Pharmacy', lat: 6.45, lng: 3.37, capability: 'medication_dispense', detail: 'general' }, + { key: 'far', name: 'Far Pharmacy', lat: 6.02, lng: 3.24, capability: 'medication_dispense', detail: 'general' }, + { key: 'farlab', name: 'Far Lab', lat: 5.9, lng: 3.4, capability: 'lab_test', detail: 'malaria' }, + { key: 'noloc', name: 'No Location Pharmacy', lat: null, lng: null, capability: 'medication_dispense', detail: 'general' }, +] as const; + +const CENTER = { lat: 6.5244, lng: 3.3792 }; + +let app: App; +let prisma: Prisma; +let patientToken: string; + +async function createFacilityRow(spec: (typeof FACILITIES)[number], suffix: string) { + const user = await prisma.user.create({ + data: { + email: `${spec.key}-${suffix}@facilities-example.test`, + passwordHash: 'not-used-in-this-test', + role: 'facility', + emailVerifiedAt: new Date(), + }, + }); + const profile = await prisma.facilityProfile.create({ + data: { + userId: user.id, + name: `${spec.name} ${suffix}`, + type: spec.capability === 'lab_test' ? 'lab' : 'pharmacy', + latitude: spec.lat, + longitude: spec.lng, + }, + }); + await prisma.facilityCapability.create({ + data: { facilityProfileId: profile.id, capabilityType: spec.capability, detail: spec.detail }, + }); + return profile; +} + +function haversineKm(a: { lat: number; lng: number }, b: { lat: number; lng: number }): number { + const toRad = (deg: number) => (deg * Math.PI) / 180; + const sinLat = Math.sin(toRad(b.lat - a.lat) / 2); + const sinLng = Math.sin(toRad(b.lng - a.lng) / 2); + return 2 * 6371 * Math.asin(Math.min(1, Math.sqrt(sinLat * sinLat + Math.cos(toRad(a.lat)) * Math.cos(toRad(b.lat)) * sinLng * sinLng))); +} + +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', + }); + + // Point the app's PrismaClient (constructed at module load) at THIS + // throwaway cluster — vitest.config's default DATABASE_URL points at + // booking's port 5433. + process.env.DATABASE_URL = databaseUrl; + + const appModule = await import('../../src/app.js'); + const dbModule = await import('../../src/db/prisma-client.js'); + const tokensModule = await import('../../src/modules/auth/services/tokens.js'); + app = appModule.createApp(); + prisma = dbModule.prisma; + + // The endpoint is public (it doubles as the price-comparison query), so + // this token only exists to prove an authenticated caller sees no + // privileged fields — and no patient location is ever attached to it. + const user = await prisma.user.create({ + data: { + email: `patient-${runId}@distance-example.test`, + passwordHash: 'not-used-in-this-test', + role: 'patient', + emailVerifiedAt: new Date(), + }, + }); + patientToken = tokensModule.signAccessToken({ sub: user.id, role: 'patient' }); + + for (const spec of FACILITIES) await createFacilityRow(spec, `base-${runId}`); +}, 120_000); + +afterAll(async () => { + await pg.stop(); +}); + +describe('distance-sorted eligible facilities (issue #74)', () => { + it('sorts by distance ascending, correct to haversine, coordinates-last', async () => { + const res = await request(app) + .get(`/facilities/eligible?capabilityType=medication_dispense&near=${CENTER.lat},${CENTER.lng}&radiusKm=60`) + .set('Authorization', `Bearer ${patientToken}`); + expect(res.status).toBe(200); + + // The endpoint keeps main's documented `{ facilities: [...] }` + // envelope (it is the price-aware public query too) — issue #74's + // location fields ride inside each row. + const rows = res.body.facilities; + const withCoords = rows.filter((row: { distanceKm: number | null }) => row.distanceKm !== null); + const withoutCoords = rows.filter((row: { distanceKm: number | null }) => row.distanceKm === null); + + // Ascending order, within rounding tolerance of the JS reference. + for (let i = 1; i < withCoords.length; i++) { + expect(withCoords[i].distanceKm).toBeGreaterThanOrEqual(withCoords[i - 1].distanceKm); + } + const mid = FACILITIES.find((f) => f.key === 'mid')!; + const midRow = withCoords.find((row: { name: string }) => row.name.startsWith(mid.name)); + expect(midRow).toBeTruthy(); + expect(Math.abs(midRow.distanceKm - haversineKm(CENTER, { lat: mid.lat, lng: mid.lng }))).toBeLessThan(0.05); + + // The far pharmacy (~56km) is inside radiusKm=60, the far lab has no + // matching capability here; no-location facility appears, sorted last. + expect(withoutCoords).toHaveLength(1); + expect(withoutCoords[0].name).toContain('No Location Pharmacy'); + expect(rows[rows.length - 1].distanceKm).toBeNull(); + + // Response shape: distance only, never the facility's raw point-of- + // interest lat/lng echoed as the patient's query — and no lat/lng + // fields at all. + for (const row of rows) { + expect(Object.keys(row).sort()).toEqual([ + 'address', + 'distanceKm', + 'facilityId', + 'name', + 'price', + 'priceOnRequest', + 'type', + ]); + } + // The patient's query point is never echoed back. + expect(JSON.stringify(res.body)).not.toContain(String(CENTER.lat)); + }); + + it('keeps unlocated facilities inside a radius search and excludes out-of-radius ones', async () => { + const res = await request(app) + .get(`/facilities/eligible?capabilityType=medication_dispense&near=${CENTER.lat},${CENTER.lng}&radiusKm=10`) + .set('Authorization', `Bearer ${patientToken}`); + expect(res.status).toBe(200); + const rows = res.body.facilities as { name: string; distanceKm: number | null }[]; + const names = rows.map((row) => row.name); + // ~56km away → excluded by the 10km radius. + expect(names.some((name) => name.startsWith('Far Pharmacy'))).toBe(false); + // Out-of-radius-to-unknown: still present, sorted last. + expect(names.some((name) => name.startsWith('No Location Pharmacy'))).toBe(true); + expect(rows[rows.length - 1].distanceKm).toBeNull(); + }); + + it('filters by capability and returns name-sorted results without a point', async () => { + const res = await request(app) + .get('/facilities/eligible?capabilityType=lab_test') + .set('Authorization', `Bearer ${patientToken}`); + expect(res.status).toBe(200); + const rows = res.body.facilities as { type: string; distanceKm: null }[]; + const types = new Set(rows.map((row) => row.type)); + expect([...types]).toEqual(['lab']); + expect(rows.every((row) => row.distanceKm === null)).toBe(true); + }); + + it('validates the query contract', async () => { + const badShape = await request(app) + .get('/facilities/eligible?near=lagos') + .set('Authorization', `Bearer ${patientToken}`); + expect(badShape.status).toBe(400); + + const radiusAlone = await request(app) + .get('/facilities/eligible?radiusKm=10') + .set('Authorization', `Bearer ${patientToken}`); + expect(radiusAlone.status).toBe(400); + + // The endpoint is public (main's contract — patients compare prices + // before signing in), so there is no 401 here. What issue #74 requires + // is that a location query never resolves unqualified: `near` without + // `type`/`capabilityType` is rejected outright, and the point itself is + // never stored (asserted above and in the response shape). + const unqualifiedLocation = await request(app).get('/facilities/eligible?near=6.5,3.4'); + expect(unqualifiedLocation.status).toBe(400); + }); + + it('measures query performance against a few thousand facilities (issue #74 DoD)', async () => { + const suffix = `load-${runId}`; + const N = 3000; + const users: { email: string; passwordHash: string; role: 'facility'; emailVerifiedAt: Date }[] = []; + for (let i = 0; i < N; i++) { + users.push({ + email: `load-${i}-${suffix}@facilities-example.test`, + passwordHash: 'x', + role: 'facility' as const, + emailVerifiedAt: new Date(), + }); + } + // createMany without FKs first, then profiles mapped to real ids is + // slow; instead create profiles with their own user rows in batches. + const profiles: { userId: string; name: string; type: 'lab' | 'pharmacy'; latitude: number | null; longitude: number | null }[] = []; + for (const user of users) { + const created = await prisma.user.create({ data: user }); + // Every ~50th facility has no coordinates, mirroring reality. + const hasCoords = Number(user.email.match(/^load-(\d+)/)![1]) % 50 !== 0; + profiles.push({ + userId: created.id, + name: `Load facility ${user.email}`, + type: 'pharmacy', + latitude: hasCoords ? CENTER.lat + (Math.random() - 0.5) * 0.6 : null, + longitude: hasCoords ? CENTER.lng + (Math.random() - 0.5) * 0.6 : null, + }); + } + await prisma.facilityProfile.createMany({ data: profiles }); + expect(await prisma.facilityProfile.count()).toBeGreaterThanOrEqual(N); + + const facilitiesService = await import('../../src/modules/facilities/services/index.js'); + const timings: number[] = []; + for (let i = 0; i < 25; i++) { + const started = performance.now(); + const rows = await facilitiesService.listEligibleFacilities({ + near: CENTER, + radiusKm: 60, + limit: 50, + }); + timings.push(performance.now() - started); + expect(rows.length).toBeGreaterThan(0); + } + timings.sort((a, b) => a - b); + const p50 = timings[Math.floor(timings.length * 0.5)]; + const p95 = timings[Math.floor(timings.length * 0.95)]; + const total = await prisma.facilityProfile.count(); + // Printed for the PR's performance section (issue #74 DoD). + console.log( + `[issue #74 perf] ${total} facilities, near+radiusKm=60, 25 runs → p50 ${p50.toFixed(1)}ms, p95 ${p95.toFixed(1)}ms, max ${timings[timings.length - 1].toFixed(1)}ms`, + ); + // Loose bound: correctness lives in the assertions above; this only + // catches a catastrophic regression (unindexed full scan + trig on + // millions of rows would blow way past this). + expect(p95).toBeLessThan(500); + }, 120_000); +}); \ No newline at end of file diff --git a/backend/tests/integration/fulfillment.test.mts b/backend/tests/integration/fulfillment.test.mts new file mode 100644 index 00000000..82f69647 --- /dev/null +++ b/backend/tests/integration/fulfillment.test.mts @@ -0,0 +1,480 @@ +// Integration tests for issue #73 — item-level (partial) fulfillment of +// multi-item prescriptions. +// +// What's proven here against a real Postgres (embedded, self-contained — +// same harness as booking.test.mts, own port 5438): +// - Split routing: one drug out of stock → patient routes the remaining +// items to a second facility, and the order-level status derives all +// the way to `fulfilled` (partially_fulfilled in between). +// - Double-dispense rejection: two facilities race to dispense the SAME +// order item — exactly one wins, the loser gets 409. This is a DB +// unique-index guarantee ((orderType, dispensedKey)), not app timing, +// hence the genuinely concurrent test. +// - Hard rule 5: a facility can only write its own records. +// - Order status is forward-only: an expired order is never resurrected. +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 = 5438; +const runId = Date.now(); + +const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'docforum-fulfillment-pg-')); +const pg = new EmbeddedPostgres({ + databaseDir: dataDir, + user: 'docforum', + password: 'docforum', + port: PORT, + persistent: false, +}); + +const databaseUrl = `postgresql://docforum:docforum@localhost:${PORT}/docforum_test`; + +type App = ReturnType; +type Prisma = typeof import('../../src/db/prisma-client.js').prisma; + +const emailedTokens = new Map(); + +/** Routing an order is a verified-email mutation (Phase 8, issue #34), so + * the patient goes through the real signup → verify flow. Facilities are + * created directly in the DB (admin-invite signup is issue #14's scope) + * and minted real access tokens via the auth module's own signer. */ +async function signupVerifiedPatient(): Promise { + const email = `patient-${runId}@example.com`; + const res = await request(app).post('/auth/signup').send({ + role: 'patient', + email, + password: 'longenough123', + fullName: 'Partial Fulfillment Patient', + }); + expect(res.status).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; +} + +async function createFacility(name: string, type: 'pharmacy' | 'lab') { + const user = await prisma.user.create({ + data: { + email: `${type}-${name}-${runId}@example.com`, + passwordHash: 'not-used-in-this-test', + role: 'facility', + emailVerifiedAt: new Date(), + }, + }); + const profile = await prisma.facilityProfile.create({ + data: { userId: user.id, name, type }, + }); + const token = signAccessToken({ sub: user.id, role: 'facility' }); + return { profile, token }; +} + +/** A minimal clinical context for one order — doctor + slot + appointment + + * consultation. Orders are versioned rows hanging off a consultation since + * issue #10, so a fixture order needs the whole chain (same shape as + * tests/integration/order-fixtures.mts). Each call makes a fresh doctor so + * the slot's unique claim never collides between fixtures. */ +async function createOrderContext(): Promise<{ consultationId: string; doctorProfileId: string }> { + contextSeq += 1; + const user = await prisma.user.create({ + data: { + email: `fulfillment-doctor-${contextSeq}-${runId}@example.com`, + passwordHash: 'unused', + role: 'doctor', + }, + }); + const doctor = await prisma.doctorProfile.create({ + data: { userId: user.id, fullName: 'Fulfillment Fixture Doctor', verificationStatus: 'verified' }, + }); + const startTime = new Date(Date.now() + contextSeq * 60 * 60 * 1000); + const slot = await prisma.availabilitySlot.create({ + data: { doctorProfileId: doctor.id, startTime, endTime: new Date(startTime.getTime() + 30 * 60 * 1000) }, + }); + const appointment = await prisma.appointment.create({ + data: { patientProfileId, doctorProfileId: doctor.id, availabilitySlotId: slot.id }, + }); + const consultation = await prisma.consultation.create({ data: { appointmentId: appointment.id } }); + return { consultationId: consultation.id, doctorProfileId: doctor.id }; +} + +async function createPrescriptionWithItems(drugNames: string[]) { + const context = await createOrderContext(); + return prisma.prescription.create({ + data: { + consultationId: context.consultationId, + patientProfileId, + prescribingDoctorId: context.doctorProfileId, + facilityProfileId: facilityA.profile.id, + amountStroops: 1_000n, + items: { + create: drugNames.map((drugName) => ({ + drugName, + dosage: '1 tablet', + frequency: 'twice daily', + duration: '5 days', + })), + }, + }, + include: { items: true }, + }); +} + +let app: App; +let prisma: Prisma; +let signAccessToken: (payload: { sub: string; role: 'patient' | 'doctor' | 'facility' | 'admin' }) => string; +let patientToken: string; +let patientProfileId: string; +let contextSeq = 0; +let facilityA: Awaited>; +let facilityB: Awaited>; + +/** The PATCH route addresses the FulfillmentItem ROW under a record + * (not the order item) — resolve it from (record, order item). */ +async function itemRowId(recordId: string, orderItemId: string): Promise { + const row = await prisma.fulfillmentItem.findFirstOrThrow({ + where: { fulfillmentRecordId: recordId, orderItemId }, + }); + return row.id; +} + +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', + }); + + // Point the app's PrismaClient (constructed at module load) at THIS + // throwaway cluster — vitest.config's default DATABASE_URL points at + // booking's port 5433. + process.env.DATABASE_URL = databaseUrl; + + 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'); + const tokensModule = await import('../../src/modules/auth/services/tokens.js'); + app = appModule.createApp(); + prisma = dbModule.prisma; + signAccessToken = tokensModule.signAccessToken; + + authServices.setEmailSender(async (message) => { + const match = /[?&]token=([^&\s]+)/.exec(message.text); + if (match) emailedTokens.set(message.to, decodeURIComponent(match[1])); + }); + + patientToken = await signupVerifiedPatient(); + const patientUser = await prisma.user.findUniqueOrThrow({ + where: { email: `patient-${runId}@example.com` }, + }); + patientProfileId = (await prisma.patientProfile.findUniqueOrThrow({ where: { userId: patientUser.id } })).id; + facilityA = await createFacility('Facility A', 'pharmacy'); + facilityB = await createFacility('Facility B', 'pharmacy'); +}, 120_000); + +afterAll(async () => { + await pg.stop(); +}); + +describe('partial fulfillment (issue #73)', () => { + it('splits a partially dispensed prescription across two facilities and derives order status', async () => { + const prescription = await createPrescriptionWithItems([ + 'Amoxicillin 500mg', + 'Ibuprofen 400mg', + 'Cetirizine 10mg', + ]); + // Lookup by name — PrescriptionItem has no created_at to order by. + const itemFor = (drugName: string) => { + const item = prescription.items.find((candidate) => candidate.drugName === drugName); + expect(item, drugName).toBeTruthy(); + return item!; + }; + const item1 = itemFor('Amoxicillin 500mg'); + const item2 = itemFor('Ibuprofen 400mg'); + const item3 = itemFor('Cetirizine 10mg'); + + // Patient routes all three items to facility A. + const routed = await request(app) + .post('/pharmacy-orders/fulfillments') + .set('Authorization', `Bearer ${patientToken}`) + .send({ orderType: 'prescription', orderId: prescription.id, facilityId: facilityA.profile.id }); + expect(routed.status).toBe(201); + expect(routed.body.items).toHaveLength(3); + expect(routed.body.status).toBe('pending'); + const recordA = routed.body.id as string; + + // A dispenses item1, is out of stock on item2 + item3. + const row1 = await itemRowId(recordA, item1.id); + const row2 = await itemRowId(recordA, item2.id); + const row3 = await itemRowId(recordA, item3.id); + const dispensed = await request(app) + .patch(`/pharmacy-orders/fulfillments/${recordA}/items/${row1}`) + .set('Authorization', `Bearer ${facilityA.token}`) + .send({ status: 'dispensed', quantityDispensed: 1 }); + expect(dispensed.status).toBe(200); + expect(dispensed.body.record.status).toBe('in_progress'); + expect(dispensed.body.orderStatus).toBe('partially_fulfilled'); + + const outOfStock1 = await request(app) + .patch(`/pharmacy-orders/fulfillments/${recordA}/items/${row2}`) + .set('Authorization', `Bearer ${facilityA.token}`) + .send({ status: 'unfulfilled' }); + expect(outOfStock1.status).toBe(200); + const outOfStock2 = await request(app) + .patch(`/pharmacy-orders/fulfillments/${recordA}/items/${row3}`) + .set('Authorization', `Bearer ${facilityA.token}`) + .send({ status: 'unfulfilled' }); + expect(outOfStock2.status).toBe(200); + // Every item terminal, only one dispensed → record derives + // partially_fulfilled (not a lie of "fulfilled", not a refusal). + expect(outOfStock2.body.record.status).toBe('partially_fulfilled'); + + const partially = await prisma.prescription.findUnique({ where: { id: prescription.id } }); + expect(partially?.status).toBe('partially_fulfilled'); + + // One-shot: A can't re-mark a terminal item. + const replay = await request(app) + .patch(`/pharmacy-orders/fulfillments/${recordA}/items/${row1}`) + .set('Authorization', `Bearer ${facilityA.token}`) + .send({ status: 'unfulfilled' }); + expect(replay.status).toBe(409); + + // Patient routes the two remaining items to facility B. + const routedB = await request(app) + .post('/pharmacy-orders/fulfillments') + .set('Authorization', `Bearer ${patientToken}`) + .send({ + orderType: 'prescription', + orderId: prescription.id, + facilityId: facilityB.profile.id, + orderItemIds: [item2.id, item3.id], + }); + expect(routedB.status).toBe(201); + expect(routedB.body.items).toHaveLength(2); + const recordB = routedB.body.id as string; + + // The already-dispensed item can never be routed again. + const reroute = await request(app) + .post('/pharmacy-orders/fulfillments') + .set('Authorization', `Bearer ${patientToken}`) + .send({ + orderType: 'prescription', + orderId: prescription.id, + facilityId: facilityB.profile.id, + orderItemIds: [item1.id], + }); + expect(reroute.status).toBe(409); + + // B dispenses both remaining items → order finally derives fulfilled. + const rowB2 = await itemRowId(recordB, item2.id); + const rowB3 = await itemRowId(recordB, item3.id); + const dispense2 = await request(app) + .patch(`/pharmacy-orders/fulfillments/${recordB}/items/${rowB2}`) + .set('Authorization', `Bearer ${facilityB.token}`) + .send({ status: 'dispensed', quantityDispensed: 1 }); + expect(dispense2.status).toBe(200); + expect(dispense2.body.orderStatus).toBe('partially_fulfilled'); + + const dispense3 = await request(app) + .patch(`/pharmacy-orders/fulfillments/${recordB}/items/${rowB3}`) + .set('Authorization', `Bearer ${facilityB.token}`) + .send({ status: 'dispensed', quantityDispensed: 1 }); + expect(dispense3.status).toBe(200); + expect(dispense3.body.orderStatus).toBe('fulfilled'); + expect(dispense3.body.record.status).toBe('fulfilled'); + + const done = await prisma.prescription.findUnique({ where: { id: prescription.id } }); + expect(done?.status).toBe('fulfilled'); + + const summary = await request(app) + .get(`/pharmacy-orders/orders/${prescription.id}/fulfillment-status?orderType=prescription`) + .set('Authorization', `Bearer ${patientToken}`); + expect(summary.status).toBe(200); + expect(summary.body).toMatchObject({ totalItems: 3, dispensedItems: 3, status: 'fulfilled' }); + }); + + it('rejects a second facility dispensing the same item — including under concurrency', async () => { + const prescription = await createPrescriptionWithItems(['Metformin 500mg']); + const [item] = prescription.items; + + // Both facilities hold a routed row for the same item (patient + // shopped around) — legal while nothing is dispensed. + const routeA = await request(app) + .post('/pharmacy-orders/fulfillments') + .set('Authorization', `Bearer ${patientToken}`) + .send({ orderType: 'prescription', orderId: prescription.id, facilityId: facilityA.profile.id }); + expect(routeA.status).toBe(201); + const routeB = await request(app) + .post('/pharmacy-orders/fulfillments') + .set('Authorization', `Bearer ${patientToken}`) + .send({ + orderType: 'prescription', + orderId: prescription.id, + facilityId: facilityB.profile.id, + orderItemIds: [item.id], + }); + expect(routeB.status).toBe(201); + + // Genuinely concurrent dispense attempts on the same order item. + const raceRowA = await itemRowId(routeA.body.id, item.id); + const raceRowB = await itemRowId(routeB.body.id, item.id); + const [a, b] = await Promise.all([ + request(app) + .patch(`/pharmacy-orders/fulfillments/${routeA.body.id}/items/${raceRowA}`) + .set('Authorization', `Bearer ${facilityA.token}`) + .send({ status: 'dispensed', quantityDispensed: 1 }), + request(app) + .patch(`/pharmacy-orders/fulfillments/${routeB.body.id}/items/${raceRowB}`) + .set('Authorization', `Bearer ${facilityB.token}`) + .send({ status: 'dispensed', quantityDispensed: 1 }), + ]); + const statuses = [a.status, b.status].sort(); + expect(statuses).toEqual([200, 409]); + + const dispensedRows = await prisma.fulfillmentItem.findMany({ + where: { orderType: 'prescription', orderItemId: item.id, status: 'dispensed' }, + }); + expect(dispensedRows).toHaveLength(1); + + // The loser's row must be untouched (its transaction rolled back), + // and a retry must also 409. + const loser = a.status === 409 ? a : b; + expect(loser.body.message).toMatch(/already been dispensed/i); + const retry = await request(app) + .patch( + `/pharmacy-orders/fulfillments/${a.status === 409 ? routeA.body.id : routeB.body.id}/items/${ + a.status === 409 ? raceRowA : raceRowB + }`, + ) + .set('Authorization', `Bearer ${a.status === 409 ? facilityA.token : facilityB.token}`) + .send({ status: 'dispensed', quantityDispensed: 1 }); + expect(retry.status).toBe(409); + }); + + it("enforces hard rule 5: a facility can only write its own record's items", async () => { + const prescription = await createPrescriptionWithItems(['Losartan 50mg']); + const [item] = prescription.items; + const routed = await request(app) + .post('/pharmacy-orders/fulfillments') + .set('Authorization', `Bearer ${patientToken}`) + .send({ orderType: 'prescription', orderId: prescription.id, facilityId: facilityA.profile.id }); + expect(routed.status).toBe(201); + const rule5Row = await itemRowId(routed.body.id, item.id); + + // Facility B writing to facility A's record → 403, item untouched. + const crossWrite = await request(app) + .patch(`/pharmacy-orders/fulfillments/${routed.body.id}/items/${rule5Row}`) + .set('Authorization', `Bearer ${facilityB.token}`) + .send({ status: 'dispensed', quantityDispensed: 1 }); + expect(crossWrite.status).toBe(403); + + // And B can't read A's record either. + const crossRead = await request(app) + .get(`/pharmacy-orders/fulfillments/${routed.body.id}`) + .set('Authorization', `Bearer ${facilityB.token}`); + expect(crossRead.status).toBe(403); + + // `fulfilled` is derived, never settable on the record. + const forceFulfilled = await request(app) + .patch(`/pharmacy-orders/fulfillments/${routed.body.id}`) + .set('Authorization', `Bearer ${facilityA.token}`) + .send({ status: 'fulfilled' }); + expect(forceFulfilled.status).toBe(400); + + // Patients can't dispense (route-level role gate). + const patientDispense = await request(app) + .patch(`/pharmacy-orders/fulfillments/${routed.body.id}/items/${rule5Row}`) + .set('Authorization', `Bearer ${patientToken}`) + .send({ status: 'dispensed', quantityDispensed: 1 }); + expect(patientDispense.status).toBe(403); + + const untouched = await prisma.fulfillmentItem.findFirst({ + where: { fulfillmentRecordId: routed.body.id, orderItemId: item.id }, + }); + expect(untouched?.status).toBe('pending'); + }); + + it('never resurrects an expired order when a dispense lands (forward-only status)', async () => { + const prescription = await createPrescriptionWithItems(['Omeprazole 20mg']); + const [item] = prescription.items; + const routed = await request(app) + .post('/pharmacy-orders/fulfillments') + .set('Authorization', `Bearer ${patientToken}`) + .send({ orderType: 'prescription', orderId: prescription.id, facilityId: facilityA.profile.id }); + expect(routed.status).toBe(201); + + await prisma.prescription.update({ + where: { id: prescription.id }, + data: { status: 'expired' }, + }); + + const expiredRow = await itemRowId(routed.body.id, item.id); + const dispense = await request(app) + .patch(`/pharmacy-orders/fulfillments/${routed.body.id}/items/${expiredRow}`) + .set('Authorization', `Bearer ${facilityA.token}`) + .send({ status: 'dispensed', quantityDispensed: 1 }); + expect(dispense.status).toBe(200); + + const after = await prisma.prescription.findUnique({ where: { id: prescription.id } }); + expect(after?.status).toBe('expired'); + }); + + it('fulfills a lab order (single-item order) through the same item path', async () => { + const context = await createOrderContext(); + const labOrder = await prisma.labOrder.create({ + data: { + consultationId: context.consultationId, + patientProfileId, + orderingDoctorId: context.doctorProfileId, + facilityProfileId: facilityA.profile.id, + amountStroops: 500n, + testType: 'malaria rapid test', + urgency: 'routine', + }, + }); + + const routed = await request(app) + .post('/pharmacy-orders/fulfillments') + .set('Authorization', `Bearer ${patientToken}`) + .send({ orderType: 'lab_order', orderId: labOrder.id, facilityId: facilityA.profile.id }); + expect(routed.status).toBe(201); + expect(routed.body.items).toHaveLength(1); + + const labRow = await itemRowId(routed.body.id, labOrder.id); + const dispense = await request(app) + .patch(`/pharmacy-orders/fulfillments/${routed.body.id}/items/${labRow}`) + .set('Authorization', `Bearer ${facilityA.token}`) + .send({ status: 'dispensed', quantityDispensed: 1 }); + expect(dispense.status).toBe(200); + expect(dispense.body.orderStatus).toBe('fulfilled'); + + const after = await prisma.labOrder.findUnique({ where: { id: labOrder.id } }); + expect(after?.status).toBe('fulfilled'); + }); + + it('rejects routing items that do not belong to the order', async () => { + const prescription = await createPrescriptionWithItems(['Vitamin C 1000mg']); + const stranger = await createPrescriptionWithItems(['Stranger Item']); + const res = await request(app) + .post('/pharmacy-orders/fulfillments') + .set('Authorization', `Bearer ${patientToken}`) + .send({ + orderType: 'prescription', + orderId: prescription.id, + facilityId: facilityA.profile.id, + orderItemIds: [stranger.items[0].id], + }); + expect(res.status).toBe(400); + }); +}); diff --git a/changelog.d/73-74-partial-fulfillment-facility-distance.md b/changelog.d/73-74-partial-fulfillment-facility-distance.md new file mode 100644 index 00000000..846d04f8 --- /dev/null +++ b/changelog.d/73-74-partial-fulfillment-facility-distance.md @@ -0,0 +1,30 @@ +Phase 5: landed issues #73 (partial fulfillment, item-level dispensing for +multi-item prescriptions) and #74 (facility location + distance-sorted +eligible-facility query) together +([issue #73](https://github.com/DocForum/docforum-core/issues/73), +[issue #74](https://github.com/DocForum/docforum-core/issues/74)). + +New `FulfillmentItem` model + `partially_fulfilled` order/record statuses, +`FacilityProfile` location fields + composite `(latitude, longitude)` index, +`/pharmacy-orders` fulfillment routes and the `GET /facilities/eligible` +`near`/`radiusKm`/`limit`/`capabilityType` params (bounding box + haversine, +no PostGIS — justification and the never-store-the-patient's-point rule are +in ARCHITECTURE.md §5.4), a dev seed of fictional facility coordinates +(`npx prisma db seed`), and two new integration test files +(`fulfillment.test.mts`, `facility-distance.test.mts`). Payment-release rule +decided and documented in ARCHITECTURE.md §5.3: no release until the whole +order derives `fulfilled`; proration stays an open question in §6.2. + +Two things the merge with `main` forced, worth knowing before you touch this +area: + +- Both records now hang off ADR 0012's typed order FKs rather than the old + polymorphic `(orderType, orderId)` pair, and ADR 0012's "one record per + order" UNIQUE became one record per **(order, facility)** — split routing + needs a record at each facility. See the ADR's 2026-10-26 amendment and + migration `20261026000000_fulfillment_record_per_order_facility`. +- The Phase 5 migration no longer adds `PrescriptionItem.prescriptionId` or + `FulfillmentRecord.facilityId`: main's `20261020000000_order_payment_intents` + and `20261022090000_fulfillment_record_typed_order_fks` own those columns, + and a second definition made `prisma migrate deploy` fail (42701) in every + integration test. diff --git a/docs/adr/0012-fulfillment-record-typed-order-fks.md b/docs/adr/0012-fulfillment-record-typed-order-fks.md index bac7cc51..70a37b9c 100644 --- a/docs/adr/0012-fulfillment-record-typed-order-fks.md +++ b/docs/adr/0012-fulfillment-record-typed-order-fks.md @@ -172,7 +172,9 @@ Rejected alternatives: stored label. No `OrderType` enum returns — there was an `OrderTypeForPayment` enum in the payments sketch, dropped in #85, and nothing here needs it back. - **One fulfillment per order, permanently, in v1.** The `UNIQUE` on each - column is the guarantee. Consequence, not papered over: a facility that + column is the guarantee. *(Relaxed 2026-10-26 for issue #73 — see the + amendment at the end of this ADR: the UNIQUE now covers each + `(order FK, facility)` pair, so an order gains one record per facility.)* Consequence, not papered over: a facility that `reject`s an order cannot be swapped for another facility by creating a second row — re-routing after rejection needs a partial unique index (`WHERE status NOT IN ('fulfilled','rejected')`), which Prisma cannot declare @@ -220,3 +222,37 @@ Rejected alternatives: what was built), §6.1.3 and §6.3.1 marked resolved, ARCHITECTURE_ESSENTIALS.md's data-model map and "Known unresolved design gaps", ROADMAP.md Phase 5's prerequisite item. + +## Amendment — 2026-10-26 (issue #73, item-level partial fulfillment) + +**The `UNIQUE` on each order FK is replaced by a `UNIQUE` on each +`(order FK, facilityId)` pair.** Everything else in this ADR stands: the table +stays single, the FKs stay typed and `RESTRICT`, the CHECK still requires +exactly one order FK, and no clinical row is written by a facility. + +- **Why.** Issue #73 requires the patient to route the items one facility + could not dispense to a *second* facility — "a new fulfillment record for + those items only" — so one order legitimately owns several records at once. + A `UNIQUE` on `prescription_id` alone makes that impossible: the second + routing is a constraint violation, which is exactly what this branch's + `fulfillment.test.mts` split scenario hit (`P2002` → 500/409 on what the + issue says must be a `201`). +- **What the original UNIQUE was buying, preserved.** "At most one fulfillment + row per order" existed to stop a *duplicated* routing — two records for the + same order doing the same job, with no way to tell which one is live. The + composite unique keeps that for the case that matters (same order, same + facility) while permitting the split flow. NULLs still don't collide, so + each index only constrains the rows its own FK covers — the same mechanism + this ADR already relied on. +- **Consequences, not papered over:** (1) `record.facilityId` is no longer + guaranteed to equal the order's `facilityProfileId` — it is the facility the + patient routed *to*, and hard rule 5's ownership check now reads it against + the writing facility, not against the order. (2) A `rejected` record is + terminal for *that facility's* attempt on the order, not for the order: the + patient may route the remaining items elsewhere, which is issue #73's + intended behaviour rather than the re-routing gap this ADR deferred. (3) The + deferred partial-index idea is still not needed: the terminal-state rule + lives in `pharmacy-orders/services`' conditional update. +- **Migration:** `20261026000000_fulfillment_record_per_order_facility` drops + the two per-column indexes and creates the two composite ones. ARCHITECTURE.md + §4.7 and ARCHITECTURE_ESSENTIALS.md's data map were updated with it. diff --git a/docs/api/README.md b/docs/api/README.md index 4d5eb003..efd2646b 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -246,7 +246,7 @@ are carried as `BigInt` end to end. A facility with no published price is | Method & path | Auth | Body / query | Notes | |---|---|---|---| -| `GET /facilities/eligible` | none | query: `type` (`lab`\|`pharmacy`, required), `catalogItemCode?`, `asset?` (default `native`), `at?` (ISO-8601) | Facilities of that type, each with the price in force at `at` (default now) for `catalogItemCode`. Shape: `{ facilities: [{ facilityId, name, type, priceOnRequest, price: { amountStroops, effectiveFrom } \| null }] }`. `price: null` + `priceOnRequest: true` is the documented "price on request" state. Public: patients compare prices before signing in. Capability matching (issue #14, PRD FR-13): only facilities of that type that *hold* the matching `FacilityCapability` (`lab` ↔ `lab_test`, `pharmacy` ↔ `medication_dispense`) are listed — **fail closed**, a facility with missing or wrong-type capability data shows for nothing. | +| `GET /facilities/eligible` | none | query: `type` (`lab`\|`pharmacy`) **or** `capabilityType` (`lab_test`\|`medication_dispense`) — one of the two required; plus `detail?`, `near=lat,lng`, `radiusKm?`, `limit?` (1–200, default 50), `catalogItemCode?`, `asset?` (default `native`), `at?` (ISO-8601) | Qualified facilities, each with the price in force at `at` (default now) for `catalogItemCode`, optionally sorted ascending by straight-line distance from `near` (Phase 5 issue #74 — indexed bounding box + in-SQL haversine, default radius 50 km when `near` is given without `radiusKm`). Shape: `{ facilities: [{ facilityId, name, type, address, distanceKm, priceOnRequest, price: { amountStroops, effectiveFrom } \| null }] }`. `price: null` + `priceOnRequest: true` is the documented "price on request" state; `distanceKm` is null when no `near` was given or the facility registered no coordinates — such facilities are **never dropped**, they sort last. Public: patients compare prices before signing in. Capability matching (issue #14, PRD FR-13) is unchanged and still **fail closed** — only facilities of `type` holding the matching `FacilityCapability` are listed, and a query with neither `type` nor `capabilityType` is a `400` (no unqualified directory). `radiusKm` without `near` → 400. **The patient's `near` point is never stored or logged** — it reaches SQL only as a bind parameter, and the response carries per-facility distances, never the query point. | | `GET /facilities/:facilityId/prices` | none | query: `catalogItemCode?`, `asset?` | The facility's full price history, newest first: `{ prices: [{ id, facilityId, catalogItemCode, asset, amountStroops, effectiveFrom, createdAt }] }`. Public. | | `GET /facilities/me/prices` | facility | query: `catalogItemCode?`, `asset?` | Same shape, scoped to the caller's own facility profile (resolved from the access token, so there is no client-supplied id to forge). | | `POST /facilities/me/prices` | facility (verified email) | `{ catalogItemCode, amount, asset?, effectiveFrom? }` | Publishes a new version. `amount` is a positive integer string of smallest units (decimals/exponents rejected); `asset` defaults to `native`; `effectiveFrom` defaults to now and may be in the future to schedule a price. `201` with the created version. `409` if that item/asset already has a version with the exact same `effectiveFrom`; `403` for a facility that isn't the caller's. Writes a `FacilityPrice` audit event (`facility_price.created`) in the same transaction. | @@ -426,6 +426,24 @@ the cleanup job deletes the stored bundle. Other knobs: `PATIENT_EXPORT_PAGE_SIZ `PATIENT_EXPORT_MAX_ATTEMPTS` — see `backend/src/config/index.ts` and `backend/src/jobs/README.md`. +## Pharmacy orders (`/pharmacy-orders`) — `backend/src/modules/pharmacy-orders` + +Item-level (partial) fulfillment — Phase 5 issue #73, see +`ARCHITECTURE.md` §5.3. + +| Method & path | Auth | Body | Notes | +|---|---|---|---| +| `POST /pharmacy-orders/fulfillments` | patient or admin (verified email) | `{ orderType, orderId, facilityId, orderItemIds? }` | Routes items to a facility: one `FulfillmentRecord` + one `FulfillmentItem` per item. Omit `orderItemIds` to route every item; pass only the remaining items to split a partially dispensed order across a second facility. `409` if any requested item was already dispensed elsewhere, `400` if an item doesn't belong to the order. | +| `GET /pharmacy-orders/fulfillments/:id` | owning facility or admin | — | Record + item rows. Patients are excluded until Phase 4 links `PatientProfile` to orders (known gap, `ARCHITECTURE.md` §6.2). | +| `PATCH /pharmacy-orders/fulfillments/:id` | owning facility (verified email) | `{ status: 'in_progress'\|'rejected' }` | `fulfilled` is **not settable** — it is derived from the record's dispensed items. Hard rule 5: a facility may only ever write records it owns (`403` otherwise). | +| `PATCH /pharmacy-orders/fulfillments/:id/items/:itemId` | owning facility (verified email) | `{ status: 'dispensed'\|'unfulfilled', quantityDispensed? }` | The only path to `dispensed`. One-shot (`409` on replay) and race-safe: the `(orderType, dispensedKey)` unique index makes a second facility's dispense of the same item a `409`, even under concurrency (`backend/tests/integration/fulfillment.test.mts`). After each item change the record status and the order-level status are re-derived. | +| `GET /pharmacy-orders/orders/:orderId/fulfillment-status?orderType=` | any signed-in user | — | Derived order state: `{ totalItems, dispensedItems, status: 'issued'\|'partially_fulfilled'\|'fulfilled' }`. Computed from item rows — never a hand-set flag. | + +Payment release rule (documented in `ARCHITECTURE.md` §5.3): partial +fulfillment never releases escrow — release eligibility requires the +order to derive `fulfilled`. Release itself is still admin-triggered +(ADR 0004 v1). + ### Account deletion (Phase 8, issue #41) | Method & path | Auth | Body | Notes | @@ -480,9 +498,8 @@ Evaluation: off if `enabled` is false; on if the user id is in `allowUserIds`; o ## Not yet implemented -`patients`, `prescriptions`, `pharmacy-orders` and `notifications` have no -routes yet — Phase 2 (intake), 4 (orders), 5 (facilities) and 6 -(notifications), see `ROADMAP.md`. Facility price lists themselves shipped +`patients`, `prescriptions` and `notifications` have no routes yet — Phase 2 +(intake), 4 (orders) and 6 (notifications), see `ROADMAP.md`. Facility price lists themselves shipped with issue #127 (`/facilities`, above); the remaining facility-admin surface is still pending.