Quick-reference only. Full reasoning lives in ARCHITECTURE.md. Full product scope in PRD.md. If this file and ARCHITECTURE.md conflict, ARCHITECTURE.md wins and this file is stale — fix it in the same PR.
- Backend: Node.js + TypeScript + Express/Fastify — modular monolith, not microservices.
- DB: PostgreSQL via Prisma 7 (
backend/prisma/schema.prisma= source of truth for models). v7 has no Rust query engine: the client is built with a driver adapter (@prisma/adapter-pg+ node-postgres) inbackend/src/db/prisma-client.ts— barenew PrismaClient()throws — and the CLI's datasource URL lives inbackend/prisma.config.ts, not in the schema. Keep theprisma-client-jsgenerator: it still writes tonode_modules/@prisma/client, so imports and this backend's CommonJS runtime are unchanged (seedocs/adr/0006). Requires Node >= 20.19. - Auth: JWT access + refresh, roles =
patient | doctor | facility | admin. The refresh cookie isSameSite=Nonecross-site, so/auth/refresh+/auth/logoutrequire anX-CSRF-Tokenheader (a per-session token bound into the refresh JWT) and an allowlistedOrigin— seebackend/src/middleware/csrf.ts. CORS is an explicit per-environment allowlist (CORS_ORIGINS). - Frontend: React + TypeScript + Vite, React Query + Zustand.
- Video/voice, SMS/email, file storage: third-party, behind an interface — never build custom.
- Jobs: keep it simple (cron/interval) until a real job exists; don't pre-install a queue. Existing interval jobs: login-throttle prune (
src/server.ts), patient-export worker/cleanup (src/jobs/patient-exports.ts) and deleted-account tombstone purge (src/jobs/account-purge.ts), all started only fromserver.ts. - New module holding patient data? Register a patient-export section (ARCHITECTURE.md §3.1) — right of access must stay complete.
- File storage = Amazon S3, private bucket (
docs/adr/0014, issue #16), only throughsrc/utils/object-storage.ts. Never return an object key or a public/presigned URL: clinical files are served by the API after an access check.
- Never mutate a
PrescriptionorLabOrderafter issuance. Corrections = new row withsupersedes_*_id. - Availability booking must use a DB-level conditional update/transaction. No app-level "check then write" for slot booking — that's the #1 predicted break (double-booking).
- A
Referralsnapshots intake + notes at creation time. Never let it read live from the originalConsultation— later edits must not rewrite what a specialist already saw. - Cross-module access only through a module's service interface. Never import another module's
repositories/*directly. - Facilities write to their own
FulfillmentRecordonly — never directly toPrescription.status/LabOrder.status. - Every module =
routes/ controllers/ services/ repositories/. Keep that shape. - Mutable-record edits use optimistic concurrency (Phase 8, issue #49). Individual-resource read returns a strong version
ETag("4"); PATCH/PUT must send it inIf-Match— missing is428, weak/list/garbage is400, stale is412. Write it as ONE conditionalUPDATE ... WHERE id = ? AND version = ?that incrementsversion(Prisma:updateManyAndReturn); read-compare-write is forbidden. Status transitions that are already conditional (booking, payment status, verification) keep their own guard, don't get a secondIf-Matchone, but still bump + republish the version. Append-onlyPrescription/LabOrderare excluded. See ARCHITECTURE.md §5.4.
User (including locale, default en, Phase 9 #133) → PatientProfile | DoctorProfile | FacilityProfile; User → AdminInvite (Phase 8, issue #39 — hashed/expiring/single-use; User.deactivatedAt soft-deactivates an account, refusing login/refresh/access tokens without deleting anything)
User (guardian) —< Guardianship >— PatientProfile (dependent, userId null) (Phase 8, issue #42 — docs/adr/0011; create-only, access ends at AGE_OF_MAJORITY_YEARS; authorise any act-for-a-patient path through patientsService.resolveActingProfile).
DoctorProfile —< DoctorSpecialty >— Specialty
AvailabilitySlot (doctor's calendar) → Appointment (availability_slot_id nullable — a cancelled appointment releases its slot; booking safety is claimOpenSlot, not that column) → IntakeForm, Consultation
PatientProfile → WaitlistEntry (optional doctor/specialty/facility capability/slot targets; time-boxed offer status)
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)
LabResult (issue #16) — one per LabOrder, attached once by the order's own facility; the file is in object storage under resultObjectKey, readable only by the patient, ordering doctor and fulfilling facility.
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.
User → EmailVerificationToken (Phase 8, issue #34 — hashed/expiring, single-use; User.emailVerifiedAt gates booking, consultation/referral mutations and payment mutations, never login/browsing).
User → AccessLog (Phase 8, issue #45 — PHI read-access log: who viewed which clinical record, when; fire-and-forget writes, fail-open).
LoginAttempt (Phase 8, issue #35 — Postgres-backed login throttle buckets, one per normalised email and per client IP; counters move only via a conditional upsert).
PatientProfile → PatientExport → PatientExportChunk (Phase 8, issue #40 — right-of-access export jobs + their chunked JSON bundle; each module contributes a section via its own service, registered in src/export-sections.ts — see ARCHITECTURE.md §3.1).
Account deletion (Phase 8, issue #41 — docs/adr/0010): anonymise in place (User.deletedAt, PatientProfile.anonymizedAt), never cascade. PatientProfile.userId is ON DELETE RESTRICT; new patient-linked clinical tables must be RESTRICT too and register a retention hold in src/retention-holds.ts.
FacilityProfile → WalletLink (payout address) ; PatientProfile → PatientWalletLink (payer address, ADR 0005) ; Prescription or LabOrder → exactly one typed PaymentIntent FK (issue #85), whose amount is in the smallest unit of a per-environment allowlisted assetCode (issue #79)
Mutable records above (plus Appointment and PaymentIntent) carry a version int for the ETag/If-Match convention — hard rule 7, ARCHITECTURE.md §5.4. Mutable profile fields a new route exposes need a conditional write, not just a .update().
- Custodial v1:
docforum-coreholds its own Stellar payer + releaser identities (PLATFORM_PAYER_SECRET/PLATFORM_RELEASER_SECRETenv vars). Patients never hold or sign with a wallet — the only reason this is possible without violatingdocforum-web's "no direct Stellar calls" hard rule. WalletLinkis scoped to the facility (the on-chain payee), not the patient.- Each issued order stores its selected facility and clinician-entered total in XLM stroops; a database check requires exactly one PaymentIntent order FK.
release/refundare admin-triggered HTTP actions in v1 (POST /payments/intents/:id/{release,refund}) — there's noFulfillmentRecord.status == fulfilledto trigger off of yet. Don't treat this as the final design.- The actual Stellar contract calls go through
@docforum/escrow-sdk(backend/src/modules/payments/services/escrow-sdk-loader.ts— dynamicimport(), the SDK is ESM-only and this backend is CommonJS). Never reimplement contract-calling logic in this repo. The SDK used today (0.2.0) has no prepare call, so prepare returns503 ESCROW_SDK_PREPARE_UNSUPPORTEDuntil the 0.3.0 prepare/submit call (docforum-escrow #58) lands.
No routing rule for a referral with no specific target doctorResolved (issue #6,docs/adr/0008-referral-routing-without-target-doctor.md): a specialty-only referral is routed at creation to the least-loaded eligible doctor (verified, holds the specialty, not the referring doctor, account not deactivated; fewestpending+acceptedreferrals, ties broken by doctor id).target_doctor_idstays NOT NULL — zero eligible doctors fails the creation with422 REFERRAL_NO_ELIGIBLE_TARGETand writes no row. PRD §8.2.2's "waiting for a specialist" state is still not built (deferred on purpose: it needs a nullable target, which this ADR rejects).- No time-based expiry/TTL job for held slots or pending referrals — the referral no-show cascade (#67) now reaches
expired, but nothing expires a referral (or a held slot) purely by age. Issue #8 / #19. AuditEventexists (§4.10 of ARCHITECTURE.md) and records booking (both booking paths), doctor verification, payment fund/release/refund, the patient's referral accept/decline and the referral admin actions — auth (the admin login-throttle clear), the referral's creation + cascade transitions, and the remaining Phase 4 order transitions are still unaudited. PRD §7 is not satisfied yet.Referral.snapshotIntakeIdhas no column —IntakeFormdoesn't exist yet (issue #1); the notes half of the snapshot rule is real and enforced.PolymorphicResolved (Phase 5 prerequisite, issue #13,FulfillmentRecord.order_idis a sketch, not final — may need splitting into two tablesdocs/adr/0012-fulfillment-record-typed-order-fks.md): one table, no polymorphism —order_type/order_idare replaced by realprescription_id/lab_order_idFKs (each unique, a CHECK requires exactly one) plus thefacility_idFK, allRESTRICT. Splitting into two tables was considered and rejected. Consequences #14/#15 must honor: the row is created once per order by the facility-selection path, andfulfilledis reachable only through the facility's own conditional update (hard rule 5, ARCHITECTURE.md §5.3 step 5).AuditEventfoundation exists (Phase 8, issue #43 — ARCHITECTURE.md §4.10): append-only (DB triggers), written byauditService.record(tx, …)inside the same transaction as the change. Wired for booking (direct and referral-origin), doctor verification, payment fund/release/refund, referral accept/decline and referral admin actions (entity_type: 'Referral') — Phase 4 transitions must call it when they're built. There is one audit table: referral actions go into it, not into a second one. Never put PHI inmetadata.- No email/SMS provider integrated yet (Phase 6, issue #17) — email verification (issue #34) therefore can't deliver its link on a hosted deployment;
backend/scripts/mark-email-verified.mjsis the interim operator path. - One doctor = one calendar assumed; multi-facility doctors not modeled.
- Non-custodial payments are only half-built: prepare exists (issue #80), submit/fee-bump does not, and the SEP-10 patient-wallet link flow that creates
PatientWalletLinkrows is not implemented.PaymentIntent.refundAfteruses the intent'screatedAtas the order-expiry anchor because orders have no expiry field yet (Phase 4). @docforum/escrow-sdk0.2.0 has no prepare call, so/payments/intents/:id/preparereturns503 ESCROW_SDK_PREPARE_UNSUPPORTEDagainst the real SDK until docforum-escrow publishes SDK 0.3.0 (docforum-core #32 / docforum-escrow #58). Integration tests exercise the endpoint with a mocked SDK.
- No custom video/WebRTC.
- No ML-based doctor-matching/recommendation.
- No configurable RBAC matrix — 4 fixed roles only.
- No chat delivery receipts/typing indicators — plain message log.
- No custom scheduling recurrence engine — fixed-length slot generation only.
- No microservice split, no separate CI/repos per module.
- Backend modules:
backend/src/modules/<name>/{routes,controllers,services,repositories} - Data model source of truth:
backend/prisma/schema.prisma - Frontend:
frontend/src/{pages,components,features,hooks,services,store,types} - Roadmap / status:
ROADMAP.md— must be updated on every contribution that changes scope or completes work.
- EscrowEventCursor stores one pagination cursor per contract deployment; configure each historical deployment as ESCROW_CONTRACTS=CONTRACT_ID@START_LEDGER,...
- EscrowChainEvent stores deduplicated Soroban events. The admin endpoint is GET /admin/payments/escrow-events.
- Unmatched release/refund events are high-severity alerts; RPC retention loss is surfaced as catchupError and must be reconciled explicitly.