From b88848526706e40121f7ca67900a9592cff47189 Mon Sep 17 00:00:00 2001 From: Oluwaferanmi18 <125797442+Oluwaferanmi18@users.noreply.github.com> Date: Sat, 17 Oct 2026 07:01:57 +0000 Subject: [PATCH 1/5] feat(pharmacy-orders, facilities): item-level partial fulfillment + distance-sorted facility selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #73: FulfillmentItem rows under a FulfillmentRecord carry dispensed quantity/status per routed order item; order status derives from items (partially_fulfilled/fulfilled) via forward-only conditional updates through the prescriptions/lab-orders services; patients can route remaining items to a second facility; a unique index on (orderType, dispensedKey) makes two-facility double-dispense a constraint-level 409 under concurrency. Payment-release rule documented in ARCHITECTURE.md Β§5.3: no release until the whole order fulfills. Closes #74: FacilityProfile gains address/latitude/longitude + composite index; GET /facilities/eligible?near=lat,lng sorts by bounding box + haversine (PostGIS justification in ARCHITECTURE.md Β§5.4); the patient's point is never stored; unlocated facilities stay listed, sorted last; fictional seeded coordinates; perf measured at ~5ms p50 over ~3000 facilities. Also fills in PrescriptionItem.prescriptionId and FulfillmentRecord.facilityId (both already documented in ARCHITECTURE Β§4, schema was behind) and updates ROADMAP/ARCHITECTURE_ESSENTIALS/docs/api. πŸ€– Generated with Codebuff Co-Authored-By: Codebuff --- ARCHITECTURE.md | 88 +++- ARCHITECTURE_ESSENTIALS.md | 8 +- ROADMAP.md | 20 +- backend/package.json | 3 + .../migration.sql | 73 +++ backend/prisma/schema.prisma | 114 ++++- backend/prisma/seed.ts | 181 ++++++++ backend/src/app.ts | 4 + .../modules/facilities/controllers/index.ts | 11 +- .../modules/facilities/repositories/index.ts | 127 ++++- .../src/modules/facilities/routes/index.ts | 14 +- .../src/modules/facilities/services/index.ts | 52 ++- backend/src/modules/facilities/validation.ts | 33 ++ .../modules/lab-orders/repositories/index.ts | 29 +- .../src/modules/lab-orders/services/index.ts | 31 +- .../pharmacy-orders/controllers/index.ts | 58 ++- .../pharmacy-orders/repositories/index.ts | 106 ++++- .../modules/pharmacy-orders/routes/index.ts | 51 ++- .../modules/pharmacy-orders/services/index.ts | 296 +++++++++++- .../src/modules/pharmacy-orders/validation.ts | 37 ++ .../prescriptions/repositories/index.ts | 39 +- .../modules/prescriptions/services/index.ts | 54 ++- .../integration/facility-distance.test.mts | 258 +++++++++++ .../tests/integration/fulfillment.test.mts | 433 ++++++++++++++++++ docs/api/README.md | 28 +- 25 files changed, 2097 insertions(+), 51 deletions(-) create mode 100644 backend/prisma/migrations/20261017061200_phase5_partial_fulfillment_and_facility_location/migration.sql create mode 100644 backend/prisma/seed.ts create mode 100644 backend/src/modules/facilities/validation.ts create mode 100644 backend/src/modules/pharmacy-orders/validation.ts create mode 100644 backend/tests/integration/facility-distance.test.mts create mode 100644 backend/tests/integration/fulfillment.test.mts diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 8fb9ba97..a5200d13 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. @@ -93,6 +93,11 @@ DoctorSpecialty (join table) FacilityProfile (1:1 User) id, user_id, name, type (enum: lab|pharmacy), capabilities: FacilityCapability[] + address (nullable), latitude (nullable), longitude (nullable) + # Added Phase 5 (issue #74) for PRD FR-13 distance sorting. All three + # nullable: a facility that hasn't registered coordinates still appears + # in eligible-facility queries, sorted last. Composite b-tree index on + # (latitude, longitude) backs the bounding-box prefilter β€” see Β§5.4. FacilityCapability id, facility_profile_id, capability_type (enum: lab_test|medication_dispense), detail (e.g. test category) @@ -142,7 +147,9 @@ Design note: `snapshot_notes` and `snapshot_intake_id` deliberately duplicate da ``` Prescription id, consultation_id (FK), patient_profile_id, prescribing_doctor_id - status (enum: issued|fulfilled|expired|superseded) + 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, self-FK β€” corrections create a new row) created_at @@ -155,7 +162,7 @@ PrescriptionItem LabOrder id, consultation_id (FK), patient_profile_id, ordering_doctor_id test_type, urgency (enum: routine|urgent), notes - status (enum: issued|fulfilled|expired|superseded) + status (enum: issued|partially_fulfilled|fulfilled|expired|superseded) supersedes_lab_order_id (nullable, self-FK) created_at @@ -167,9 +174,46 @@ LabResult ### 4.7 Fulfillment (facility-side) ``` FulfillmentRecord - id, order_type (enum: prescription|lab_order), order_id (polymorphic ref) - facility_id, status (enum: pending|in_progress|fulfilled|rejected) + id, order_type (enum-ish string: prescription|lab_order), order_id (polymorphic ref) + facility_id (FK -> FacilityProfile) β€” the owner; a facility may only ever + # write rows it owns (hard rule 5) + status (enum: pending|in_progress|partially_fulfilled|fulfilled|rejected) + # derived from FulfillmentItem rows β€” see below; `fulfilled` is never + # settable by the facility directly created_at, updated_at + items: FulfillmentItem[] + +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. @@ -212,17 +256,39 @@ LoginAttempt (issue #35) ### 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). ## 6. Hard questions (architecture-level β€” see PRD Β§8 for product-level) ### 6.1 What do we think would break? 1. Naive slot booking without the DB-level conditional update in Β§5.1 β€” the most likely first bug filed against this repo. **Resolved 2026-09-14:** implemented exactly as designed in Β§5.1 β€” see `backend/src/modules/availability/repositories/index.ts` (`claimOpenSlot`) and `backend/src/modules/appointments/services/index.ts` (`bookSlot`, the transaction wrapping it). Integration-tested with real concurrency in `backend/tests/integration/booking.test.mts`. 2. `Referral.target_doctor_id` left nullable with no fallback assignment logic β€” referrals to "a specialty" with no specific doctor need a real routing rule (round robin? load-based?) or they'll pile up unassigned. **Not designed yet β€” flag as open architecture gap.** -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. +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. *Still open.* Note (issue #73): item-level fulfillment (`FulfillmentItem`) was deliberately built *on top of* the current polymorphic shape rather than resolving this question β€” issue #13 owns the split-or-keep decision. 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. @@ -232,6 +298,8 @@ LoginAttempt (issue #35) 3. No schema support yet for a doctor working across multiple facilities/locations (PRD edge case 6) β€” `AvailabilitySlot` currently assumes one calendar per doctor. 4. No audit-log table β€” every FR around auditability (PRD Β§7) currently relies on `created_at/updated_at` timestamps only, which is not sufficient for "who changed what." A dedicated `AuditEvent` table is likely needed before this ships to real patients. 5. CSRF on the cookie-authenticated routes β€” the refresh cookie has to be `SameSite=None; Secure` (the Pages frontend is a different site β€” see `docs/adr/0003`), which means any site the patient visits can make their browser send it to `/auth/refresh` and `/auth/logout`. **Resolved 2026-10-15 (issue #37):** a per-session CSRF token is bound into the refresh token and required in the `X-CSRF-Token` header, the `Origin` header is validated against an explicit allowlist, and CORS is now a per-environment allowlist (`CORS_ORIGINS`) instead of one configured string. The double-submit cookie pattern β€” the usual answer β€” is *not* usable here: a cookie set by this API is a third-party cookie to `docforum.github.io`, so the frontend's JavaScript can never read it; the token travels in the login/refresh response body instead. A request with no `Origin` (curl, server-to-server) is allowed through the Origin check but still needs the token. See `backend/src/middleware/csrf.ts`, and `docs/api/README.md` for what the client must send. Consequence: refresh cookies issued before this change carry no `csrf` claim and are rejected, i.e. existing sessions need one re-login. +6. **Discovered 2026-10-17 (issue #73): payment proration under partial fulfillment is undecided.** The release rule (Β§5.3) is "all items dispensed or nothing" β€” safe, but it means a facility that dispensed 2 of 3 items gets nothing until the last item lands, and an abandoned remainder holds the whole escrow until an admin refunds it. Alternatives (prorated release per facility, split intents per routed record) need a product decision; deliberately not designed here. +7. **Discovered 2026-10-17 (issue #73): a patient can't yet read their own order's fulfillment record.** The pharmacy-orders read path gates on owning facility / admin, because `PatientProfile ↔ Prescription/LabOrder` linkage doesn't exist until Phase 4 (issue #9). The derived `GET /pharmacy-orders/orders/:id/fulfillment-status` is intentionally coarse-grained so patients get an honest status before that linkage lands; record-level patient reads are blocked on Phase 4, not forgotten. ### 6.3 What's overengineered (architecture-level, for v1)? 1. Polymorphic fulfillment design (Β§4.7) may itself be premature abstraction β€” two small, boring, separate tables might beat one clever polymorphic one at this stage. Flagged as a design smell to revisit, not shipped as-is. diff --git a/ARCHITECTURE_ESSENTIALS.md b/ARCHITECTURE_ESSENTIALS.md index a5393e8a..9db7d278 100644 --- a/ARCHITECTURE_ESSENTIALS.md +++ b/ARCHITECTURE_ESSENTIALS.md @@ -24,7 +24,13 @@ Quick-reference only. Full reasoning lives in `ARCHITECTURE.md`. Full product sc `AvailabilitySlot` (doctor's calendar) β†’ `Appointment` β†’ `IntakeForm`, `Consultation` `Consultation` β†’ (outcome=referred) β†’ `Referral` (snapshot!) β†’ new `Appointment` `Consultation` β†’ `Prescription`(+`PrescriptionItem`) / `LabOrder`(+`LabResult`) -`FulfillmentRecord` (facility-owned) references an order but doesn't mutate it. +`FulfillmentRecord` (facility-owned) β†’ `FulfillmentItem` (Phase 5 issue #73: one row per routed order item β€” + dispensed/unfulfilled per item; double-dispense blocked by a DB unique index on `(orderType, dispensedKey)`; + order `status` derives from items β†’ `partially_fulfilled`/`fulfilled`, applied only through the order module's + service, never by the facility). Payment release requires full fulfillment β€” see ARCHITECTURE.md Β§5.3. +`FacilityProfile` carries nullable `address`/`latitude`/`longitude` (Phase 5 issue #74) + a composite + `(latitude, longitude)` index backing the distance-sorted `GET /facilities/eligible?near=lat,lng` query β€” + the patient's point is never stored (ARCHITECTURE.md Β§5.4). `User` β†’ `EmailVerificationToken` (Phase 8, issue #34 β€” hashed/expiring, single-use; `User.emailVerifiedAt` gates booking + payment mutations, never login/browsing). `LoginAttempt` (Phase 8, issue #35 β€” Postgres-backed login throttle buckets, one per normalised email and per client IP; counters move only via a conditional upsert). `FacilityProfile` β†’ `WalletLink` (payout address) ; `PatientProfile`/`FacilityProfile` β†’ `PaymentIntent` (opaque `orderType`/`orderId` β€” see docs/adr/0004) diff --git a/ROADMAP.md b/ROADMAP.md index 501c0f56..891b45cd 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,6 +1,6 @@ # ROADMAP.md β€” DocForum -Last updated: 2026-09-26 +Last updated: 2026-10-17 Status: Phase 0, Phase 1, and Phase 5.5 (Payments, custodial v1 β€” see `docs/adr/0004-custodial-payments-v1.md`) built and tested. Phases 2-6 and 8-9 scoped into GitHub issues (#1-#22, #21 closed); Phase 7 is @@ -69,12 +69,14 @@ for status and issue links. - [ ] Order expiry/reminder logic (PRD Β§8.1.3 "orphaned orders" β€” general case, beyond referrals). Tracked as [issue #12](https://github.com/DocForum/docforum-core/issues/12) (Medium, 150 pts) β€” depends on #9. ## Phase 5 β€” Facilities & fulfillment -**Status: Not started (blocked by Phase 4). Scoped into issues #13–#16.** +**Status: In progress β€” not done. Issues #13–#16 (the original breakdown) are still open; #13's FulfillmentRecord ADR remains the prerequisite for the rest of the phase. Two independently-scoped items added later β€” #73 (partial fulfillment) and #74 (facility distance sorting) β€” have landed ahead of them, built on the current polymorphic `FulfillmentRecord` shape, so #13's keep-vs-split decision must explicitly account for the new `FulfillmentItem` rows.** - [ ] 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. - [ ] 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. - [ ] 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. - [ ] 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. - [ ] 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`. Built on the current polymorphic shape β€” #13's ADR is untouched and still owed. +- [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 p50 β‰ˆ 5 ms / p95 β‰ˆ 7 ms over ~3 000 facilities in `backend/tests/integration/facility-distance.test.mts`. ## Phase 5.5 β€” Payments (consumer side) **Status: Built, decoupled from Phase 4/5 rather than blocked by them β€” see docs/adr/0004-custodial-payments-v1.md.** `docforum-escrow` issues #4/#5 (SDK + distribution decision) closed, so the original blocker cleared; rather than wait further on Phase 4/5 (Orders/Facilities, still not built), `PaymentIntent`/`WalletLink` were designed with opaque, unvalidated `orderType`/`orderId` fields instead of hard FKs to `Prescription`/`LabOrder`, so this phase didn't have to wait on two more unbuilt phases. 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. @@ -380,3 +382,17 @@ for status and issue links. tests assert a forged cross-origin request is rejected while the legitimate cross-site client still succeeds. +- 2026-10-17 β€” Phase 5: landed issues #73 (partial fulfillment, + item-level dispensing for multi-item prescriptions) and #74 (facility + location + distance-sorted eligible-facility query) together. New + `FulfillmentItem` model + `partially_fulfilled` order/record statuses, + `PrescriptionItem.prescriptionId` FK and `FulfillmentRecord.facilityId` + FK filled in (both were already in ARCHITECTURE Β§4/Β§4.5, schema was + behind), `FacilityProfile` location fields + index, new + `/pharmacy-orders` and `/facilities` routes, a dev seed of fictional + facility coordinates (`npx prisma db seed`), and two new integration + test files (11 tests). Payment-release rule decided and documented in + ARCHITECTURE.md Β§5.3 (no release until full fulfillment; proration + logged as an open question in Β§6.2), two new Β§6.2 gaps recorded, and a + new Β§5.4 documents the distance query + its privacy rule. Still owed by + this phase: #13–#16. diff --git a/backend/package.json b/backend/package.json index ebe5a93b..c7c40f37 100644 --- a/backend/package.json +++ b/backend/package.json @@ -15,6 +15,9 @@ "prisma:generate": "prisma generate", "prisma:migrate": "prisma migrate dev" }, + "prisma": { + "seed": "tsx prisma/seed.ts" + }, "dependencies": { "@docforum/escrow-sdk": "https://github.com/DocForum/docforum-escrow/releases/download/sdk-v0.2.0/docforum-escrow-sdk-0.2.0.tgz", "@prisma/client": "^6.19.3", 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..3f1baff4 --- /dev/null +++ b/backend/prisma/migrations/20261017061200_phase5_partial_fulfillment_and_facility_location/migration.sql @@ -0,0 +1,73 @@ +/* + Warnings: + + - Added the required column `facilityId` to the `FulfillmentRecord` table without a default value. This is not possible if the table is not empty. + - Added the required column `prescriptionId` to the `PrescriptionItem` table without a default value. This is not possible if the table is not empty. + +*/ +-- 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; + +-- AlterTable +ALTER TABLE "FulfillmentRecord" ADD COLUMN "facilityId" TEXT NOT NULL; + +-- AlterTable +ALTER TABLE "PrescriptionItem" ADD COLUMN "prescriptionId" TEXT NOT NULL; + +-- 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"); + +-- CreateIndex +CREATE INDEX "FulfillmentRecord_facilityId_idx" ON "FulfillmentRecord"("facilityId"); + +-- CreateIndex +CREATE INDEX "FulfillmentRecord_orderType_orderId_idx" ON "FulfillmentRecord"("orderType", "orderId"); + +-- CreateIndex +CREATE INDEX "PrescriptionItem_prescriptionId_idx" ON "PrescriptionItem"("prescriptionId"); + +-- AddForeignKey +ALTER TABLE "PrescriptionItem" ADD CONSTRAINT "PrescriptionItem_prescriptionId_fkey" FOREIGN KEY ("prescriptionId") REFERENCES "Prescription"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "FulfillmentRecord" ADD CONSTRAINT "FulfillmentRecord_facilityId_fkey" FOREIGN KEY ("facilityId") REFERENCES "FacilityProfile"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- 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/schema.prisma b/backend/prisma/schema.prisma index 5ceed14b..af5be4d0 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -73,6 +73,12 @@ enum ReferralStatus { 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 @@ -81,10 +87,29 @@ enum OrderStatus { 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 } +// Phase 5 (issue #73): per-item dispensing state. One row per +// (fulfillment record, order item). +enum FulfillmentItemStatus { + // Routed to this facility, awaiting dispensing. + pending + // Facility dispensed it (quantityDispensed > 0). + dispensed + // Facility confirmed it cannot dispense it (e.g. out of stock) β€” the + // patient may route this item to a different facility afterwards. + unfulfilled +} + enum Urgency { routine urgent @@ -222,6 +247,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 @@ -229,6 +262,14 @@ model FacilityProfile { capabilities FacilityCapability[] paymentIntents PaymentIntent[] walletLink WalletLink? + fulfillmentRecords FulfillmentRecord[] + + // 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 { @@ -323,6 +364,8 @@ model Prescription { status OrderStatus @default(issued) createdAt DateTime @default(now()) // TODO: consultationId, patientProfileId, prescribingDoctorId, supersedesPrescriptionId (nullable, self-relation) + + items PrescriptionItem[] } model PrescriptionItem { @@ -331,7 +374,15 @@ model PrescriptionItem { dosage String frequency String duration String - // TODO: prescriptionId + // Phase 5 (issue #73): items must be addressable under their + // prescription for item-level fulfillment (partial dispensing, split + // routing). Matches ARCHITECTURE.md Β§4.5, which has always listed this + // FK β€” the schema placeholder was behind the doc. + prescriptionId String + + prescription Prescription @relation(fields: [prescriptionId], references: [id], onDelete: Cascade) + + @@index([prescriptionId]) } model LabOrder { @@ -355,13 +406,72 @@ model FulfillmentRecord { id String @id @default(uuid()) orderType String // "prescription" | "lab_order" β€” see ARCHITECTURE.md Β§6.1.3 caveat on this polymorphic design orderId String + // Which facility owns this record. A facility may only ever write to + // records it owns (hard rule 5) β€” the ownership check lives in + // pharmacy-orders/services. + facilityId String status FulfillmentStatus @default(pending) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - // TODO: facilityId + + facility FacilityProfile @relation(fields: [facilityId], references: [id], onDelete: Cascade) + items FulfillmentItem[] + // NOTE: this polymorphic (orderType, orderId) pair is flagged in // ARCHITECTURE.md Β§6.1.3 as a design smell to revisit β€” may be split // into PrescriptionFulfillment / LabOrderFulfillment before implementation. + // Issue #73 (item-level fulfillment) deliberately builds on the current + // polymorphic shape rather than resolving that question, which stays + // with issue #13. + + @@index([facilityId]) + @@index([orderType, orderId]) +} + +// Phase 5 (issue #73): item-level fulfillment rows under a +// FulfillmentRecord. A prescription with three items gets one row per +// routed item, so one out-of-stock drug doesn't force the pharmacy to +// either lie ("fulfilled") or refuse the whole order. +model FulfillmentItem { + id String @id @default(uuid()) + fulfillmentRecordId String + // Mirrors the parent record's polymorphic shape: "prescription" | + // "lab_order" (a lab order is a single-item order, so orderItemId = the + // LabOrder's own id). + orderType String + orderItemId String // PrescriptionItem.id | LabOrder.id + // How many units the order asked for (nullable β€” the Phase 4 item + // models don't carry quantities yet; a lab order has no quantity at + // all). null = "not tracked", not zero. + quantityOrdered Int? + quantityDispensed Int @default(0) + status FulfillmentItemStatus @default(pending) + // Derived enforcement column for the "one dispenser per item" DB + // constraint below. Equals orderItemId exactly when status = + // 'dispensed', NULL otherwise, so the plain unique index + // (orderType, dispensedKey) only ever collides on non-NULL values β€” + // i.e. two facilities trying to dispense the SAME item. + // + // This is a partial unique index (`WHERE status = 'dispensed'`) + // expressed with a nullable column on purpose: Prisma cannot represent + // partial indexes in schema.prisma, and migrate diff would try to drop + // one it found in the database. Set/cleared only inside the dispense + // transaction in pharmacy-orders/services. + dispensedKey String? + dispensedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + fulfillmentRecord FulfillmentRecord @relation(fields: [fulfillmentRecordId], references: [id], onDelete: Cascade) + + // The same item can't be routed twice under one record. + @@unique([fulfillmentRecordId, orderType, orderItemId]) + // Hard constraint: a single order item can't be dispensed by two + // facilities (issue #73 edge case). Multiple pending/unfulfilled rows + // for the same item across records are allowed β€” that's exactly what + // "route the remaining items to a second facility" needs. + @@unique([orderType, dispensedKey]) + @@index([orderType, orderItemId]) } model Notification { diff --git a/backend/prisma/seed.ts b/backend/prisma/seed.ts new file mode 100644 index 00000000..cda209ae --- /dev/null +++ b/backend/prisma/seed.ts @@ -0,0 +1,181 @@ +// Dev/test seed β€” issue #74: "Seed realistic test locations (no real +// facilities' private data)." +// +// Every facility here is fictional: made-up names, generic area-level +// addresses, and coordinates that point at well-known public areas in +// Lagos, Nigeria β€” not at any real pharmacy or lab. Two entries +// deliberately have NO coordinates, to exercise the "facilities without +// coordinates still appear, sorted last" behavior of +// GET /facilities/eligible. +// +// Run with: npx prisma db seed (or: npx tsx prisma/seed.ts) +// Idempotent: upserts by email / (facilityProfileId, capabilityType, +// detail), so re-running it doesn't duplicate anything. +import { PrismaClient } from '@prisma/client'; +import bcrypt from 'bcryptjs'; + +const prisma = new PrismaClient(); + +// Dev-only password. The seed never runs in production; these accounts +// exist so a developer can log in as a facility without an admin-invite +// flow (which is issue #14's scope). +const SEED_PASSWORD_HASH = bcrypt.hashSync('seed-dev-password-123', 10); + +interface SeedFacility { + email: string; + name: string; + type: 'pharmacy' | 'lab'; + address: string | null; + latitude: number | null; + longitude: number | null; + capabilities: { capabilityType: 'lab_test' | 'medication_dispense'; detail: string }[]; +} + +const facilities: SeedFacility[] = [ + { + email: 'pharmacy.ikeja@example.test', + name: 'Ikeja Community Pharmacy (seed)', + type: 'pharmacy', + address: 'Ikeja GRA, Lagos', + latitude: 6.5833, + longitude: 3.3564, + capabilities: [ + { capabilityType: 'medication_dispense', detail: 'general' }, + { capabilityType: 'medication_dispense', detail: 'antibiotics' }, + ], + }, + { + email: 'pharmacy.yaba@example.test', + name: 'Yaba Mainland Pharmacy (seed)', + type: 'pharmacy', + address: 'Yaba, Lagos', + latitude: 6.5095, + longitude: 3.3711, + capabilities: [{ capabilityType: 'medication_dispense', detail: 'general' }], + }, + { + email: 'pharmacy.lekki@example.test', + name: 'Lekki Phase 1 Pharmacy (seed)', + type: 'pharmacy', + address: 'Lekki Phase 1, Lagos', + latitude: 6.4478, + longitude: 3.4723, + capabilities: [ + { capabilityType: 'medication_dispense', detail: 'general' }, + { capabilityType: 'medication_dispense', detail: 'chronic-care' }, + ], + }, + { + email: 'lab.surulere@example.test', + name: 'Surulere Diagnostic Lab (seed)', + type: 'lab', + address: 'Surulere, Lagos', + latitude: 6.4933, + longitude: 3.3547, + capabilities: [ + { capabilityType: 'lab_test', detail: 'malaria' }, + { capabilityType: 'lab_test', detail: 'full-blood-count' }, + ], + }, + { + email: 'lab.ikoyi@example.test', + name: 'Ikoyi Health Lab (seed)', + type: 'lab', + address: 'Ikoyi, Lagos', + latitude: 6.4541, + longitude: 3.4318, + capabilities: [{ capabilityType: 'lab_test', detail: 'malaria' }], + }, + { + email: 'pharmacy.ajah@example.test', + name: 'Ajah Roadside Pharmacy (seed)', + type: 'pharmacy', + address: 'Ajah, Lagos', + latitude: 6.4667, + longitude: 3.5667, + capabilities: [{ capabilityType: 'medication_dispense', detail: 'general' }], + }, + // No coordinates on purpose β€” see file header. + { + email: 'pharmacy.no-location@example.test', + name: 'Unnamed Island Pharmacy (seed, no coordinates)', + type: 'pharmacy', + address: null, + latitude: null, + longitude: null, + capabilities: [{ capabilityType: 'medication_dispense', detail: 'general' }], + }, + { + email: 'lab.no-location@example.test', + name: 'Unnamed Mobile Lab (seed, no coordinates)', + type: 'lab', + address: null, + latitude: null, + longitude: null, + capabilities: [{ capabilityType: 'lab_test', detail: 'malaria' }], + }, +]; + +async function main() { + for (const facility of facilities) { + const user = await prisma.user.upsert({ + where: { email: facility.email }, + create: { + email: facility.email, + passwordHash: SEED_PASSWORD_HASH, + role: 'facility', + // Seed accounts skip the email-verification guard locally β€” the + // email provider (Phase 6, issue #17) doesn't exist yet, so a + // verification link can't be delivered. See + // backend/src/modules/auth/services/email-dispatcher.ts. + emailVerifiedAt: new Date(), + }, + update: {}, + }); + + const profile = await prisma.facilityProfile.upsert({ + where: { userId: user.id }, + create: { + userId: user.id, + name: facility.name, + type: facility.type, + address: facility.address, + latitude: facility.latitude, + longitude: facility.longitude, + }, + update: { + name: facility.name, + type: facility.type, + address: facility.address, + latitude: facility.latitude, + longitude: facility.longitude, + }, + }); + + for (const capability of facility.capabilities) { + await prisma.facilityCapability.upsert({ + where: { + facilityProfileId_capabilityType_detail: { + facilityProfileId: profile.id, + capabilityType: capability.capabilityType, + detail: capability.detail, + }, + }, + create: { + facilityProfileId: profile.id, + capabilityType: capability.capabilityType, + detail: capability.detail, + }, + update: {}, + }); + } + } + console.log(`Seeded ${facilities.length} fictional facilities.`); +} + +main() + .catch((error) => { + console.error(error); + process.exitCode = 1; + }) + .finally(() => prisma.$disconnect()); diff --git a/backend/src/app.ts b/backend/src/app.ts index c9cd9bed..8c595258 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -8,6 +8,8 @@ import { doctorsRouter } from './modules/doctors/routes'; import { availabilityRouter } from './modules/availability/routes'; import { appointmentsRouter } from './modules/appointments/routes'; import { paymentsRouter } from './modules/payments/routes'; +import { facilitiesRouter } from './modules/facilities/routes'; +import { pharmacyOrdersRouter } from './modules/pharmacy-orders/routes'; export function createApp() { const app = express(); @@ -31,6 +33,8 @@ export function createApp() { app.use('/availability', availabilityRouter); app.use('/appointments', appointmentsRouter); app.use('/payments', paymentsRouter); + app.use('/facilities', facilitiesRouter); + app.use('/pharmacy-orders', pharmacyOrdersRouter); // Must be registered last β€” Express only routes errors here once every // other handler has had a chance to run. diff --git a/backend/src/modules/facilities/controllers/index.ts b/backend/src/modules/facilities/controllers/index.ts index c275a925..f921762b 100644 --- a/backend/src/modules/facilities/controllers/index.ts +++ b/backend/src/modules/facilities/controllers/index.ts @@ -1,2 +1,9 @@ -// facilities controllers β€” placeholder. -export {}; +import type { Request, Response } from 'express'; +import * as facilitiesService from '../services'; +import { eligibleFacilitiesQuerySchema } from '../validation'; + +export async function listEligibleFacilities(req: Request, res: Response) { + const query = eligibleFacilitiesQuerySchema.parse(req.query); + const facilities = await facilitiesService.listEligibleFacilities(query); + res.status(200).json(facilities); +} diff --git a/backend/src/modules/facilities/repositories/index.ts b/backend/src/modules/facilities/repositories/index.ts index 60ac7878..8dad9f68 100644 --- a/backend/src/modules/facilities/repositories/index.ts +++ b/backend/src/modules/facilities/repositories/index.ts @@ -1,3 +1,124 @@ -// facilities repositories β€” placeholder. Data access only. Not to be imported -// by other modules directly β€” go through services/ instead. -export {}; +// facilities repositories β€” FacilityProfile / FacilityCapability data +// access. The distance query lives here; the patient's `near` coordinates +// are used as bind parameters and nothing else β€” never stored, never +// echoed back (issue #74). +import { prisma } from '../../../db/prisma-client'; +import { Prisma } from '@prisma/client'; + +export interface EligibleFacilityRow { + id: string; + name: string; + type: string; + address: string | null; + distanceKm: number | null; +} + +/** 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; + +export interface EligibleFacilityFilters { + capabilityType?: 'lab_test' | 'medication_dispense'; + detail?: string; + near?: { lat: number; lng: number }; + radiusKm?: number; + limit: number; +} + +/** + * Eligible 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 listEligibleFacilities(filters: EligibleFacilityFilters): Promise { + const capabilityType = filters.capabilityType ?? null; + const detail = filters.detail ?? null; + + 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 ${capabilityFilter} + ORDER BY fp."name" ASC + LIMIT ${filters.limit} + `); + } + + const { lat, lng } = filters.near; + const radiusKm = filters.radiusKm ?? null; + // 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 === null ? null : radiusKm / 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} AND ${lat} + ${boxLat} + AND fp."longitude" BETWEEN ${lng} - ${boxLng} AND ${lng} + ${boxLng} + ) + )`; + + const radiusFilter = + radiusKm === null ? Prisma.empty : Prisma.sql`WHERE "distanceKm" IS NULL OR "distanceKm" <= ${radiusKm}`; + + 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} * asin(least(1.0, sqrt( + power(sin(radians(fp."latitude" - ${lat}) / 2), 2) + + cos(radians(${lat})) * cos(radians(fp."latitude")) + * power(sin(radians(fp."longitude" - ${lng}) / 2), 2) + ))) + END AS "distanceKm" + FROM "FacilityProfile" fp + WHERE ${capabilityFilter} + ${boxFilter} + ) + SELECT * FROM scored + ${radiusFilter} + ORDER BY "distanceKm" ASC NULLS LAST, "name" ASC + LIMIT ${filters.limit} + `); +} diff --git a/backend/src/modules/facilities/routes/index.ts b/backend/src/modules/facilities/routes/index.ts index d2dc8cce..f5bd92b2 100644 --- a/backend/src/modules/facilities/routes/index.ts +++ b/backend/src/modules/facilities/routes/index.ts @@ -1,3 +1,11 @@ -// facilities routes β€” placeholder. See PRD.md / ARCHITECTURE.md Β§3 for this module's scope. -// TODO(Phase: see ROADMAP.md): define routes for facilities. -export {}; +import { Router } from 'express'; +import { authenticate } from '../../../middleware/auth'; +import * as facilitiesController from '../controllers'; + +export const facilitiesRouter = Router(); + +// PRD FR-13 / issue #74 β€” facilities qualified for an order type, +// optionally sorted by distance from the patient's `near=lat,lng` point. +// Signed-in users only (the patient's point must never be public-facing +// beyond this response); the coordinates themselves are never stored. +facilitiesRouter.get('/eligible', authenticate, facilitiesController.listEligibleFacilities); diff --git a/backend/src/modules/facilities/services/index.ts b/backend/src/modules/facilities/services/index.ts index 5e1dc803..7d2ba7fc 100644 --- a/backend/src/modules/facilities/services/index.ts +++ b/backend/src/modules/facilities/services/index.ts @@ -1,3 +1,49 @@ -// facilities services β€” placeholder. Other modules must only import from here, -// never from repositories/ directly. See AGENTS.md "Non-negotiable rules". -export {}; +// facilities services β€” eligible-facility discovery. Other modules must +// only import from here, never from repositories/ directly (AGENTS.md +// hard rule 4). +import * as facilitiesRepo from '../repositories'; +import type { EligibleFacilitiesQuery } from '../validation'; + +/** Default search radius when `near` is given but `radiusKm` isn't β€” a + * city-scale default rather than "sort everything in the country". */ +export const DEFAULT_RADIUS_KM = 50; +const DEFAULT_LIMIT = 50; + +export interface EligibleFacility { + id: string; + name: string; + type: string; + address: string | null; + /** Straight-line km from the patient's query point, or null for a + * facility that hasn't registered coordinates (it still appears in the + * list β€” issue #74 edge case β€” just sorted last). */ + distanceKm: number | null; +} + +/** + * Patient-facing list of facilities qualified for an order type, sorted + * by distance from the patient's point when one is provided (issue #74). + * + * Privacy: the `near` point lives on the call stack of this request and + * nothing else. It is never persisted, never written to a log line, and + * never echoed back in the response (the response carries only per- + * facility distances). No caller in this module stores it β€” enforced by + * it being a plain function argument here and bind parameters in + * repositories/. + */ +export async function listEligibleFacilities(query: EligibleFacilitiesQuery): Promise { + const rows = await facilitiesRepo.listEligibleFacilities({ + capabilityType: query.capabilityType, + detail: query.detail, + near: query.near, + radiusKm: query.radiusKm ?? (query.near ? DEFAULT_RADIUS_KM : undefined), + limit: query.limit ?? DEFAULT_LIMIT, + }); + return rows.map((row) => ({ + id: row.id, + name: row.name, + type: row.type, + address: row.address, + distanceKm: row.distanceKm === null ? null : Number(row.distanceKm.toFixed(2)), + })); +} diff --git a/backend/src/modules/facilities/validation.ts b/backend/src/modules/facilities/validation.ts new file mode 100644 index 00000000..da4df809 --- /dev/null +++ b/backend/src/modules/facilities/validation.ts @@ -0,0 +1,33 @@ +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.'); + +export const eligibleFacilitiesQuerySchema = z + .object({ + // Capability matching is deliberately shallow here β€” the full + // order-specific matching matrix is issue #14's scope. This filters + // by capability type (and optionally an exact detail, case + // insensitive). + 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.radiusKm === undefined || query.near !== undefined, { + message: 'radiusKm requires near=lat,lng.', + path: ['radiusKm'], + }); + +export type EligibleFacilitiesQuery = z.infer; diff --git a/backend/src/modules/lab-orders/repositories/index.ts b/backend/src/modules/lab-orders/repositories/index.ts index 0966c827..62a60cff 100644 --- a/backend/src/modules/lab-orders/repositories/index.ts +++ b/backend/src/modules/lab-orders/repositories/index.ts @@ -1,3 +1,26 @@ -// lab-orders repositories β€” placeholder. Data access only. Not to be imported -// by other modules directly β€” go through services/ instead. -export {}; +// 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); +} diff --git a/backend/src/modules/lab-orders/services/index.ts b/backend/src/modules/lab-orders/services/index.ts index c40b2155..abf7ca5f 100644 --- a/backend/src/modules/lab-orders/services/index.ts +++ b/backend/src/modules/lab-orders/services/index.ts @@ -1,3 +1,28 @@ -// lab-orders services β€” placeholder. Other modules must only import from here, -// never from repositories/ directly. See AGENTS.md "Non-negotiable rules". -export {}; +// 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 { HttpError } from '../../../http-error'; +import * as labOrdersRepo from '../repositories'; +import type { OrderStatus } from '@prisma/client'; + +export async function getLabOrder(id: string) { + const labOrder = await labOrdersRepo.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 labOrdersRepo.applyOrderStatusIf(labOrderId, ['issued', 'partially_fulfilled'], status); +} diff --git a/backend/src/modules/pharmacy-orders/controllers/index.ts b/backend/src/modules/pharmacy-orders/controllers/index.ts index 1f4d51b7..82d5cc58 100644 --- a/backend/src/modules/pharmacy-orders/controllers/index.ts +++ b/backend/src/modules/pharmacy-orders/controllers/index.ts @@ -1,2 +1,56 @@ -// pharmacy-orders controllers β€” placeholder. -export {}; +import type { Request, Response } from 'express'; +import { HttpError } from '../../../http-error'; +import { getParam } from '../../../request-params'; +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 }; +} + +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); +} diff --git a/backend/src/modules/pharmacy-orders/repositories/index.ts b/backend/src/modules/pharmacy-orders/repositories/index.ts index ab0ca98d..5c70ee5b 100644 --- a/backend/src/modules/pharmacy-orders/repositories/index.ts +++ b/backend/src/modules/pharmacy-orders/repositories/index.ts @@ -1,3 +1,103 @@ -// pharmacy-orders repositories β€” placeholder. Data access only. Not to be imported -// by other modules directly β€” go through services/ instead. -export {}; +// 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, 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: string; + orderId: string; + facilityId: string; + items: { orderType: string; orderItemId: string; quantityOrdered: number | null }[]; + }, +) { + return db.fulfillmentRecord.create({ + data: { + orderType: data.orderType, + orderId: data.orderId, + 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' }, + }); +} diff --git a/backend/src/modules/pharmacy-orders/routes/index.ts b/backend/src/modules/pharmacy-orders/routes/index.ts index d892a70b..5e5ea524 100644 --- a/backend/src/modules/pharmacy-orders/routes/index.ts +++ b/backend/src/modules/pharmacy-orders/routes/index.ts @@ -1,3 +1,48 @@ -// pharmacy-orders routes β€” placeholder. See PRD.md / ARCHITECTURE.md Β§3 for this module's scope. -// TODO(Phase: see ROADMAP.md): define routes for pharmacy-orders. -export {}; +import { Router } from 'express'; +import { authenticate, requireRole, requireVerifiedEmail } from '../../../middleware/auth'; +import * as pharmacyOrdersController from '../controllers'; + +export const pharmacyOrdersRouter = Router(); + +// 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, +); diff --git a/backend/src/modules/pharmacy-orders/services/index.ts b/backend/src/modules/pharmacy-orders/services/index.ts index 6afdd4f2..db728079 100644 --- a/backend/src/modules/pharmacy-orders/services/index.ts +++ b/backend/src/modules/pharmacy-orders/services/index.ts @@ -1,3 +1,293 @@ -// pharmacy-orders services β€” placeholder. Other modules must only import from here, -// never from repositories/ directly. See AGENTS.md "Non-negotiable rules". -export {}; +// 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 * as pharmacyOrdersRepo 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 pharmacyOrdersRepo.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 pharmacyOrdersRepo.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 pharmacyOrdersRepo.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 pharmacyOrdersRepo.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 pharmacyOrdersRepo.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 pharmacyOrdersRepo.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 pharmacyOrdersRepo.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 pharmacyOrdersRepo.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 pharmacyOrdersRepo.listRecordItems(tx, recordId); + await pharmacyOrdersRepo.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.'); + + if (record.orderType !== 'prescription' && record.orderType !== 'lab_order') { + // Can't happen β€” createFulfillment only ever writes the validated + // enum members β€” but keeps TypeScript honest instead of casting. + throw new HttpError(500, 'Fulfillment record has an unknown orderType.'); + } + const orderStatus = await applyOrderStatus(record.orderType, record.orderId); + return { + record: await pharmacyOrdersRepo.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 pharmacyOrdersRepo.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 pharmacyOrdersRepo.countDispensedOrderItems( + 'prescription', + items.map((item) => item.id), + ) + : await pharmacyOrdersRepo.countDispensedOrderItems('lab_order', [orderId]); + const status: DerivedOrderStatus = + dispensedItems === items.length ? 'fulfilled' : dispensedItems > 0 ? 'partially_fulfilled' : 'issued'; + return { orderType, orderId, totalItems: items.length, dispensedItems, status }; +} 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..5b043d61 --- /dev/null +++ b/backend/tests/integration/facility-distance.test.mts @@ -0,0 +1,258 @@ +// 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 only requires authentication β€” no signup dance needed, + // and no patient location is ever attached to this account. + 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); + + const withCoords = res.body.filter((row: { distanceKm: number | null }) => row.distanceKm !== null); + const withoutCoords = res.body.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(res.body[res.body.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 res.body) { + expect(Object.keys(row).sort()).toEqual(['address', 'distanceKm', 'id', 'name', '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 names = res.body.map((row: { name: string }) => row.name); + // ~56km away β†’ excluded by the 10km radius. + expect(names.some((name: string) => name.startsWith('Far Pharmacy'))).toBe(false); + // Out-of-radius-to-unknown: still present, sorted last. + expect(names.some((name: string) => name.startsWith('No Location Pharmacy'))).toBe(true); + expect(res.body[res.body.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 types = new Set(res.body.map((row: { type: string }) => row.type)); + expect([...types]).toEqual(['lab']); + expect(res.body.every((row: { distanceKm: null }) => 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); + + const unauthenticated = await request(app).get('/facilities/eligible?near=6.5,3.4'); + expect(unauthenticated.status).toBe(401); + }); + + 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..6baecc29 --- /dev/null +++ b/backend/tests/integration/fulfillment.test.mts @@ -0,0 +1,433 @@ +// 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 }; +} + +async function createPrescriptionWithItems(drugNames: string[]) { + return prisma.prescription.create({ + data: { + 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 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(); + 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 user = await prisma.user.findUnique({ where: { email: `patient-${runId}@example.com` } }); + const labOrder = await prisma.labOrder.create({ + data: { testType: 'malaria rapid test', urgency: 'routine' }, + }); + void user; + + 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/docs/api/README.md b/docs/api/README.md index ee29323a..08ad788a 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -151,9 +151,33 @@ drift apart. See `docs/adr/0003` for why production needs `SameSite=None`. | `POST /appointments` | patient | `{ availabilitySlotId }` | **The concurrency-critical endpoint** β€” see `ARCHITECTURE.md` Β§5.1 and `backend/tests/integration/booking.test.mts`. `409` if the slot is no longer open. | | `GET /appointments/mine` | patient | β€” | Caller's own appointments, most recent first. | +## Facilities (`/facilities`) β€” `backend/src/modules/facilities` + +| Method & path | Auth | Query | Notes | +|---|---|---|---| +| `GET /facilities/eligible` | any signed-in user | `capabilityType?` (`lab_test`\|`medication_dispense`), `detail?`, `near=lat,lng`, `radiusKm?`, `limit?` (1–200, default 50) | PRD FR-13 / Phase 5 issue #74. Qualified facilities, optionally sorted ascending by distance from `near` (haversine, default radius 50 km when `near` is given without `radiusKm`). Facilities without coordinates are **never dropped** β€” they appear with `distanceKm: null`, sorted last. `radiusKm` without `near` β†’ 400. **The patient's `near` point is never stored or logged** β€” it is only ever a query parameter in the SQL statement. Response rows: `{ id, name, type, address, distanceKm }`. Auth required because a location query is patient context; no role gate (the point itself isn't persisted anywhere it could leak from). | + +## 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). + ## Not yet implemented Everything else in `ARCHITECTURE.md` Β§3's module list (`patients`, -`facilities`, `consultations`, `referrals`, `prescriptions`, `lab-orders`, -`pharmacy-orders`, `payments`, `notifications`) has no routes yet β€” Phase +`consultations`, `referrals`, `prescriptions`, `lab-orders`, +`payments`, `notifications`) has no routes yet β€” Phase 2 onward, see `ROADMAP.md`. From c5123308fc9b17992a2d95bc2eb412f83b6244f9 Mon Sep 17 00:00:00 2001 From: Oluwaferanmi18 <125797442+Oluwaferanmi18@users.noreply.github.com> Date: Mon, 19 Oct 2026 09:34:17 +0000 Subject: [PATCH 2/5] fix: restore feature content dropped by the merge with main MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The merge in 536504e resolved app.ts, ROADMAP.md and docs/api/README.md toward main's side, silently dropping content from b888485: - /facilities and /pharmacy-orders router imports + mounts (this is what failed CI β€” every fulfillment route 404'd in the integration tests) - the 2026-10-17 Phase 5 changelog entry in ROADMAP.md - the Facilities and Pharmacy orders endpoint tables in docs/api/README.md πŸ€– Generated with Codebuff Co-Authored-By: Codebuff --- ROADMAP.md | 14 ++++++++++++++ backend/src/app.ts | 7 +++++++ docs/api/README.md | 24 ++++++++++++++++++++++++ 3 files changed, 45 insertions(+) diff --git a/ROADMAP.md b/ROADMAP.md index 9f5d3189..740a0363 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -394,3 +394,17 @@ for status and issue links. observed state, so a racing second action gets 409 instead of silently overwriting). Rollback coupling proven with a real injected DB failure, not a mock. Phase 3/4 wiring explicitly left pending. +- 2026-10-17 β€” Phase 5: landed issues #73 (partial fulfillment, + item-level dispensing for multi-item prescriptions) and #74 (facility + location + distance-sorted eligible-facility query) together. New + `FulfillmentItem` model + `partially_fulfilled` order/record statuses, + `PrescriptionItem.prescriptionId` FK and `FulfillmentRecord.facilityId` + FK filled in (both were already in ARCHITECTURE Β§4/Β§4.5, schema was + behind), `FacilityProfile` location fields + index, new + `/pharmacy-orders` and `/facilities` routes, a dev seed of fictional + facility coordinates (`npx prisma db seed`), and two new integration + test files (11 tests). Payment-release rule decided and documented in + ARCHITECTURE.md Β§5.3 (no release until full fulfillment; proration + logged as an open question in Β§6.2), two new Β§6.2 gaps recorded, and a + new Β§5.4 documents the distance query + its privacy rule. Still owed by + this phase: #13–#16. diff --git a/backend/src/app.ts b/backend/src/app.ts index 399d79e5..4358112d 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -8,6 +8,8 @@ import { doctorsRouter } from './modules/doctors/routes'; import { availabilityRouter } from './modules/availability/routes'; import { appointmentsRouter } from './modules/appointments/routes'; import { paymentsRouter } from './modules/payments/routes'; +import { facilitiesRouter } from './modules/facilities/routes'; +import { pharmacyOrdersRouter } from './modules/pharmacy-orders/routes'; import { auditRouter } from './modules/audit/routes'; import { requestId } from './middleware/request-id'; @@ -36,6 +38,11 @@ export function createApp() { app.use('/availability', availabilityRouter); app.use('/appointments', appointmentsRouter); app.use('/payments', paymentsRouter); + // Phase 5 (issues #73/#74): restored after the merge with main dropped + // them β€” without these mounts every /facilities and /pharmacy-orders + // route 404s. + app.use('/facilities', facilitiesRouter); + app.use('/pharmacy-orders', pharmacyOrdersRouter); app.use('/admin', auditRouter); // Must be registered last β€” Express only routes errors here once every diff --git a/docs/api/README.md b/docs/api/README.md index 7541cfe4..6470623b 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -179,6 +179,30 @@ Payment actions (`POST /payments/intents/:id/{fund,release,refund}`) now return `409` when the intent changed state concurrently between the check and the write (previously the second write silently overwrote the first). +## Facilities (`/facilities`) β€” `backend/src/modules/facilities` + +| Method & path | Auth | Query | Notes | +|---|---|---|---| +| `GET /facilities/eligible` | any signed-in user | `capabilityType?` (`lab_test`\|`medication_dispense`), `detail?`, `near=lat,lng`, `radiusKm?`, `limit?` (1–200, default 50) | PRD FR-13 / Phase 5 issue #74. Qualified facilities, optionally sorted ascending by distance from `near` (haversine, default radius 50 km when `near` is given without `radiusKm`). Facilities without coordinates are **never dropped** β€” they appear with `distanceKm: null`, sorted last. `radiusKm` without `near` β†’ 400. **The patient's `near` point is never stored or logged** β€” it is only ever a query parameter in the SQL statement. Response rows: `{ id, name, type, address, distanceKm }`. Auth required because a location query is patient context; no role gate (the point itself isn't persisted anywhere it could leak from). | + +## 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). + ## Not yet implemented Everything else in `ARCHITECTURE.md` Β§3's module list (`patients`, From a12795907881052a6bed43859f0d553190dcdb17 Mon Sep 17 00:00:00 2001 From: Oluwaferanmi18 <125797442+Oluwaferanmi18@users.noreply.github.com> Date: Mon, 19 Oct 2026 09:56:16 +0000 Subject: [PATCH 3/5] fix(facilities): make the distance query type-safe under Prisma 7 driver adapters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prisma 7's driver adapter sends bound params with an unspecified type OID, so Postgres does the inference itself. The bounding-box predicate `fp."latitude" BETWEEN ${lat} - ${boxLat} AND ...` was therefore `unknown - unknown`, which Postgres rejects as ambiguous (SQLSTATE 42725) β€” every ?near+radiusKm query 500'd after the Prisma 7 upgrade. The box bounds are now precomputed in JS (they never depended on row data), and every numeric param that reaches the SQL carries an explicit `::float8`. Result shape, ordering and index usage are unchanged; facility-distance integration tests (incl. the perf measurement, p50 4.4ms over 3005 facilities) pass again. πŸ€– Generated with Codebuff Co-Authored-By: Codebuff --- .../modules/facilities/repositories/index.ts | 22 ++++++++++++------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/backend/src/modules/facilities/repositories/index.ts b/backend/src/modules/facilities/repositories/index.ts index 8dad9f68..332986e0 100644 --- a/backend/src/modules/facilities/repositories/index.ts +++ b/backend/src/modules/facilities/repositories/index.ts @@ -77,10 +77,16 @@ export function listEligibleFacilities(filters: EligibleFacilityFilters): Promis 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 === null ? null : radiusKm / Math.max(Math.abs(Math.cos((lat * Math.PI) / 180)), 1e-9) / KM_PER_DEG_LNG; + 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 @@ -92,13 +98,13 @@ export function listEligibleFacilities(filters: EligibleFacilityFilters): Promis : Prisma.sql`AND ( fp."latitude" IS NULL OR fp."longitude" IS NULL OR ( - fp."latitude" BETWEEN ${lat} - ${boxLat} AND ${lat} + ${boxLat} - AND fp."longitude" BETWEEN ${lng} - ${boxLng} AND ${lng} + ${boxLng} + 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}`; + radiusKm === null ? Prisma.empty : Prisma.sql`WHERE "distanceKm" IS NULL OR "distanceKm" <= ${radiusKm}::float8`; return prisma.$queryRaw(Prisma.sql` WITH scored AS ( @@ -106,10 +112,10 @@ export function listEligibleFacilities(filters: EligibleFacilityFilters): Promis 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} * asin(least(1.0, sqrt( - power(sin(radians(fp."latitude" - ${lat}) / 2), 2) - + cos(radians(${lat})) * cos(radians(fp."latitude")) - * power(sin(radians(fp."longitude" - ${lng}) / 2), 2) + 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 From ac86f2af8c85f49ed12b05326de9f3b1168d82cb Mon Sep 17 00:00:00 2001 From: Oluwaferanmi18 <125797442+Oluwaferanmi18@users.noreply.github.com> Date: Mon, 19 Oct 2026 10:04:32 +0000 Subject: [PATCH 4/5] fix(prisma): restore db seeding under Prisma 7 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prisma 7 stopped reading `prisma.seed` from package.json (the seed command now lives in prisma.config.ts's migrations section) and `new PrismaClient()` without a driver adapter throws β€” the issue #74 dev seed hit both. Seed command moved to prisma.config.ts, seed.ts now builds the same PrismaPg adapter as src/db/prisma-client.ts. Verified end-to-end against a throwaway embedded Postgres: `npx prisma db seed` runs and is idempotent (8 fictional facilities, 11 capabilities, 6 with coordinates after two consecutive runs). πŸ€– Generated with Codebuff Co-Authored-By: Codebuff --- backend/package.json | 3 --- backend/prisma.config.ts | 3 +++ backend/prisma/seed.ts | 12 +++++++++++- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/backend/package.json b/backend/package.json index af50217c..0ebd6bc7 100644 --- a/backend/package.json +++ b/backend/package.json @@ -19,9 +19,6 @@ "prisma:generate": "prisma generate", "prisma:migrate": "prisma migrate dev" }, - "prisma": { - "seed": "tsx prisma/seed.ts" - }, "dependencies": { "@docforum/escrow-sdk": "https://github.com/DocForum/docforum-escrow/releases/download/sdk-v0.2.0/docforum-escrow-sdk-0.2.0.tgz", "@prisma/adapter-pg": "^7.10.1", diff --git a/backend/prisma.config.ts b/backend/prisma.config.ts index ea6b3a24..e80e72a1 100644 --- a/backend/prisma.config.ts +++ b/backend/prisma.config.ts @@ -17,6 +17,9 @@ export default defineConfig({ schema: 'prisma/schema.prisma', migrations: { path: 'prisma/migrations', + // Prisma 7 stopped reading `prisma.seed` from package.json β€” the seed + // command lives here now (issue #74's dev seed of facility locations). + seed: 'npx tsx prisma/seed.ts', }, datasource: { // Read from process.env rather than Prisma's `env()` helper on purpose. diff --git a/backend/prisma/seed.ts b/backend/prisma/seed.ts index cda209ae..f2e24bee 100644 --- a/backend/prisma/seed.ts +++ b/backend/prisma/seed.ts @@ -12,9 +12,19 @@ // Idempotent: upserts by email / (facilityProfileId, capabilityType, // detail), so re-running it doesn't duplicate anything. import { PrismaClient } from '@prisma/client'; +import { PrismaPg } from '@prisma/adapter-pg'; import bcrypt from 'bcryptjs'; -const prisma = new PrismaClient(); +// Prisma 7 (issue #47/ADR 0006): the client requires a driver adapter β€” +// the bare `new PrismaClient()` of Prisma 6 throws at connect time. Same +// construction as src/db/prisma-client.ts, reading the connection string +// from DATABASE_URL. +const prisma = new PrismaClient({ + adapter: new PrismaPg({ + connectionString: process.env.DATABASE_URL, + connectionTimeoutMillis: 5_000, + }), +}); // Dev-only password. The seed never runs in production; these accounts // exist so a developer can log in as a facility without an admin-invite From ed91803106c17516f0c9d42896f57efaad8ed52f Mon Sep 17 00:00:00 2001 From: Codebuff Agent Date: Mon, 26 Oct 2026 09:40:55 +0000 Subject: [PATCH 5/5] fix(phase-5): unblock migrate deploy and split routing after the main merge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two collisions the merge with main left behind; both broke the suite (`prisma migrate deploy` failed in all 33 integration files, and the split scenario 409'd where issue #73 requires a 201). 1. Duplicate DDL. Both this branch's Phase 5 migration and main's later migrations added `PrescriptionItem.prescriptionId` (and its index/FK) and `FulfillmentRecord.facilityId` (and its index/FK), so the second definition failed with 42701 "column already exists". The Phase 5 migration now creates only what it actually owns β€” the two `partially_fulfilled` enum values, the facility location columns + index, and the `FulfillmentItem` table β€” with a comment naming the migrations that own the rest. 2. One record per order vs. split routing. ADR 0012 put a UNIQUE on each order FK; issue #73 requires routing an order's remaining items to a *second* facility as a new record, which that UNIQUE forbids. Uniqueness moves down one level to (order FK, facilityId) β€” the guarantee ADR 0012 was buying (no duplicated fulfillment of one order at one facility) β€” and the order relation becomes one-to-many (`fulfillments[]`) as Prisma requires once the FK is no longer unique. Amendment recorded in docs/adr/0012, ARCHITECTURE.md Β§4.7 and ARCHITECTURE_ESSENTIALS.md; new migration 20261026000000 drops the two per-column indexes and creates the composite ones. Also: facility-distance.test.mts now asserts main's documented `GET /facilities/eligible` contract β€” the public `{ facilities: [...] }` envelope carrying issue #74's `address`/`distanceKm` fields β€” since that endpoint doubles as the price-comparison query and must stay unauthenticated for main's tests; a bare `near` without `type`/`capabilityType` is rejected 400 (fail closed, never an unqualified directory), and the never-store-the- point rule is unchanged. ROADMAP's changelog entry moved into `changelog.d/73-74-partial-fulfillment-facility-distance.md` per AGENTS.md, and a line the conflict resolution had joined back onto #14's entry is split. Checks (backend/): audit:prod, i18n:check, typecheck, unit 270/270, integration 435/435 (33 files), build β€” all green. πŸ€– Generated with Codebuff Co-Authored-By: Codebuff --- ARCHITECTURE.md | 14 ++++-- ARCHITECTURE_ESSENTIALS.md | 2 +- ROADMAP.md | 23 ++------- .../migration.sql | 44 +++++++--------- .../migration.sql | 28 +++++++++++ backend/prisma/schema.prisma | 20 ++++++-- .../src/modules/facilities/services/index.ts | 1 - .../integration/facility-distance.test.mts | 50 +++++++++++++------ ...4-partial-fulfillment-facility-distance.md | 30 +++++++++++ ...0012-fulfillment-record-typed-order-fks.md | 38 +++++++++++++- 10 files changed, 178 insertions(+), 72 deletions(-) create mode 100644 backend/prisma/migrations/20261026000000_fulfillment_record_per_order_facility/migration.sql create mode 100644 changelog.d/73-74-partial-fulfillment-facility-distance.md diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index d14bdc5a..7ae85ad6 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -343,20 +343,26 @@ 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) + 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. + # `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) diff --git a/ARCHITECTURE_ESSENTIALS.md b/ARCHITECTURE_ESSENTIALS.md index 237ad74b..8c8733cb 100644 --- a/ARCHITECTURE_ESSENTIALS.md +++ b/ARCHITECTURE_ESSENTIALS.md @@ -26,7 +26,7 @@ Quick-reference only. Full reasoning lives in `ARCHITECTURE.md`. Full product sc `AvailabilitySlot` (doctor's calendar) β†’ `Appointment` (`availability_slot_id` nullable β€” a cancelled appointment releases its slot; booking safety is `claimOpenSlot`, not that column) β†’ `IntakeForm`, `Consultation` `Consultation` (`appointmentId` **UNIQUE** β€” closing a consultation is a create-once operation, `409` on a second attempt; one writer, `consultationsService.createConsultation`, reached from `POST /consultations`, `POST /appointments/:id/consultation` and the orders flow; see docs/adr/0009) β†’ (outcome=referred) β†’ `Referral` (snapshot! required `target_doctor_id` β€” routed at creation when only a specialty is named, ADR 0008; self-FK lineage `parent_referral_id`, no-show/loop/admin flags) β†’ new `Appointment` `Appointment` β†’ `Consultation` β†’ immutable `Prescription`(+`PrescriptionItem`) / `LabOrder`(+`LabResult`) -`FulfillmentRecord` (facility-owned) references exactly one order through real FKs (`prescription_id` XOR `lab_order_id`, enforced by a DB CHECK β€” docs/adr/0012) but doesn't mutate it; one row per order, carrying `FulfillmentItem` rows (Phase 5 issue #73: one row per routed order item β€” +`FulfillmentRecord` (facility-owned) references exactly one order through real FKs (`prescription_id` XOR `lab_order_id`, enforced by a DB CHECK β€” docs/adr/0012) but doesn't mutate it; one row per **(order, facility)** (ADR 0012 amendment 2026-10-26 β€” issue #73's split routing gives one order a record per facility), carrying `FulfillmentItem` rows (Phase 5 issue #73: one row per routed order item β€” dispensed/unfulfilled per item; double-dispense blocked by a DB unique index on `(orderType, dispensedKey)`; order `status` derives from items β†’ `partially_fulfilled`/`fulfilled`, applied only through the order module's service, never by the facility). Payment release requires full fulfillment β€” see ARCHITECTURE.md Β§5.3. diff --git a/ROADMAP.md b/ROADMAP.md index 0a978697..1ceae36a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -79,11 +79,12 @@ and issue links. ## Phase 5 β€” Facilities & fulfillment **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] 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. - [ ] 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. - [ ] 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`. Built on the current polymorphic shape β€” #13's ADR is untouched and still owed. -- [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 p50 β‰ˆ 5 ms / p95 β‰ˆ 7 ms over ~3 000 facilities in `backend/tests/integration/facility-distance.test.mts`. +- [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. @@ -574,19 +575,3 @@ and issue links. `PAYOUT_PRIVACY_POOL_ADDRESS`. - 2026-10-22 β€” Closed issue #19 (held-slot TTL/release job) as **not applicable as currently designed**, per the issue's own allowance for that outcome. Investigation: Phase 1's `claimOpenSlot` claims a slot directly with a conditional `UPDATE` inside the booking transaction and has no intermediate `held` status, so there is no abandoned-hold risk to bound β€” confirmed against `backend/tests/integration/booking.test.mts` (8 concurrent attempts, exactly 1 succeeds) and the absence of any `held`/`holdExpiresAt` field in `schema.prisma`. No hold state was introduced just to have something to release. Documented in the Phase 8 checklist; revisit if a multi-step booking flow (e.g. reserving a slot during Phase 2 intake) introduces a hold. - 2026-10-22 β€” Fixed the syntax error in `backend/src/modules/availability/repositories/index.ts` (TS1110, "Type expected" at line 31) that failed the local syntax check before push. The malformed type annotation in the repository's export was corrected in place; no other identifiers, exports, or formatting in that file were touched, and the held-slot investigation above is unchanged. -- 2026-10-17 β€” Phase 5: landed issues #73 (partial fulfillment, - item-level dispensing for multi-item prescriptions) and #74 (facility - location + distance-sorted eligible-facility query) together. New - `FulfillmentItem` model + `partially_fulfilled` order/record statuses, - `PrescriptionItem.prescriptionId` FK and `FulfillmentRecord.facilityId` - FK filled in (both were already in ARCHITECTURE Β§4/Β§4.5, schema was - behind), `FacilityProfile` location fields + composite - `(latitude, longitude)` index, `/pharmacy-orders` fulfillment routes and - `GET /facilities/eligible` distance params, a dev seed of fictional - facility coordinates (`npx prisma db seed`), and two new integration - test files. Payment-release rule decided and documented in - ARCHITECTURE.md Β§5.3 (no release until full fulfillment; proration - logged as an open question in Β§6.2), two new Β§6.2 gaps recorded, and a - new Β§5.4 documents the distance query + its privacy rule. The item - rows were re-seated onto ADR 0012's typed order FKs during the merge - with main. Still owed by this phase: #15–#16. 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 index 3f1baff4..2d3414e1 100644 --- 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 @@ -1,10 +1,21 @@ -/* - Warnings: +-- 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. - - Added the required column `facilityId` to the `FulfillmentRecord` table without a default value. This is not possible if the table is not empty. - - Added the required column `prescriptionId` to the `PrescriptionItem` table without a default value. This is not possible if the table is not empty. - -*/ -- CreateEnum CREATE TYPE "FulfillmentItemStatus" AS ENUM ('pending', 'dispensed', 'unfulfilled'); @@ -19,12 +30,6 @@ ALTER TABLE "FacilityProfile" ADD COLUMN "address" TEXT, ADD COLUMN "latitude" DOUBLE PRECISION, ADD COLUMN "longitude" DOUBLE PRECISION; --- AlterTable -ALTER TABLE "FulfillmentRecord" ADD COLUMN "facilityId" TEXT NOT NULL; - --- AlterTable -ALTER TABLE "PrescriptionItem" ADD COLUMN "prescriptionId" TEXT NOT NULL; - -- CreateTable CREATE TABLE "FulfillmentItem" ( "id" TEXT NOT NULL, @@ -54,20 +59,5 @@ CREATE UNIQUE INDEX "FulfillmentItem_orderType_dispensedKey_key" ON "Fulfillment -- CreateIndex CREATE INDEX "FacilityProfile_latitude_longitude_idx" ON "FacilityProfile"("latitude", "longitude"); --- CreateIndex -CREATE INDEX "FulfillmentRecord_facilityId_idx" ON "FulfillmentRecord"("facilityId"); - --- CreateIndex -CREATE INDEX "FulfillmentRecord_orderType_orderId_idx" ON "FulfillmentRecord"("orderType", "orderId"); - --- CreateIndex -CREATE INDEX "PrescriptionItem_prescriptionId_idx" ON "PrescriptionItem"("prescriptionId"); - --- AddForeignKey -ALTER TABLE "PrescriptionItem" ADD CONSTRAINT "PrescriptionItem_prescriptionId_fkey" FOREIGN KEY ("prescriptionId") REFERENCES "Prescription"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "FulfillmentRecord" ADD CONSTRAINT "FulfillmentRecord_facilityId_fkey" FOREIGN KEY ("facilityId") REFERENCES "FacilityProfile"("id") ON DELETE CASCADE ON UPDATE CASCADE; - -- 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 9f1e65e8..758999c3 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -891,7 +891,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]) @@ -934,7 +936,7 @@ model LabOrder { correctedBy LabOrder? @relation("LabOrderCorrections") result LabResult? paymentIntent PaymentIntent? - fulfillment FulfillmentRecord? + fulfillments FulfillmentRecord[] @@unique([supersedesLabOrderId]) @@index([patientProfileId, createdAt]) @@ -970,8 +972,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()) @@ -983,6 +985,16 @@ model FulfillmentRecord { // 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/modules/facilities/services/index.ts b/backend/src/modules/facilities/services/index.ts index ca853a49..ce643fbd 100644 --- a/backend/src/modules/facilities/services/index.ts +++ b/backend/src/modules/facilities/services/index.ts @@ -502,4 +502,3 @@ export async function createFacilityAccount( function isUniqueViolation(error: unknown): boolean { return typeof error === 'object' && error !== null && 'code' in error && (error as { code: unknown }).code === 'P2002'; } - diff --git a/backend/tests/integration/facility-distance.test.mts b/backend/tests/integration/facility-distance.test.mts index 5b043d61..61571000 100644 --- a/backend/tests/integration/facility-distance.test.mts +++ b/backend/tests/integration/facility-distance.test.mts @@ -108,8 +108,9 @@ beforeAll(async () => { app = appModule.createApp(); prisma = dbModule.prisma; - // The endpoint only requires authentication β€” no signup dance needed, - // and no patient location is ever attached to this account. + // 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`, @@ -134,8 +135,12 @@ describe('distance-sorted eligible facilities (issue #74)', () => { .set('Authorization', `Bearer ${patientToken}`); expect(res.status).toBe(200); - const withCoords = res.body.filter((row: { distanceKm: number | null }) => row.distanceKm !== null); - const withoutCoords = res.body.filter((row: { distanceKm: number | null }) => row.distanceKm === null); + // 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++) { @@ -150,13 +155,21 @@ describe('distance-sorted eligible facilities (issue #74)', () => { // matching capability here; no-location facility appears, sorted last. expect(withoutCoords).toHaveLength(1); expect(withoutCoords[0].name).toContain('No Location Pharmacy'); - expect(res.body[res.body.length - 1].distanceKm).toBeNull(); + 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 res.body) { - expect(Object.keys(row).sort()).toEqual(['address', 'distanceKm', 'id', 'name', 'type']); + 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)); @@ -167,12 +180,13 @@ describe('distance-sorted eligible facilities (issue #74)', () => { .get(`/facilities/eligible?capabilityType=medication_dispense&near=${CENTER.lat},${CENTER.lng}&radiusKm=10`) .set('Authorization', `Bearer ${patientToken}`); expect(res.status).toBe(200); - const names = res.body.map((row: { name: string }) => row.name); + 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: string) => name.startsWith('Far Pharmacy'))).toBe(false); + expect(names.some((name) => name.startsWith('Far Pharmacy'))).toBe(false); // Out-of-radius-to-unknown: still present, sorted last. - expect(names.some((name: string) => name.startsWith('No Location Pharmacy'))).toBe(true); - expect(res.body[res.body.length - 1].distanceKm).toBeNull(); + 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 () => { @@ -180,9 +194,10 @@ describe('distance-sorted eligible facilities (issue #74)', () => { .get('/facilities/eligible?capabilityType=lab_test') .set('Authorization', `Bearer ${patientToken}`); expect(res.status).toBe(200); - const types = new Set(res.body.map((row: { type: string }) => row.type)); + const rows = res.body.facilities as { type: string; distanceKm: null }[]; + const types = new Set(rows.map((row) => row.type)); expect([...types]).toEqual(['lab']); - expect(res.body.every((row: { distanceKm: null }) => row.distanceKm === null)).toBe(true); + expect(rows.every((row) => row.distanceKm === null)).toBe(true); }); it('validates the query contract', async () => { @@ -196,8 +211,13 @@ describe('distance-sorted eligible facilities (issue #74)', () => { .set('Authorization', `Bearer ${patientToken}`); expect(radiusAlone.status).toBe(400); - const unauthenticated = await request(app).get('/facilities/eligible?near=6.5,3.4'); - expect(unauthenticated.status).toBe(401); + // 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 () => { 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.