Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 22 additions & 5 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ Canonical source of truth: `backend/prisma/schema.prisma`. This section is the h

**Phase 1 models are real** (User, PatientProfile, DoctorProfile, Specialty, DoctorSpecialty, FacilityProfile, FacilityCapability, AvailabilitySlot, Appointment) — migrated via `backend/prisma/migrations/20260914110638_init/`. **Phase 3 added the real `Referral` and the `Consultation`↔appointment link** — migrated via `backend/prisma/migrations/20261016214929_referral_phase3/`; §4.3 and §4.4 describe what actually shipped, not a sketch. The audit trail (`AuditEvent`, §4.10) is the Phase 8 one (issue #43, `20261017212335_add_audit_event`) — the referral admin actions live in it rather than in a second table. Everything else below (§4.5 onward) is still the Phase 0 structural placeholder.

Three intentional deviations from the original sketches, all flagged in the schema file itself: `Appointment.intakeId` is deferred (nullable-by-omission, not yet a column) since `IntakeForm` doesn't exist until Phase 2; `PatientProfile.guardianUserId` is a plain string with no FK relation, since the minor/dependent consent model (§8.2.5 in PRD.md) is still an open question, not resolved here; and `Referral.snapshotIntakeId` likewise has no column yet (there is no `IntakeForm` to snapshot) — the intake half of the snapshot rule cannot be violated while there is no intake data at all, and the column must be added (as a copy) when issue #1 lands.
Three intentional deviations from the original sketches, all flagged in the schema file itself: `Appointment.intakeId` is deferred (nullable-by-omission, not yet a column) since `IntakeForm` doesn't exist until Phase 2; `PatientProfile.guardianUserId` was a plain string with no FK relation while the minor/dependent consent model (PRD §8.2.5) was open — **replaced in Phase 8 (issue #42, `docs/adr/0011`)** by the `Guardianship` relation (§4.1); and `Referral.snapshotIntakeId` likewise has no column yet (there is no `IntakeForm` to snapshot) — the intake half of the snapshot rule cannot be violated while there is no intake data at all, and the column must be added (as a copy) when issue #1 lands.

### 4.1 User / Role model
```
Expand Down Expand Up @@ -142,12 +142,29 @@ EmailVerificationToken (Phase 8 issue #34)
UPDATE (consumed_at IS NULL), same single-use shape as §5.1's booking
guarantee, so two concurrent redemptions can't both win.

PatientProfile (1:1 User)
id, user_id (FK -> User, ON DELETE RESTRICT since issue #41 — was CASCADE)
full_name, date_of_birth, guardian_user_id (nullable — see PRD edge case: minors)
PatientProfile (1:1 User, or 0:1 for a dependent)
id, user_id (nullable since issue #42 — null = a guardian-owned dependent
with no login; still unique. FK -> User, ON DELETE RESTRICT since issue
#41 — was CASCADE), full_name, date_of_birth
anonymized_at (nullable — issue #41; set with full_name -> 'Deleted patient',
date_of_birth/guardian_user_id -> null. The row stays so Appointment and
date_of_birth -> null. The row stays so Appointment and
PaymentIntent keep their FK; both of those reference it ON DELETE RESTRICT.)
# CHECK (user_id IS NOT NULL OR date_of_birth IS NOT NULL): a dependent
always has a DOB, because guardian access ends at the age of majority.
# guardian_user_id (unenforced string) was dropped in issue #42; the
migration refuses to run if any row still had a value.

Guardianship (Phase 8, issue #42 — docs/adr/0011)
id, guardian_user_id (FK -> User, RESTRICT), dependent_profile_id (FK -> PatientProfile, RESTRICT)
created_at, revoked_at (nullable)
# unique (guardian_user_id, dependent_profile_id) — no duplicate pairs.
# Created only together with a new dependent profile (POST
/patients/me/dependents); there is no way to link an existing profile.
# Active = revoked_at IS NULL AND the dependent is under
AGE_OF_MAJORITY_YEARS (UTC calendar date, checked at request time).
Never deleted — ending access sets revoked_at, or happens by age.
# All dependent-scoped access goes through
patients/services resolveActingProfile (booking, appointment listing).

DoctorProfile (1:1 User)
id, user_id, full_name, verification_status (enum: pending|verified|rejected)
Expand Down
1 change: 1 addition & 0 deletions ARCHITECTURE_ESSENTIALS.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ Quick-reference only. Full reasoning lives in `ARCHITECTURE.md`. Full product sc

## Data model map (see ARCHITECTURE.md §4 for full field lists)
`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`
`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`
Expand Down
2 changes: 1 addition & 1 deletion PRD.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ The core value proposition: **collapse a multi-visit, multi-queue hospital journ
2. Doctor **refers to a specialty with zero verified doctors on the platform** — dead end. **Resolved at the point of referral, 2026-10-21 (Phase 3, 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; when no verified doctor holds the specialty, the creation fails with `422 REFERRAL_NO_ELIGIBLE_TARGET` and **no referral row is written**, so the patient is never handed a referral with no owner. The distinct patient-facing "waiting for a specialist" state that issue #5's DoD asked for is *still not built* — it requires a referral to exist before it has a target, which the ADR deliberately rejects; that stays a known gap, not a silent substitution.
3. Patient **cancels or no-shows** after a referral was already created downstream. **Resolved 2026-10-17 (issue #67):** the appointment status transition and the referral state change happen in one transaction, so a referral can never be left `accepted` behind a dead appointment. Cancelled → referral returns to `pending` (slot released, re-bookable, same snapshot); first no-show → `pending` with `noShowCount = 1`; second no-show → `expired` with `referring_doctor_notified_at` set (threshold is a deliberately boring default, not a tuned one). Every write is conditional on the referral still pointing at that appointment, so a doubled hook is a no-op — integration-tested in `tests/integration/referral-cascade.test.mts`.
4. **Multiple simultaneous referrals** — can a single consultation spawn more than one referral (e.g., "see both cardiology and endocrinology")? **Resolved 2026-10-17 (issue #66, decision recorded in `docs/adr/0006-multiple-referrals-per-consultation.md`):** yes — an atomic all-or-none batch (`POST /referrals/from-consultation/:consultationId`, equivalently `POST /consultations/:id/referrals`) whose referrals then live **independently** (accept/decline each on its own; no bundle lifecycle). Duplicate doctor *or* specialty within a batch, or against a referral already made from that consultation, fails the whole batch. Each target gets its own snapshot copy, and `GET /referrals/mine` groups them by source consultation for the patient's "one visit, several referrals" view.
5. **Minor/dependent patients** — booking on behalf of a child or dependent; consent and account ownership model undefined.
5. **Minor/dependent patients** — booking on behalf of a child or dependent; consent and account ownership model undefined. **Resolved (re-scoped) 2026-10-17, issue #42 — `docs/adr/0011`:** dependents are guardian-owned patient profiles with no login, created (never linked) by a patient guardian; the guardian can list them, book for them and see their appointments; access ends automatically at the age of majority (configurable, default 18) and the history stays with the dependent's own profile. Still open, by design: verifying guardianship beyond self-assertion, the at-majority account-claim flow, and any care a guardian shouldn't see before majority (jurisdiction-specific consent).
6. **Doctor moonlighting across facilities** — same doctor, multiple hospitals/clinics, different availability per location — v1 assumes one doctor = one availability calendar, which may be wrong.
7. **Lab result requiring urgent follow-up** — abnormal result with no automatic escalation path back to a doctor.
8. **Prescription drug interaction** — no interaction/allergy checking in v1; is that acceptable to ship, or a blocking safety requirement?
Expand Down
1 change: 1 addition & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ roadmap. See each phase section below for status and issue links.
- [ ] Patient data export (right of access) as a complete, machine-readable bundle. `POST /patients/me/export` + `GET /patients/me/export/:id` (+ `/download`), owner-only; an exporter registry where each module contributes its own section through its service (ARCHITECTURE.md §3.1 — `account`, `profile`, `appointments`, `payments` today); versioned JSON with `generatedAt`; generated by an interval worker in `src/jobs/patient-exports.ts` and stored as bounded chunks in Postgres (`PatientExport`/`PatientExportChunk`, §4.11 — no object-storage provider exists yet, see §6.3.4); expiry + cleanup after `PATIENT_EXPORT_TTL_HOURS`. Tracked as [issue #40](https://github.com/DocForum/docforum-core/issues/40) (High, 200 pts) — **in review**, branch `feat/40-patient-data-export`. Later modules (referrals, prescriptions, lab orders) must register their own section when they land; a verified guardian exporting a dependent's data is left to issue #42's model.
- [ ] `AuditEvent` table and write path — **foundation done, Phase 3/4 wiring pending.** Append-only `AuditEvent` (DB triggers reject UPDATE/DELETE/TRUNCATE), `auditService.record()` that must run inside the state change's own transaction, request ids on every response, and `GET /admin/audit-events` (entity/actor/date filters, keyset cursor). Wired into appointment booking (both the direct and the referral-origin path), doctor verification, payment fund/release/refund, the patient's referral accept/decline and the referral admin actions (`referral.reassign` / `referral.cancel`, Phase 3 issue #68 — recorded as `entityType: 'Referral'` rows in this one table, not a second referral-specific table). Still unaudited: the referral's *creation* and the cascade-driven `completed`/`expired` transitions (no actor to attribute them to), the remaining Phase 4 order transitions, and the login-throttle admin clear (console-only). Tracked as [issue #43](https://github.com/DocForum/docforum-core/issues/43) (High, 200 pts) — **in review**, branch `feat/43-auditevent-foundation`.
- [ ] Account deletion with clinical-record retention: `DELETE /patients/me` (password re-confirmation, throttled) anonymises the identity in place and keeps appointments + payment intents intact; `PatientProfile.userId` FK CASCADE → RESTRICT (migration); interval purge of record-free tombstones (`src/jobs/account-purge.ts`, holds in `src/retention-holds.ts`). Decisions in `docs/adr/0010` — the **legal retention period for clinical/order/payment records stays an open decision** (kept indefinitely until set). Tracked as [issue #41](https://github.com/DocForum/docforum-core/issues/41) (High, 200 pts) — **in review**, branch `feat/41-account-deletion-retention`. Follow-up when #40 also merges: delete a deleted patient's `PatientExport` rows inside the anonymisation transaction (ADR 0010 table).
- [ ] Guardian and dependent accounts: booking on behalf of a minor. Dependents are guardian-owned `PatientProfile`s with no login, linked through a real `Guardianship` relation that replaces the unenforced `guardianUserId` string (migration refuses to run over legacy values); `POST`/`GET /patients/me/dependents` (create-only — no linking of existing profiles); `patientProfileId` on `POST /appointments` (and `GET /appointments/mine`) authorised in the service layer before the unchanged booking transaction; access ends automatically at `AGE_OF_MAJORITY_YEARS` (default 18). Decisions in `docs/adr/0011`; PRD §8.2.5 marked resolved/re-scoped. Tracked as [issue #42](https://github.com/DocForum/docforum-core/issues/42) (High, 200 pts) — **in review**, branch `feat/42-guardian-dependent-accounts`. Follow-ups: majority account-claim flow, guardian/admin revoke endpoints, guardianship verification beyond self-assertion.
- [ ] JWT refresh revocation list (ARCHITECTURE.md §6.1.4). Tracked as [issue #18](https://github.com/DocForum/docforum-core/issues/18) (Medium, 150 pts) — unblocked now, operates on Phase 1's auth module.
- [ ] Held-slot TTL/release job (ARCHITECTURE.md §6.2.1). Tracked as [issue #19](https://github.com/DocForum/docforum-core/issues/19) (Medium, 150 pts) — unblocked now, operates on Phase 1's `AvailabilitySlot`.
- [x] Access-control test suite: who may read intake, consultation and payment data. Integration tests (`tests/integration/access-control.test.mts`) establishing the test harness and access-control patterns for the three PHI data categories; intake/consultation test cases are scaffolded as placeholders until Phase 2/3 routes exist, payment-intent access cases exercise the live admin-gated routes. Tracked as [issue #44](https://github.com/DocForum/docforum-core/issues/44) — **Done**.
Expand Down
4 changes: 4 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ PATIENT_EXPORT_MAX_ATTEMPTS=3
ACCOUNT_PURGE_GRACE_HOURS=24
ACCOUNT_PURGE_INTERVAL_SECONDS=21600

# Guardians and dependents (Phase 8, issue #42, docs/adr/0011). Guardian
# access ends when a dependent reaches this age (UTC calendar date).
AGE_OF_MAJORITY_YEARS=18

# Payments (Phase 5.5 — custodial v1, see docs/adr/0004-custodial-payments-v1.md)
# Testnet only (docforum-escrow hard rule 4) — real secret keys, never commit
# a filled-in .env. Generate + fund your own with:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
-- Phase 8, issue #42 (docs/adr/0011): replace the unenforced
-- PatientProfile.guardianUserId string with a real Guardianship relation.
--
-- Safety: no code ever wrote guardianUserId, so every row should be NULL.
-- If any isn't, its meaning is unknown (the old field was explicitly
-- "don't build on this"), so stop here and let an operator decide rather
-- than silently dropping or reinterpreting it.
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM "PatientProfile" WHERE "guardianUserId" IS NOT NULL) THEN
RAISE EXCEPTION 'PatientProfile.guardianUserId has non-null values; resolve them before applying guardian_dependent_profiles (see docs/adr/0011).';
END IF;
END $$;

-- AlterTable
ALTER TABLE "PatientProfile" DROP COLUMN "guardianUserId",
ALTER COLUMN "userId" DROP NOT NULL;

-- CreateTable
CREATE TABLE "Guardianship" (
"id" TEXT NOT NULL,
"guardianUserId" TEXT NOT NULL,
"dependentProfileId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"revokedAt" TIMESTAMP(3),

CONSTRAINT "Guardianship_pkey" PRIMARY KEY ("id")
);

-- CreateIndex
CREATE INDEX "Guardianship_dependentProfileId_idx" ON "Guardianship"("dependentProfileId");

-- CreateIndex
CREATE UNIQUE INDEX "Guardianship_guardianUserId_dependentProfileId_key" ON "Guardianship"("guardianUserId", "dependentProfileId");

-- AddForeignKey
ALTER TABLE "Guardianship" ADD CONSTRAINT "Guardianship_guardianUserId_fkey" FOREIGN KEY ("guardianUserId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "Guardianship" ADD CONSTRAINT "Guardianship_dependentProfileId_fkey" FOREIGN KEY ("dependentProfileId") REFERENCES "PatientProfile"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

-- A dependent (no login) must carry a date of birth: the age-of-majority
-- rule that ends guardian access is computed from it. Profiles with a login
-- are unaffected (dateOfBirth stays optional for them).
ALTER TABLE "PatientProfile" ADD CONSTRAINT "PatientProfile_dependent_has_dob"
CHECK ("userId" IS NOT NULL OR "dateOfBirth" IS NOT NULL);
Loading
Loading