diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 1ee76d86..7b6b8f07 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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 ``` @@ -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) diff --git a/ARCHITECTURE_ESSENTIALS.md b/ARCHITECTURE_ESSENTIALS.md index ce21c38a..7aa0b414 100644 --- a/ARCHITECTURE_ESSENTIALS.md +++ b/ARCHITECTURE_ESSENTIALS.md @@ -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` diff --git a/PRD.md b/PRD.md index 1c0c6c85..ee26864c 100644 --- a/PRD.md +++ b/PRD.md @@ -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? diff --git a/ROADMAP.md b/ROADMAP.md index c9a4d946..88d92df7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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**. diff --git a/backend/.env.example b/backend/.env.example index b01b062c..d4397577 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -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: diff --git a/backend/prisma/migrations/20261021130000_guardian_dependent_profiles/migration.sql b/backend/prisma/migrations/20261021130000_guardian_dependent_profiles/migration.sql new file mode 100644 index 00000000..c6c7002d --- /dev/null +++ b/backend/prisma/migrations/20261021130000_guardian_dependent_profiles/migration.sql @@ -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); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index b52f32d4..a453ad75 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -155,6 +155,8 @@ model User { // Phase 8 (issue #45): PHI read-access log rows this user wrote (as viewer). // Opposite of AccessLog.accessedByUser. accessLogs AccessLog[] + // Phase 8 (issue #42, docs/adr/0011): dependents this user is guardian of. + guardianships Guardianship[] @@index([role]) } @@ -268,28 +270,29 @@ model RefreshSession { } model PatientProfile { - id String @id @default(uuid()) - userId String @unique - fullName String - dateOfBirth DateTime? - // Nullable, no FK relation on purpose — the minor/dependent consent and - // account-ownership model is an explicit open PRD question (§8.2.5), not - // resolved here. Don't build on top of this field assuming it means - // anything enforced yet. - guardianUserId String? + id String @id @default(uuid()) + // Null for a dependent (Phase 8, issue #42, docs/adr/0011): a + // guardian-owned profile with no login of its own. Still unique, so a + // login owns at most one profile. A DB CHECK (see the + // guardian_dependent_profiles migration) requires dateOfBirth whenever + // userId is null — the age-of-majority rule depends on it. + userId String? @unique + fullName String + dateOfBirth DateTime? // Phase 8 (issue #41, docs/adr/0010): set when the owner deleted their - // account. fullName/dateOfBirth/guardianUserId are anonymised at the same - // moment; the row itself stays so appointments and payments keep their - // foreign key. getMyPatientProfile refuses an anonymised profile. - anonymizedAt DateTime? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + // account. fullName/dateOfBirth are anonymised at the same moment; the row + // itself stays so appointments and payments keep their foreign key. + // getMyPatientProfile refuses an anonymised profile. + anonymizedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt // RESTRICT, not CASCADE (issue #41): a patient's User row is never // hard-deleted while this profile exists — deletion is anonymisation // (ADR 0010), and an accidental user delete must fail loudly rather than - // take the clinical identity anchor with it. - user User @relation(fields: [userId], references: [id], onDelete: Restrict) + // take the clinical identity anchor with it. Optional since issue #42: a + // dependent profile has no login. + user User? @relation(fields: [userId], references: [id], onDelete: Restrict) appointments Appointment[] paymentIntents PaymentIntent[] prescriptions Prescription[] @@ -301,6 +304,9 @@ model PatientProfile { // Phase 8 (issue #45): who viewed this patient's records. accessLogs AccessLog[] + + // Phase 8 (issue #42, docs/adr/0011): guardians of this (dependent) profile. + guardians Guardianship[] } // Phase 8 (issue #40): right-of-access export jobs. One row per request; @@ -370,6 +376,30 @@ model PatientExportChunk { @@id([exportId, attempt, seq]) } +// Phase 8 (issue #42, docs/adr/0011): replaces the unenforced +// PatientProfile.guardianUserId string. A guardian (a patient User) owns a +// dependent PatientProfile they created; there is deliberately no way to +// link an existing profile. Active = revokedAt is null AND the dependent is +// under AGE_OF_MAJORITY_YEARS (checked at request time from the DOB — no +// job flips anything). Never deleted: ending a guardianship sets revokedAt, +// so who had access, and when, stays on record. +model Guardianship { + id String @id @default(uuid()) + guardianUserId String + dependentProfileId String + createdAt DateTime @default(now()) + revokedAt DateTime? + + guardian User @relation(fields: [guardianUserId], references: [id], onDelete: Restrict) + dependentProfile PatientProfile @relation(fields: [dependentProfileId], references: [id], onDelete: Restrict) + + // One row per (guardian, dependent) pair — no duplicate or ambiguous + // relationships. More than one guardian per dependent is allowed by the + // model (two parents), though v1's API only creates one. + @@unique([guardianUserId, dependentProfileId]) + @@index([dependentProfileId]) +} + model DoctorProfile { id String @id @default(uuid()) userId String @unique diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts index df531677..c4436b72 100644 --- a/backend/src/config/index.ts +++ b/backend/src/config/index.ts @@ -146,6 +146,12 @@ export const config = { graceHours: Number(process.env.ACCOUNT_PURGE_GRACE_HOURS ?? 24), intervalSeconds: Number(process.env.ACCOUNT_PURGE_INTERVAL_SECONDS ?? 6 * 60 * 60), }, + // Guardians and dependents (Phase 8, issue #42, docs/adr/0011). Guardian + // access ends automatically when the dependent reaches this age (UTC + // calendar date, checked at request time). + patients: { + ageOfMajorityYears: Number(process.env.AGE_OF_MAJORITY_YEARS ?? 18), + }, // Explicit, per-environment CORS allowlist (Phase 8, issue #37). A single // configured origin string was one typo away from "reflect whatever origin // asked"; an allowlist makes every permitted frontend a deliberate entry. diff --git a/backend/src/modules/appointments/controllers/index.ts b/backend/src/modules/appointments/controllers/index.ts index fea707ab..6a0004dc 100644 --- a/backend/src/modules/appointments/controllers/index.ts +++ b/backend/src/modules/appointments/controllers/index.ts @@ -1,20 +1,21 @@ import type { Request, Response } from 'express'; import * as appointmentsService from '../services'; -import { bookSlotSchema, updateStatusSchema } from '../validation'; +import { bookSlotSchema, listMineQuerySchema, updateStatusSchema } from '../validation'; import { HttpError } from '../../../http-error'; import { auditActor } from '../../../middleware/request-id'; import { getParam } from '../../../request-params'; export async function book(req: Request, res: Response) { if (!req.user) throw new HttpError(401, 'Authentication required.'); - const { availabilitySlotId } = bookSlotSchema.parse(req.body); - const appointment = await appointmentsService.bookSlot(auditActor(req), availabilitySlotId); + const { availabilitySlotId, patientProfileId } = bookSlotSchema.parse(req.body); + const appointment = await appointmentsService.bookSlot(auditActor(req), availabilitySlotId, patientProfileId); res.status(201).json(appointment); } export async function listMine(req: Request, res: Response) { if (!req.user) throw new HttpError(401, 'Authentication required.'); - const appointments = await appointmentsService.listMyAppointments(req.user.id); + const { patientProfileId } = listMineQuerySchema.parse(req.query); + const appointments = await appointmentsService.listMyAppointments(req.user.id, patientProfileId); res.status(200).json(appointments); } diff --git a/backend/src/modules/appointments/services/index.ts b/backend/src/modules/appointments/services/index.ts index b2536a1b..7b788880 100644 --- a/backend/src/modules/appointments/services/index.ts +++ b/backend/src/modules/appointments/services/index.ts @@ -25,14 +25,21 @@ import * as referralsService from '../../referrals/services'; * See backend/tests/integration/booking.test.ts for the concurrency proof * this is built to satisfy. * + * Phase 8, issue #42 (docs/adr/0011): `patientProfileId` books for a + * dependent the caller is an active guardian of. Authorisation happens + * here, BEFORE the transaction — it only decides which profile the + * appointment is for. The transaction below is unchanged by it. + * * Phase 8, issue #43: the booking's AuditEvent is written as a third step * of the same transaction, after the Appointment row exists — so an event * exists if and only if the booking committed. A caller that loses the - * race fails at step 1 and never reaches it. + * race fails at step 1 and never reaches it. For a guardian booking, the + * actor is the guardian and the event's metadata names the dependent's + * profile id. */ -export async function bookSlot(actor: auditService.AuditActor, availabilitySlotId: string) { +export async function bookSlot(actor: auditService.AuditActor, availabilitySlotId: string, patientProfileId?: string) { if (!actor.userId) throw new HttpError(401, 'Authentication required.'); - const patientProfile = await patientsService.getMyPatientProfile(actor.userId); + const patientProfile = await patientsService.resolveActingProfile(actor.userId, patientProfileId); return prisma.$transaction(async (tx) => { const claimed = await availabilityService.claimOpenSlot(tx, availabilitySlotId); @@ -59,15 +66,20 @@ export async function bookSlot(actor: auditService.AuditActor, availabilitySlotI action: 'appointment.booked', fromState: null, toState: appointment.status, - metadata: { availabilitySlotId, doctorProfileId: slot.doctorProfileId, origin: appointment.origin }, + metadata: { + availabilitySlotId, + doctorProfileId: slot.doctorProfileId, + patientProfileId: patientProfile.id, + origin: appointment.origin, + }, }); return appointment; }); } -export async function listMyAppointments(userId: string) { - const patientProfile = await patientsService.getMyPatientProfile(userId); +export async function listMyAppointments(userId: string, patientProfileId?: string) { + const patientProfile = await patientsService.resolveActingProfile(userId, patientProfileId); return appointmentsRepo.findAppointmentsForPatient(patientProfile.id); } diff --git a/backend/src/modules/appointments/validation.ts b/backend/src/modules/appointments/validation.ts index 35485507..8a730943 100644 --- a/backend/src/modules/appointments/validation.ts +++ b/backend/src/modules/appointments/validation.ts @@ -2,6 +2,15 @@ import { z } from 'zod'; export const bookSlotSchema = z.object({ availabilitySlotId: z.string().min(1), + // Phase 8, issue #42: book for a dependent the caller is guardian of. + // Omitted (or the caller's own profile id) = book for yourself. + patientProfileId: z.string().min(1).optional(), +}); + +// Phase 8, issue #42: `GET /appointments/mine?patientProfileId=` lists a +// dependent's appointments, authorised the same way as booking. +export const listMineQuerySchema = z.object({ + patientProfileId: z.string().min(1).optional(), }); export const updateStatusSchema = z.object({ diff --git a/backend/src/modules/patients/controllers/index.ts b/backend/src/modules/patients/controllers/index.ts index 76f6610c..7c747013 100644 --- a/backend/src/modules/patients/controllers/index.ts +++ b/backend/src/modules/patients/controllers/index.ts @@ -1,9 +1,9 @@ import type { Request, Response } from 'express'; import { once } from 'node:events'; import * as patientsService from '../services'; +import { createDependentSchema, deleteAccountSchema } from '../validation'; import { HttpError } from '../../../http-error'; import { getParam } from '../../../request-params'; -import { deleteAccountSchema } from '../validation'; import { LoginThrottledError } from '../../auth/services'; // Phase 8, issue #40 — right of access. The subject is always `req.user`; @@ -66,3 +66,17 @@ export async function deleteMe(req: Request, res: Response) { } res.status(204).send(); } + +// Phase 8, issue #42 (docs/adr/0011). The guardian is always `req.user`. + +export async function createDependent(req: Request, res: Response) { + if (!req.user) throw new HttpError(401, 'Authentication required.'); + const input = createDependentSchema.parse(req.body ?? {}); + const dependent = await patientsService.createMyDependent(req.user.id, input); + res.status(201).json(dependent); +} + +export async function listDependents(req: Request, res: Response) { + if (!req.user) throw new HttpError(401, 'Authentication required.'); + res.status(200).json(await patientsService.listMyDependents(req.user.id)); +} diff --git a/backend/src/modules/patients/repositories/index.ts b/backend/src/modules/patients/repositories/index.ts index e6b01a46..54657728 100644 --- a/backend/src/modules/patients/repositories/index.ts +++ b/backend/src/modules/patients/repositories/index.ts @@ -113,7 +113,7 @@ export const ANONYMIZED_PATIENT_NAME = 'Deleted patient'; export async function anonymizeProfile(db: DbClient, profileId: string, anonymizedAt: Date): Promise { const result = await db.patientProfile.updateMany({ where: { id: profileId, anonymizedAt: null }, - data: { fullName: ANONYMIZED_PATIENT_NAME, dateOfBirth: null, guardianUserId: null, anonymizedAt }, + data: { fullName: ANONYMIZED_PATIENT_NAME, dateOfBirth: null, anonymizedAt }, }); return result.count === 1; } @@ -213,3 +213,56 @@ export async function deleteAnonymizedProfile(db: DbClient, profileId: string): const result = await db.patientProfile.deleteMany({ where: { id: profileId, anonymizedAt: { not: null } } }); return result.count === 1; } + +// --- Guardians and dependents (Phase 8, issue #42, docs/adr/0011) -------- + +/** Creates a login-less dependent profile and its guardianship together: + * a dependent with no guardian (or a guardianship with no dependent) is + * never observable. */ +export function createDependentWithGuardianship(input: { + guardianUserId: string; + fullName: string; + dateOfBirth: Date; +}) { + return prisma.$transaction(async (tx) => { + const profile = await tx.patientProfile.create({ + data: { userId: null, fullName: input.fullName, dateOfBirth: input.dateOfBirth }, + }); + const guardianship = await tx.guardianship.create({ + data: { guardianUserId: input.guardianUserId, dependentProfileId: profile.id }, + }); + return { profile, guardianship }; + }); +} + +/** The guardian's non-revoked guardianships with their dependent profiles. + * Majority is applied by the service (it needs the configured age). */ +export function findUnrevokedGuardianshipsForGuardian(guardianUserId: string) { + return prisma.guardianship.findMany({ + where: { guardianUserId, revokedAt: null }, + include: { dependentProfile: true }, + orderBy: { createdAt: 'asc' }, + }); +} + +/** The guardianship between this guardian and this profile, if any (revoked + * or not), with the dependent profile. */ +export function findGuardianship(guardianUserId: string, dependentProfileId: string) { + return prisma.guardianship.findUnique({ + where: { guardianUserId_dependentProfileId: { guardianUserId, dependentProfileId } }, + include: { dependentProfile: true }, + }); +} + +/** Account deletion (issues #41/#42, docs/adr/0010 + 0011): a deleted + * guardian's access to their dependents ends with the account. Revoked, never + * deleted — the rows are the record of who had access and when. */ +export function revokeGuardianshipsOfGuardian(db: DbClient, guardianUserId: string, revokedAt: Date) { + return db.guardianship + .updateMany({ where: { guardianUserId, revokedAt: null }, data: { revokedAt } }) + .then((result) => result.count); +} + +export async function guardianHasGuardianships(guardianUserId: string): Promise { + return (await prisma.guardianship.findFirst({ where: { guardianUserId }, select: { id: true } })) !== null; +} diff --git a/backend/src/modules/patients/routes/index.ts b/backend/src/modules/patients/routes/index.ts index 7d350c44..39668dec 100644 --- a/backend/src/modules/patients/routes/index.ts +++ b/backend/src/modules/patients/routes/index.ts @@ -1,5 +1,5 @@ import { Router } from 'express'; -import { authenticate, requireRole } from '../../../middleware/auth'; +import { authenticate, requireRole, requireVerifiedEmail } from '../../../middleware/auth'; import * as patientsController from '../controllers'; export const patientsRouter = Router(); @@ -20,3 +20,15 @@ patientsRouter.get( // Deliberately not behind requireVerifiedEmail: an unverified account must // still be deletable. patientsRouter.delete('/me', authenticate, requireRole('patient'), patientsController.deleteMe); + +// Guardians and dependents (Phase 8, issue #42, docs/adr/0011). Patients +// only; creating a dependent needs a verified address, the same bar as +// booking for them. +patientsRouter.post( + '/me/dependents', + authenticate, + requireRole('patient'), + requireVerifiedEmail, + patientsController.createDependent, +); +patientsRouter.get('/me/dependents', authenticate, requireRole('patient'), patientsController.listDependents); diff --git a/backend/src/modules/patients/services/index.ts b/backend/src/modules/patients/services/index.ts index 21758a94..2d5b9846 100644 --- a/backend/src/modules/patients/services/index.ts +++ b/backend/src/modules/patients/services/index.ts @@ -1,5 +1,6 @@ import { Prisma } from '@prisma/client'; import { prisma } from '../../../db/prisma-client'; +import type { PatientProfile } from '@prisma/client'; import { config } from '../../../config'; import { HttpError } from '../../../http-error'; import * as patientsRepo from '../repositories'; @@ -19,6 +20,9 @@ export { } from './export-registry'; export type { ExportSubject, PatientExportSection } from './export-registry'; export { EXPORT_FORMAT, EXPORT_SCHEMA_VERSION } from './export-bundle'; +import { isMinor } from './majority'; + +export { isMinor, majorityDate } from './majority'; export async function getMyPatientProfile(userId: string) { const profile = await patientsRepo.findPatientProfileByUserId(userId); @@ -178,7 +182,10 @@ async function generateClaimedExport( try { // The owning user is needed by account-level sections (auth module). const owner = await patientsRepo.findPatientProfileOwner(record.patientProfileId); - if (!owner) throw new ExportSectionError('profile', new Error('profile missing')); + // A dependent profile (issue #42) has no login, so it can never be the + // subject of `POST /patients/me/export`; refuse rather than export it + // without an account section. + if (!owner?.userId) throw new ExportSectionError('profile', new Error('profile missing')); await writeExportBundle({ exportId, generatedAt, @@ -327,6 +334,7 @@ export async function deleteMyAccount( if (!(await authService.anonymizeAccount(tx, userId, { unusablePasswordHash, deletedAt }))) return false; await patientsRepo.anonymizeProfile(tx, profile.id, deletedAt); await patientsRepo.deleteExportsForPatient(tx, profile.id); + await patientsRepo.revokeGuardianshipsOfGuardian(tx, userId, deletedAt); return true; }); return { alreadyDeleted: !anonymized }; @@ -407,7 +415,9 @@ export async function purgeDeletedPatientAccounts(now = new Date()): Promise { if (!(await patientsRepo.deleteAnonymizedProfile(tx, profile.id))) return; - await authService.purgeAnonymizedAccount(tx, profile.userId); + // Anonymised profiles always had a login (a dependent has none and + // can't delete itself), but the column is nullable since #42. + if (profile.userId) await authService.purgeAnonymizedAccount(tx, profile.userId); }); result.purged += 1; } catch (err) { @@ -425,3 +435,115 @@ export async function purgeDeletedPatientAccounts(now = new Date()): Promise { + // The guardian must be a patient with their own profile (the route also + // requires the patient role). + await getMyPatientProfile(guardianUserId); + if (input.dateOfBirth.getTime() > now.getTime()) { + throw new HttpError(400, 'dateOfBirth cannot be in the future.'); + } + if (!isMinor(input.dateOfBirth, config.patients.ageOfMajorityYears, now)) { + throw new HttpError( + 400, + `A dependent must be under ${config.patients.ageOfMajorityYears}. An adult needs their own account.`, + 'DEPENDENT_NOT_A_MINOR', + ); + } + const { profile, guardianship } = await patientsRepo.createDependentWithGuardianship({ + guardianUserId, + fullName: input.fullName, + dateOfBirth: input.dateOfBirth, + }); + return toDependentView({ createdAt: guardianship.createdAt, dependentProfile: profile }); +} + +/** The caller's dependents they can currently act for: not revoked and still + * under the age of majority. Never anyone else's. */ +export async function listMyDependents(guardianUserId: string, now = new Date()): Promise { + await getMyPatientProfile(guardianUserId); // 404 for a deleted account (issue #41), like every patient route + const guardianships = await patientsRepo.findUnrevokedGuardianshipsForGuardian(guardianUserId); + return guardianships.filter((g) => isActive(g, now)).map(toDependentView); +} + +/** + * THE authorisation check for acting on a patient profile (booking, listing + * appointments — and every future dependent-scoped surface). Returns the + * profile the caller may act for: + * - no `patientProfileId`, or the caller's own profile id -> own profile; + * - an active dependent of the caller -> that dependent; + * - a dependent whose guardianship ended (revoked / reached majority) + * -> 403 GUARDIANSHIP_INACTIVE; + * - anything else -> 404, the same as a profile that doesn't exist (no + * enumeration of other patients or other guardians' dependents). + */ +export async function resolveActingProfile( + userId: string, + patientProfileId?: string, + now = new Date(), +): Promise { + const own = await patientsRepo.findPatientProfileByUserId(userId); + // A deleted account (issue #41) acts for no one — not for itself and not + // for its former dependents — even while its access token is still valid. + if (own?.anonymizedAt) throw new HttpError(404, 'No patient profile for this account.'); + if (!patientProfileId) { + if (!own) throw new HttpError(404, 'No patient profile for this account.'); + return own; + } + if (own && own.id === patientProfileId) return own; + + const guardianship = await patientsRepo.findGuardianship(userId, patientProfileId); + if (!guardianship) throw new HttpError(404, 'Patient profile not found.'); + if (!isActive(guardianship, now)) { + throw new HttpError( + 403, + 'Your guardianship of this patient has ended, so you can no longer act for them.', + 'GUARDIANSHIP_INACTIVE', + ); + } + return guardianship.dependentProfile; +} + +/** Retention hold (issues #41/#42, docs/adr/0010 + 0011): a deleted + * guardian's tombstone stays while Guardianship rows name it — they are the + * record of who had access to a minor's profile, and the FK is RESTRICT. */ +export async function hasGuardianshipsAsGuardian(patientProfileId: string): Promise { + const profile = await patientsRepo.findPatientProfileById(patientProfileId); + return profile?.userId ? patientsRepo.guardianHasGuardianships(profile.userId) : false; +} diff --git a/backend/src/modules/patients/services/majority.ts b/backend/src/modules/patients/services/majority.ts new file mode 100644 index 00000000..a5913053 --- /dev/null +++ b/backend/src/modules/patients/services/majority.ts @@ -0,0 +1,21 @@ +// Age of majority (Phase 8, issue #42, docs/adr/0011). Pure date math, so +// it's unit-tested exhaustively instead of being re-derived at call sites. +// +// Convention: evaluated in UTC on the calendar date. A person reaches +// majority at 00:00 UTC on their Nth birthday; someone born on 29 February +// reaches it on 1 March in a non-leap year (their birthday doesn't exist +// that year, and they are not yet N on 28 February). + +/** The instant (00:00 UTC) at which someone born on `dateOfBirth` turns + * `years` old. */ +export function majorityDate(dateOfBirth: Date, years: number): Date { + const y = dateOfBirth.getUTCFullYear() + years; + const m = dateOfBirth.getUTCMonth(); + const d = dateOfBirth.getUTCDate(); + // Date.UTC rolls 29 Feb of a non-leap year over to 1 Mar — the intended rule. + return new Date(Date.UTC(y, m, d)); +} + +export function isMinor(dateOfBirth: Date, years: number, now: Date): boolean { + return now.getTime() < majorityDate(dateOfBirth, years).getTime(); +} diff --git a/backend/src/modules/patients/validation.ts b/backend/src/modules/patients/validation.ts index c0d77f0d..112b6794 100644 --- a/backend/src/modules/patients/validation.ts +++ b/backend/src/modules/patients/validation.ts @@ -4,3 +4,24 @@ import { z } from 'zod'; export const deleteAccountSchema = z.object({ password: z.string().min(1, 'password is required to delete your account.'), }); + +// Phase 8, issue #42 (docs/adr/0011). `.strict()` on purpose: a body that +// tries to name an existing profile or user (`patientProfileId`, `userId`, +// ...) is rejected outright — dependents are create-only, never linked. +export const createDependentSchema = z + .object({ + fullName: z.string().trim().min(1).max(200), + dateOfBirth: z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/, 'dateOfBirth must be YYYY-MM-DD') + .transform((value, ctx) => { + const date = new Date(`${value}T00:00:00.000Z`); + // Rejects impossible dates like 2020-02-30 (Date would roll them over). + if (Number.isNaN(date.getTime()) || date.toISOString().slice(0, 10) !== value) { + ctx.addIssue({ code: 'custom', message: 'dateOfBirth must be a real calendar date.' }); + return z.NEVER; + } + return date; + }), + }) + .strict(); diff --git a/backend/src/retention-holds.ts b/backend/src/retention-holds.ts index 69c797c5..76698c19 100644 --- a/backend/src/retention-holds.ts +++ b/backend/src/retention-holds.ts @@ -14,7 +14,7 @@ import { hasAccessLogsForPatient } from './modules/audit/services'; import { hasOrdersForPatient } from './modules/orders/services'; import { hasPaymentIntentsForPatient } from './modules/payments/services'; import { hasReferralsForPatient } from './modules/referrals/services'; -import { registerRetentionHold, type RetentionHold } from './modules/patients/services'; +import { hasGuardianshipsAsGuardian, registerRetentionHold, type RetentionHold } from './modules/patients/services'; export const RETENTION_HOLDS: RetentionHold[] = [ { name: 'appointments', holds: hasAppointmentsForPatient }, @@ -22,6 +22,7 @@ export const RETENTION_HOLDS: RetentionHold[] = [ { name: 'orders', holds: hasOrdersForPatient }, { name: 'referrals', holds: hasReferralsForPatient }, { name: 'access_logs', holds: hasAccessLogsForPatient }, + { name: 'guardianships', holds: hasGuardianshipsAsGuardian }, ]; export function registerRetentionHolds(): void { diff --git a/backend/tests/integration/access-control.test.mts b/backend/tests/integration/access-control.test.mts index a90c953b..2e128489 100644 --- a/backend/tests/integration/access-control.test.mts +++ b/backend/tests/integration/access-control.test.mts @@ -70,6 +70,8 @@ const KNOWN_ROUTES = [ 'POST /patients/me/export', 'GET /patients/me/export/:exportId', 'GET /patients/me/export/:exportId/download', + 'POST /patients/me/dependents', + 'GET /patients/me/dependents', ] as const; // --------------------------------------------------------------------------- @@ -137,6 +139,10 @@ const ACCESS_MATRIX: MatrixRow[] = [ { method: 'POST', path: '/patients/me/export', expected: { anon: 401, patient: 202, doctor: 403, facility: 403, admin: 403 } }, { method: 'GET', path: '/patients/me/export/:exportId', expected: { anon: 401, patient: 404, doctor: 403, facility: 403, admin: 403 } }, { method: 'GET', path: '/patients/me/export/:exportId/download', expected: { anon: 401, patient: 404, doctor: 403, facility: 403, admin: 403 } }, + // Dependents (issue #42) — patients only. Creating one needs a verified + // email (this suite's patient is unverified → 403 EMAIL_NOT_VERIFIED). + { method: 'POST', path: '/patients/me/dependents', expected: { anon: 401, patient: 403, doctor: 403, facility: 403, admin: 403 } }, + { method: 'GET', path: '/patients/me/dependents', expected: { anon: 401, patient: 200, doctor: 403, facility: 403, admin: 403 } }, ]; const ACTORS: Actor[] = ['anon', 'patient', 'doctor', 'facility', 'admin']; diff --git a/backend/tests/integration/account-deletion.test.mts b/backend/tests/integration/account-deletion.test.mts index 6512d86b..eb7dc795 100644 --- a/backend/tests/integration/account-deletion.test.mts +++ b/backend/tests/integration/account-deletion.test.mts @@ -142,7 +142,7 @@ describe('account deletion with clinical-record retention', () => { await prisma.user.update({ where: { id: p.userId }, data: { phone: '+2348000000000' } }); await prisma.patientProfile.update({ where: { id: p.profileId }, - data: { dateOfBirth: new Date('1990-05-01'), guardianUserId: doctor.userId }, + data: { dateOfBirth: new Date('1990-05-01') }, }); } @@ -261,7 +261,7 @@ describe('account deletion with clinical-record retention', () => { expect(await bcrypt.compare(PASSWORD, user.passwordHash)).toBe(false); const profile = await prisma.patientProfile.findUniqueOrThrow({ where: { id: alice.profileId } }); - expect(profile).toMatchObject({ fullName: 'Deleted patient', dateOfBirth: null, guardianUserId: null }); + expect(profile).toMatchObject({ fullName: 'Deleted patient', dateOfBirth: null }); expect(profile.anonymizedAt?.getTime()).toBe(user.deletedAt!.getTime()); // No personal field survives anywhere on the two rows. diff --git a/backend/tests/integration/guardianship.test.mts b/backend/tests/integration/guardianship.test.mts new file mode 100644 index 00000000..59d84aee --- /dev/null +++ b/backend/tests/integration/guardianship.test.mts @@ -0,0 +1,408 @@ +// Integration tests for guardians and dependents (Phase 8, issue #42, +// docs/adr/0011): creating and listing dependents, booking on a +// dependent's behalf, refusing someone else's dependent before anything is +// booked, revocation and age of majority, the DB constraints, the +// migration's data-preservation guard — and the booking concurrency +// guarantee re-proven with a guardian booking in the race. Self-contained, +// same embedded-Postgres pattern as booking.test.mts. Own port (5454). +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 MIGRATIONS_DIR = path.join(BACKEND_ROOT, 'prisma', 'migrations'); +const PORT = 5454; +const runId = Date.now(); + +const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'docforum-guardian-pg-')); +const pg = new EmbeddedPostgres({ + databaseDir: dataDir, + user: 'docforum', + password: 'docforum', + port: PORT, + persistent: false, +}); +const urlFor = (db: string) => `postgresql://docforum:docforum@localhost:${PORT}/${db}`; +const databaseUrl = urlFor('docforum_test'); + +type App = ReturnType; +type Prisma = typeof import('../../src/db/prisma-client.js').prisma; + +interface Patient { + email: string; + userId: string; + profileId: string; + token: string; +} + +const emailedTokens = new Map(); + +function yearsAgo(years: number, days = 0): string { + const now = new Date(); + const d = new Date(Date.UTC(now.getUTCFullYear() - years, now.getUTCMonth(), now.getUTCDate() - days)); + return d.toISOString().slice(0, 10); +} + +// Prisma 7 (ADR 0006) dropped `db execute --url`: the CLI reads the URL from +// prisma.config.ts, i.e. from DATABASE_URL, so the target goes in the env. +function dbExecute(url: string, sql: string) { + execFileSync('npx', ['prisma', 'db', 'execute', '--stdin'], { + cwd: BACKEND_ROOT, + env: { ...process.env, DATABASE_URL: url }, + input: sql, + stdio: ['pipe', 'pipe', 'pipe'], + }); +} + +function dbExecuteFile(url: string, file: string) { + execFileSync('npx', ['prisma', 'db', 'execute', '--file', file], { + cwd: BACKEND_ROOT, + env: { ...process.env, DATABASE_URL: url }, + stdio: 'pipe', + }); +} + +describe('guardian and dependent profiles', () => { + let app: App; + let prisma: Prisma; + let doctorToken: string; + let doctorProfileId: string; + let g1: Patient; // guardian with dependents + let g2: Patient; // another guardian + let adult: Patient; // ordinary patient, no dependents + let unverified: Patient; + let d1: string; // g1's dependent + let d2: string; // g2's dependent + const freeSlots: string[] = []; + + async function signup(role: 'patient' | 'doctor', label: string, verify = true) { + const email = `${label}-${runId}@example.com`; + const res = await request(app).post('/auth/signup').send({ role, email, password: 'longenough123', fullName: `${label} Fullname` }); + expect(res.status).toBe(200); + if (verify) expect((await request(app).post('/auth/verify-email').send({ token: emailedTokens.get(email) })).status).toBe(200); + return { email, userId: res.body.user.id as string, token: res.body.accessToken as string }; + } + + async function signupPatient(label: string, verify = true): Promise { + const user = await signup('patient', label, verify); + const profile = await prisma.patientProfile.findUniqueOrThrow({ where: { userId: user.userId } }); + return { ...user, profileId: profile.id }; + } + + const auth = (p: { token: string }) => ({ Authorization: `Bearer ${p.token}` }); + const createDependent = (p: Patient, body: object) => request(app).post('/patients/me/dependents').set(auth(p)).send(body); + const book = (p: Patient, body: object) => request(app).post('/appointments').set(auth(p)).send(body); + const nextSlot = () => { + const slot = freeSlots.shift(); + if (!slot) throw new Error('test ran out of slots'); + return slot; + }; + + 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', + }); + + process.env.DATABASE_URL = databaseUrl; + process.env.AGE_OF_MAJORITY_YEARS = '18'; + + const appModule = await import('../../src/app.js'); + const dbModule = await import('../../src/db/prisma-client.js'); + const authServices = await import('../../src/modules/auth/services/index.js'); + app = appModule.createApp(); + prisma = dbModule.prisma; + authServices.setEmailSender(async (message) => { + const match = /[?&]token=([^&\s]+)/.exec(message.text); + if (match) emailedTokens.set(message.to, decodeURIComponent(match[1])); + }); + + const doctor = await signup('doctor', 'guardian-doctor'); + doctorToken = doctor.token; + doctorProfileId = (await prisma.doctorProfile.findUniqueOrThrow({ where: { userId: doctor.userId } })).id; + const tomorrow = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString().slice(0, 10); + const generated = await request(app) + .post(`/availability/doctors/${doctorProfileId}/slots/generate`) + .set('Authorization', `Bearer ${doctorToken}`) + .send({ date: tomorrow, startHour: 8, endHour: 18, slotLengthMinutes: 30 }); + expect(generated.status).toBe(201); + freeSlots.push(...((await request(app).get(`/availability/doctors/${doctorProfileId}/slots`)).body as { id: string }[]).map((s) => s.id)); + + g1 = await signupPatient('guardian-one'); + g2 = await signupPatient('guardian-two'); + adult = await signupPatient('adult'); + unverified = await signupPatient('unverified', false); + }, 180_000); + + afterAll(async () => { + const authServices = await import('../../src/modules/auth/services/index.js'); + authServices.resetEmailSender(); + await prisma?.$disconnect(); + await pg.stop(); + fs.rmSync(dataDir, { recursive: true, force: true }); + }); + + it('lets a patient create a dependent: a login-less profile plus a guardianship', async () => { + const res = await createDependent(g1, { fullName: 'Child One', dateOfBirth: yearsAgo(6) }); + expect(res.status).toBe(201); + expect(res.body).toMatchObject({ fullName: 'Child One', dateOfBirth: `${yearsAgo(6)}T00:00:00.000Z` }); + expect(Object.keys(res.body).sort()).toEqual(['dateOfBirth', 'fullName', 'guardianSince', 'id']); + d1 = res.body.id; + + const profile = await prisma.patientProfile.findUniqueOrThrow({ where: { id: d1 } }); + expect(profile.userId).toBeNull(); + const guardianships = await prisma.guardianship.findMany({ where: { dependentProfileId: d1 } }); + expect(guardianships).toEqual([expect.objectContaining({ guardianUserId: g1.userId, revokedAt: null })]); + + const other = await createDependent(g2, { fullName: 'Child Two', dateOfBirth: yearsAgo(10) }); + expect(other.status).toBe(201); + d2 = other.body.id; + }); + + it('refuses unauthorised or invalid dependent creation, and never links an existing profile', async () => { + const before = await prisma.guardianship.count(); + // Linking attempts: naming an existing profile/user is rejected outright. + for (const extra of [{ patientProfileId: adult.profileId }, { userId: adult.userId }, { patientProfileId: d2 }]) { + const res = await createDependent(g1, { fullName: 'Sneaky', dateOfBirth: yearsAgo(5), ...extra }); + expect(res.status).toBe(400); + } + expect(await prisma.guardianship.count({ where: { dependentProfileId: { in: [adult.profileId, d2] }, guardianUserId: g1.userId } })).toBe(0); + + const adultDob = await createDependent(g1, { fullName: 'Grown Up', dateOfBirth: yearsAgo(18) }); + expect(adultDob.status).toBe(400); + expect(adultDob.body.code).toBe('DEPENDENT_NOT_A_MINOR'); + expect((await createDependent(g1, { fullName: 'Future', dateOfBirth: '2999-01-01' })).status).toBe(400); + expect((await createDependent(g1, { fullName: 'Bad', dateOfBirth: '2020-02-30' })).status).toBe(400); + expect((await createDependent(g1, { dateOfBirth: yearsAgo(3) })).status).toBe(400); + + const doctor = await request(app).post('/patients/me/dependents').set('Authorization', `Bearer ${doctorToken}`).send({ fullName: 'X', dateOfBirth: yearsAgo(3) }); + expect(doctor.status).toBe(403); + const unverifiedRes = await createDependent(unverified, { fullName: 'X', dateOfBirth: yearsAgo(3) }); + expect(unverifiedRes.status).toBe(403); + expect(unverifiedRes.body.code).toBe('EMAIL_NOT_VERIFIED'); + expect((await request(app).post('/patients/me/dependents').send({ fullName: 'X', dateOfBirth: yearsAgo(3) })).status).toBe(401); + + // One day short of majority is still a minor. + const almost = await createDependent(g1, { fullName: 'Almost Adult', dateOfBirth: yearsAgo(18, -1) }); + expect(almost.status).toBe(201); + expect(await prisma.guardianship.count()).toBe(before + 1); + }); + + it("lists only the caller's own active dependents", async () => { + const mine = await request(app).get('/patients/me/dependents').set(auth(g1)); + expect(mine.status).toBe(200); + expect(mine.body.map((d: { fullName: string }) => d.fullName)).toEqual(['Child One', 'Almost Adult']); + const theirs = await request(app).get('/patients/me/dependents').set(auth(g2)); + expect(theirs.body.map((d: { id: string }) => d.id)).toEqual([d2]); + expect((await request(app).get('/patients/me/dependents').set(auth(adult))).body).toEqual([]); + expect((await request(app).get('/patients/me/dependents').set('Authorization', `Bearer ${doctorToken}`)).status).toBe(403); + }); + + it('lets a guardian book for their dependent and see that appointment', async () => { + const slot = nextSlot(); + const res = await book(g1, { availabilitySlotId: slot, patientProfileId: d1 }); + expect(res.status).toBe(201); + expect(res.body.patientProfileId).toBe(d1); + + // Audit (issue #43): the guardian is the actor; the event names the + // dependent's profile the appointment was booked for. + const event = await prisma.auditEvent.findFirstOrThrow({ where: { entityType: 'Appointment', entityId: res.body.id } }); + expect(event).toMatchObject({ actorUserId: g1.userId, actorRole: 'patient', action: 'appointment.booked', toState: 'scheduled' }); + expect(event.metadata).toMatchObject({ patientProfileId: d1, availabilitySlotId: slot }); + + const dependentAppointments = await request(app).get(`/appointments/mine?patientProfileId=${d1}`).set(auth(g1)); + expect(dependentAppointments.status).toBe(200); + expect(dependentAppointments.body.map((a: { id: string }) => a.id)).toEqual([res.body.id]); + // The guardian's own list is the guardian's own history only. + const own = await request(app).get('/appointments/mine').set(auth(g1)); + expect(own.body).toEqual([]); + }); + + it("refuses another guardian's dependent, or another patient, before anything is booked", async () => { + for (const target of [d2, adult.profileId]) { + const slot = nextSlot(); + const res = await book(g1, { availabilitySlotId: slot, patientProfileId: target }); + expect(res.status).toBe(404); + // Rejected before the booking ran: the slot is still open, no appointment exists. + expect((await prisma.availabilitySlot.findUniqueOrThrow({ where: { id: slot } })).status).toBe('open'); + expect(await prisma.appointment.count({ where: { availabilitySlotId: slot } })).toBe(0); + freeSlots.unshift(slot); + } + expect((await request(app).get(`/appointments/mine?patientProfileId=${d2}`).set(auth(g1))).status).toBe(404); + expect(await prisma.appointment.count({ where: { patientProfileId: { in: [d2, adult.profileId] } } })).toBe(0); + }); + + it('keeps self-booking working, with or without naming your own profile', async () => { + const plain = await book(adult, { availabilitySlotId: nextSlot() }); + expect(plain.status).toBe(201); + expect(plain.body.patientProfileId).toBe(adult.profileId); + const named = await book(adult, { availabilitySlotId: nextSlot(), patientProfileId: adult.profileId }); + expect(named.status).toBe(201); + expect(named.body.patientProfileId).toBe(adult.profileId); + expect((await request(app).get('/appointments/mine').set(auth(adult))).body).toHaveLength(2); + }); + + it('rejects a missing, empty or unknown patientProfileId cleanly', async () => { + const slot = nextSlot(); + expect((await book(g1, { availabilitySlotId: slot, patientProfileId: '' })).status).toBe(400); + expect((await book(g1, { availabilitySlotId: slot, patientProfileId: '00000000-0000-0000-0000-000000000000' })).status).toBe(404); + expect((await book(g1, { patientProfileId: d1 })).status).toBe(400); + expect((await prisma.availabilitySlot.findUniqueOrThrow({ where: { id: slot } })).status).toBe('open'); + freeSlots.unshift(slot); + }); + + it('ends access on revocation and at the age of majority, keeping the history', async () => { + // Revoked guardianship. + const revokedChild = await createDependent(g1, { fullName: 'Revoked Child', dateOfBirth: yearsAgo(8) }); + await prisma.guardianship.updateMany({ where: { dependentProfileId: revokedChild.body.id }, data: { revokedAt: new Date() } }); + const revokedBooking = await book(g1, { availabilitySlotId: nextSlot(), patientProfileId: revokedChild.body.id }); + expect(revokedBooking.status).toBe(403); + expect(revokedBooking.body.code).toBe('GUARDIANSHIP_INACTIVE'); + + // Reaching majority: D1 (who has an appointment) turns 18. + await prisma.patientProfile.update({ where: { id: d1 }, data: { dateOfBirth: new Date(`${yearsAgo(18)}T00:00:00.000Z`) } }); + const adultBooking = await book(g1, { availabilitySlotId: nextSlot(), patientProfileId: d1 }); + expect(adultBooking.status).toBe(403); + expect(adultBooking.body.code).toBe('GUARDIANSHIP_INACTIVE'); + expect((await request(app).get(`/appointments/mine?patientProfileId=${d1}`).set(auth(g1))).status).toBe(403); + + const listed = (await request(app).get('/patients/me/dependents').set(auth(g1))).body.map((d: { id: string }) => d.id); + expect(listed).not.toContain(d1); + expect(listed).not.toContain(revokedChild.body.id); + + // Clinical history and the guardianship record are kept, not deleted. + expect(await prisma.appointment.count({ where: { patientProfileId: d1 } })).toBe(1); + expect(await prisma.guardianship.count({ where: { dependentProfileId: { in: [d1, revokedChild.body.id] } } })).toBe(2); + }); + + it('enforces the model in the database: unique pair, dependent DOB, RESTRICT', async () => { + await expect( + prisma.guardianship.create({ data: { guardianUserId: g2.userId, dependentProfileId: d2 } }), + ).rejects.toMatchObject({ code: 'P2002' }); + await expect(prisma.patientProfile.create({ data: { userId: null, fullName: 'No DOB' } })).rejects.toThrow( + /PatientProfile_dependent_has_dob/, + ); + await expect(prisma.patientProfile.delete({ where: { id: d2 } })).rejects.toThrow(/Guardianship_dependentProfileId_fkey/); + }); + + it('keeps the booking concurrency guarantee with a guardian booking in the race', async () => { + const racers = await Promise.all(Array.from({ length: 6 }, (_, i) => signupPatient(`racer-${i}`))); + const child = await createDependent(g2, { fullName: 'Race Child', dateOfBirth: yearsAgo(4) }); + expect(child.status).toBe(201); + const slot = nextSlot(); + + const responses = await Promise.all([ + book(g2, { availabilitySlotId: slot, patientProfileId: child.body.id }), + ...racers.map((racer) => book(racer, { availabilitySlotId: slot })), + ]); + expect(responses.filter((r) => r.status === 201)).toHaveLength(1); + expect(responses.filter((r) => r.status === 409)).toHaveLength(racers.length); + expect(await prisma.appointment.count({ where: { availabilitySlotId: slot } })).toBe(1); + expect((await prisma.availabilitySlot.findUniqueOrThrow({ where: { id: slot } })).status).toBe('booked'); + }); + + it("revokes a guardian's access when they delete their account, keeping the dependent and the record", async () => { + const patients = await import('../../src/modules/patients/services/index.js'); + const g3 = await signupPatient('guardian-deleting'); + const created = await createDependent(g3, { fullName: 'Kept Child', dateOfBirth: '2018-03-04' }); + expect(created.status).toBe(201); + const d3 = created.body.id as string; + expect((await book(g3, { availabilitySlotId: nextSlot(), patientProfileId: d3 })).status).toBe(201); + const dependentBefore = await prisma.patientProfile.findUniqueOrThrow({ where: { id: d3 } }); + + const deleted = await request(app).delete('/patients/me').set(auth(g3)).send({ password: 'longenough123' }); + expect(deleted.status).toBe(204); + + // Revoked, not deleted: the row is the record of who had access. + const guardianship = await prisma.guardianship.findFirstOrThrow({ where: { guardianUserId: g3.userId } }); + expect(guardianship.revokedAt).not.toBeNull(); + // The dependent's own profile and history are the dependent's — untouched. + expect(await prisma.patientProfile.findUniqueOrThrow({ where: { id: d3 } })).toEqual(dependentBefore); + expect(await prisma.appointment.count({ where: { patientProfileId: d3 } })).toBe(1); + + // The still-valid access token acts for no one. + // Booking is refused even earlier: deletion clears the verified email. + const booking = await book(g3, { availabilitySlotId: freeSlots[0], patientProfileId: d3 }); + expect(booking.status).toBe(403); + expect(booking.body.code).toBe('EMAIL_NOT_VERIFIED'); + expect((await request(app).get(`/appointments/mine?patientProfileId=${d3}`).set(auth(g3))).status).toBe(404); + expect((await request(app).get('/patients/me/dependents').set(auth(g3))).status).toBe(404); + + // The purge keeps the guardian's tombstone while the guardianship row exists. + const later = new Date(Date.now() + 48 * 60 * 60 * 1000); + await patients.purgeDeletedPatientAccounts(later); + expect(await prisma.user.findUnique({ where: { id: g3.userId } })).not.toBeNull(); + expect(await prisma.guardianship.count({ where: { guardianUserId: g3.userId } })).toBe(1); + }); + + describe('migration', () => { + const newMigration = fs.readdirSync(MIGRATIONS_DIR).find((d) => d.endsWith('_guardian_dependent_profiles'))!; + const earlier = fs + .readdirSync(MIGRATIONS_DIR) + .filter((d) => d < newMigration && fs.existsSync(path.join(MIGRATIONS_DIR, d, 'migration.sql'))) + .sort(); + const migrationFile = (dir: string) => path.join(MIGRATIONS_DIR, dir, 'migration.sql'); + + /** A fresh database at the migration just before this one, seeded — + * one `prisma db execute` for all of it (each CLI spawn costs seconds). */ + async function databaseAtPreviousMigration(name: string, seedSql: string) { + await pg.createDatabase(name); + const script = [...earlier.map((dir) => fs.readFileSync(migrationFile(dir), 'utf8')), seedSql].join('\n;\n'); + dbExecute(urlFor(name), script); + return urlFor(name); + } + + async function withClient(url: string, fn: (client: Prisma) => Promise): Promise { + const { PrismaClient } = await import('@prisma/client'); + const { PrismaPg } = await import('@prisma/adapter-pg'); + // Prisma 7 (ADR 0006): the URL goes to a driver adapter, not `datasources`. + const client = new PrismaClient({ adapter: new PrismaPg({ connectionString: url }) }); + try { + return await fn(client); + } finally { + await client.$disconnect(); + } + } + + const seed = (guardianUserId: string | null) => ` + INSERT INTO "User" ("id","email","passwordHash","role","updatedAt") VALUES ('u1','u1@example.com','x','patient',NOW()); + INSERT INTO "User" ("id","email","passwordHash","role","updatedAt") VALUES ('doc','doc@example.com','x','doctor',NOW()); + INSERT INTO "PatientProfile" ("id","userId","fullName","guardianUserId","updatedAt") VALUES ('p1','u1','Existing Patient',${guardianUserId === null ? 'NULL' : `'${guardianUserId}'`},NOW()); + INSERT INTO "DoctorProfile" ("id","userId","fullName","updatedAt") VALUES ('d1','doc','Doc',NOW()); + INSERT INTO "AvailabilitySlot" ("id","doctorProfileId","status","startTime","endTime","updatedAt") VALUES ('s1','d1','booked',NOW(),NOW() + interval '30 minutes',NOW()); + INSERT INTO "Appointment" ("id","patientProfileId","doctorProfileId","availabilitySlotId","updatedAt") VALUES ('a1','p1','d1','s1',NOW()); + `; + + it('preserves existing profiles and their records', { timeout: 120_000 }, async () => { + const url = await databaseAtPreviousMigration('migration_clean', seed(null)); + dbExecuteFile(url, migrationFile(newMigration)); + await withClient(url, async (client) => { + expect(await client.patientProfile.findUniqueOrThrow({ where: { id: 'p1' } })).toMatchObject({ userId: 'u1', fullName: 'Existing Patient' }); + expect(await client.appointment.findUniqueOrThrow({ where: { id: 'a1' } })).toMatchObject({ patientProfileId: 'p1' }); + expect(await client.guardianship.count()).toBe(0); + }); + }); + + it('refuses to run, changing nothing, if legacy guardianUserId data exists', { timeout: 120_000 }, async () => { + const url = await databaseAtPreviousMigration('migration_legacy', seed('u-someone')); + expect(() => dbExecuteFile(url, migrationFile(newMigration))).toThrow(); + // The column and its value are still there for an operator to resolve, + // and nothing else from the migration was applied. + await withClient(url, async (client) => { + const legacy = await client.$queryRaw<{ guardianUserId: string }[]>`SELECT "guardianUserId" FROM "PatientProfile" WHERE "id" = 'p1'`; + expect(legacy).toEqual([{ guardianUserId: 'u-someone' }]); + const table = await client.$queryRaw<{ exists: boolean }[]>`SELECT to_regclass('"Guardianship"') IS NOT NULL AS "exists"`; + expect(table).toEqual([{ exists: false }]); + }); + }); + }); +}); diff --git a/backend/tests/unit/guardianship.test.ts b/backend/tests/unit/guardianship.test.ts new file mode 100644 index 00000000..e60f193d --- /dev/null +++ b/backend/tests/unit/guardianship.test.ts @@ -0,0 +1,71 @@ +// Guardians and dependents (Phase 8, issue #42, docs/adr/0011): the pure +// parts — age-of-majority date math and the strict request schemas. +// Database-backed authorisation is in tests/integration/guardianship.test.mts. +import { describe, expect, it } from 'vitest'; +import { isMinor, majorityDate } from '../../src/modules/patients/services/majority'; +import { createDependentSchema } from '../../src/modules/patients/validation'; +import { bookSlotSchema } from '../../src/modules/appointments/validation'; + +const utc = (iso: string) => new Date(`${iso}T00:00:00.000Z`); + +describe('age of majority', () => { + it('is reached at 00:00 UTC on the Nth birthday, not a moment before', () => { + const dob = utc('2008-06-15'); + expect(majorityDate(dob, 18).toISOString()).toBe('2026-06-15T00:00:00.000Z'); + expect(isMinor(dob, 18, new Date('2026-06-14T23:59:59.999Z'))).toBe(true); + expect(isMinor(dob, 18, new Date('2026-06-15T00:00:00.000Z'))).toBe(false); + }); + + it('treats a 29 February birthday as reaching majority on 1 March in a non-leap year', () => { + const dob = utc('2008-02-29'); + expect(majorityDate(dob, 18).toISOString()).toBe('2026-03-01T00:00:00.000Z'); + expect(isMinor(dob, 18, utc('2026-02-28'))).toBe(true); + expect(isMinor(dob, 18, utc('2026-03-01'))).toBe(false); + // ...and on 29 February itself when the majority year is a leap year. + expect(majorityDate(utc('2012-02-29'), 16).toISOString()).toBe('2028-02-29T00:00:00.000Z'); + }); + + it('honours a different configured age', () => { + expect(isMinor(utc('2010-01-01'), 16, utc('2025-12-31'))).toBe(true); + expect(isMinor(utc('2010-01-01'), 16, utc('2026-01-01'))).toBe(false); + }); + + it('treats a newborn as a minor', () => { + expect(isMinor(utc('2026-10-17'), 18, utc('2026-10-17'))).toBe(true); + }); +}); + +describe('createDependentSchema', () => { + it('accepts a name and a real calendar date', () => { + const parsed = createDependentSchema.parse({ fullName: ' Ada ', dateOfBirth: '2020-02-29' }); + expect(parsed.fullName).toBe('Ada'); + expect(parsed.dateOfBirth.toISOString()).toBe('2020-02-29T00:00:00.000Z'); + }); + + it('rejects impossible dates instead of rolling them over', () => { + expect(createDependentSchema.safeParse({ fullName: 'Ada', dateOfBirth: '2021-02-29' }).success).toBe(false); + expect(createDependentSchema.safeParse({ fullName: 'Ada', dateOfBirth: '2020-13-01' }).success).toBe(false); + expect(createDependentSchema.safeParse({ fullName: 'Ada', dateOfBirth: '01/02/2020' }).success).toBe(false); + }); + + it('is strict: a body that tries to link an existing profile or user is rejected', () => { + for (const extra of [{ patientProfileId: 'p' }, { userId: 'u' }, { guardianUserId: 'g' }]) { + expect(createDependentSchema.safeParse({ fullName: 'Ada', dateOfBirth: '2020-01-01', ...extra }).success).toBe(false); + } + }); + + it('requires a non-empty name', () => { + expect(createDependentSchema.safeParse({ fullName: ' ', dateOfBirth: '2020-01-01' }).success).toBe(false); + }); +}); + +describe('bookSlotSchema', () => { + it('keeps self-booking unchanged and accepts an optional patientProfileId', () => { + expect(bookSlotSchema.parse({ availabilitySlotId: 's' })).toEqual({ availabilitySlotId: 's' }); + expect(bookSlotSchema.parse({ availabilitySlotId: 's', patientProfileId: 'p' })).toEqual({ + availabilitySlotId: 's', + patientProfileId: 'p', + }); + expect(bookSlotSchema.safeParse({ availabilitySlotId: 's', patientProfileId: '' }).success).toBe(false); + }); +}); diff --git a/changelog.d/42-guardian-dependent-accounts.md b/changelog.d/42-guardian-dependent-accounts.md new file mode 100644 index 00000000..37135443 --- /dev/null +++ b/changelog.d/42-guardian-dependent-accounts.md @@ -0,0 +1,20 @@ +Issue #42 (guardian and dependent accounts) opened as a PR +with ADR 0011: dependents are guardian-owned patient profiles +(`PatientProfile.userId` now nullable, a CHECK requires a DOB when it's +null) linked by a new `Guardianship` table (real FKs, unique per pair, +`revokedAt`, never deleted). The bare `guardianUserId` string is dropped; +the migration aborts if any row still holds a value instead of silently +discarding it (tested both ways). `POST`/`GET /patients/me/dependents` are +create-only; `POST /appointments` takes `patientProfileId`, authorised by +`patientsService.resolveActingProfile` before the booking transaction, +which is otherwise byte-for-byte unchanged — re-proven with a concurrent +race that includes a guardian booking. Guardian access ends at the age of +majority (default 18). PRD §8.2.5 marked resolved/re-scoped. + +Reconciled with #41 (account deletion), which merged first: +`PatientProfile.userId` is nullable but keeps #41's `ON DELETE RESTRICT`; +deleting a guardian's account revokes their guardianships in the same +transaction (dependents and their records untouched), a deleted account +acts for no one through `resolveActingProfile`, and the account purge holds +a guardian's tombstone while guardianship rows name it (hold +`guardianships`). ADR 0010's retention table and ADR 0011 updated. diff --git a/docs/adr/0010-account-deletion-and-clinical-record-retention.md b/docs/adr/0010-account-deletion-and-clinical-record-retention.md index 540469a4..5196ecbd 100644 --- a/docs/adr/0010-account-deletion-and-clinical-record-retention.md +++ b/docs/adr/0010-account-deletion-and-clinical-record-retention.md @@ -71,7 +71,8 @@ a person. | `User.role`, `User.id`, `createdAt` | Retained | while the row exists | Not identifying on their own. The id is what clinical rows ultimately join to. | | `PatientProfile.fullName` | **Anonymised** to `Deleted patient` | — | What a doctor's appointment list shows from now on. | | `PatientProfile.dateOfBirth` | **Deleted** (null) | — | PII. | -| `PatientProfile.guardianUserId` | **Deleted** (null) | — | Points at another person. Unenforced field; #42 replaces it. | +| `PatientProfile.guardianUserId` | — | — | Column dropped by issue #42 (ADR 0011), replaced by `Guardianship`. | +| `Guardianship` rows where the deleted user is the guardian (issue #42) | **Revoked** (`revokedAt` set), retained | while the tombstone exists | Access to a minor's profile ends with the account; the rows are the record of who had it. Retention hold `guardianships`. The dependents' own profiles and records are untouched. | | `PatientProfile.anonymizedAt` (new) | **Set** | permanent | Owned by the patients module. Every patient route resolves the caller's profile through `getMyPatientProfile`, which refuses an anonymised profile, so booking, listing and repeat deletion stop working immediately. | | `PatientProfile.id` | Retained | while any record references it | The link that keeps appointments/payments attached without any PII. | | `EmailVerificationToken` (all of the user's) | **Deleted** | — | Bookkeeping tied to the old address. | diff --git a/docs/adr/0011-guardian-dependent-profiles.md b/docs/adr/0011-guardian-dependent-profiles.md new file mode 100644 index 00000000..470720b4 --- /dev/null +++ b/docs/adr/0011-guardian-dependent-profiles.md @@ -0,0 +1,163 @@ +# ADR 0011: Dependents are guardian-owned patient profiles, created (never linked) by the guardian; access ends at the age of majority + +Status: Proposed (issue #42). Becomes Accepted when the implementing PR merges. + +## Context + +PRD §8.2.5 lists "minor/dependent patients — booking on behalf of a child or +dependent; consent and account ownership model undefined" as an open edge +case. The Phase 1 schema has a placeholder for it, +`PatientProfile.guardianUserId`: a nullable string with no foreign key and a +comment saying not to build on it. Repository check: no code reads or writes +it, so there's no existing data model to preserve. + +Today a `PatientProfile` is 1:1 with a login (`userId` required and unique), +and booking always books for the caller's own profile +(`appointments/services` `bookSlot`). + +Paediatric visits are a large share of hospital care, so the product needs a +real ownership model before launch. Two shapes were considered: + +1. **Guardian-owned dependent profiles.** A dependent is a `PatientProfile` + with no login of its own, owned by the guardian through an explicit + guardianship relation. +2. **Separate accounts, linked later.** The dependent has their own `User`, + and a guardian link needs consent from both sides. + +Option 2 is a poor fit for young children, who don't have an email address +or a password. Worse, a "link to an existing patient" operation is exactly +the kind of request that lets one user reach another person's records. It +needs identity proofing this platform doesn't have. + +The model choice and the age-of-majority behaviour were confirmed with the +maintainer before implementation. + +## Decision + +### Ownership and representation + +- A **dependent** is a `PatientProfile` whose `userId` is **null**. It has no + login and can't authenticate. +- Guardianship is a row in a new **`Guardianship`** table: + `guardianUserId → User` and `dependentProfileId → PatientProfile`, both + real foreign keys (`ON DELETE RESTRICT`), plus `createdAt` and + `revokedAt`. It's unique on `(guardianUserId, dependentProfileId)`, so + there can't be duplicate or ambiguous rows for one pair. The model allows + more than one guardian per dependent (two parents) for later. v1's API + only ever creates one, for the guardian who created the profile. +- The bare `PatientProfile.guardianUserId` string is **dropped**. The + migration refuses to run if any row has a value (none should; no code ever + wrote one), so an operator decides rather than the migration silently + discarding or reinterpreting data. +- A database `CHECK` keeps a login-less profile from existing without a + date of birth (`"userId" IS NOT NULL OR "dateOfBirth" IS NOT NULL`). The + majority rule below depends on it. + +### How guardianship is created and verified + +- **Create-only.** `POST /patients/me/dependents` creates a *new* dependent + profile and the guardianship together, in one transaction. There is **no + operation that links an existing profile or account** to a guardian. The + request schema is strict and rejects unknown fields such as `userId` or + `patientProfileId`. So the feature can't be used to get access to anyone + else's records. A guardian can only ever see data they created on the + dependent's behalf, plus the care that follows from bookings they made. +- **Only patients can be guardians** (`requireRole('patient')`), with a + verified email (`requireVerifiedEmail`, the same bar as booking). +- **Guardianship is self-asserted in v1.** There's no document or identity + check. That's acceptable here only because of create-only: asserting + guardianship of a child gives access to nothing that already exists. Real + verification (e.g. at a facility desk) is future work and is listed under + open decisions. +- The dependent must be **under the age of majority at creation**, and the + DOB can't be in the future. An adult creates their own account. + +### What a guardian can access + +While the guardianship is **active** (see below), the guardian can: + +- list the dependent (`GET /patients/me/dependents`: id, name, DOB, created + date and nothing else); +- book an appointment for them (`POST /appointments` with + `patientProfileId`); +- list the dependent's appointments (`GET /appointments/mine?patientProfileId=`). + +Those are the only dependent-scoped surfaces that exist today. When later +phases add intake, consultation notes, orders or exports, each must +authorise through the same check, `patientsService.resolveActingProfile`, +and decide in its own ADR or PR whether guardians see it. PRD §7's privacy +rule applies: intake and consultation notes go to "the patient, the +treating doctor(s), the fulfilling facility". For a minor, that patient is +represented by their guardian. + +A guardian never gains access to another guardian's dependent. Any +`patientProfileId` that isn't the caller's own profile or an active +dependent of theirs is refused **before** anything else happens. The answer +is `404` when the caller has no relationship to the profile at all (no +enumeration), and `403 GUARDIANSHIP_INACTIVE` when they did and it has +ended. + +### Age of majority + +- `AGE_OF_MAJORITY_YEARS` is configurable, **default 18**. It's evaluated in + UTC on the calendar date: a person reaches majority at 00:00 UTC on their + Nth birthday. Someone born on 29 February reaches it on 1 March in a + non-leap year. +- Guardian access **ends automatically** on that date. It's checked at + request time from the DOB, so no job has to run and there's no window + where a stale flag still grants access. The dependent drops out of the + guardian's list, and booking for them is refused with + `GUARDIANSHIP_INACTIVE`. +- The profile and all its clinical history are **kept unchanged**. Nothing + is deleted or moved at majority. +- The now-adult **claiming** that profile as their own account (attaching a + login to `userId`, with appropriate verification) is **not built here**. + It's a follow-up issue. Until then, the profile is reachable by nobody but + the treating doctors who already see its appointments. + +### Revocation + +A guardianship is inactive if **either** `revokedAt` is set **or** the +dependent has reached majority. `revokedAt` exists in the model and every +check honours it. v1 doesn't expose a revoke endpoint: guardian +relinquishment and admin revocation (e.g. a court order) are follow-ups +that only need to set it. Revocation is always a soft end (`revokedAt`), +never a delete, so who had access and when stays on record. + +### Privacy and clinical history + +- Dependents' records are ordinary patient records. Appointments reference + the dependent's own `PatientProfile`, never the guardian's, so a child's + history stays the child's even when guardianship ends or changes. +- `Guardianship` rows are never deleted. The FKs are RESTRICT, so neither the + guardian's user nor the dependent's profile can be hard-deleted out from + under the relation. +- A guardian deleting their own account (issue #41, ADR 0010) revokes all + their guardianships in the same transaction as the anonymisation. The + dependents' profiles and records are untouched (they're the dependents'), + and the revoked rows stay as the record of who had access. A deleted + account also acts for no one through `resolveActingProfile`, even while + its access token is still valid, and the account purge holds the + guardian's tombstone while those rows exist (retention hold + `guardianships`). + +## Open decisions (not resolved here) + +- How guardianship is verified beyond self-assertion. That's needed before + any feature lets a guardian see data they didn't create. +- The majority claim flow, and whether some care (e.g. adolescent sexual + health) should be hidden from a guardian *before* majority. That's a + jurisdiction-specific consent rule this ADR doesn't invent. +- Adding a second guardian, and the revoke endpoints. + +## Consequences + +- `PatientProfile.userId` becomes nullable. Everything that resolves "my + profile" still goes through `getMyPatientProfile(userId)`, which can never + return a dependent, since a dependent has no `userId`. +- `bookSlot` gains one authorisation step *before* its transaction: + resolving which profile the caller may act for. The transaction itself, + `claimOpenSlot`'s conditional `UPDATE` followed by the `Appointment` + insert, is unchanged, so the double-booking guarantee (ARCHITECTURE §5.1) + is unchanged. That's re-proven with a concurrent test that mixes a + guardian-for-dependent booking with self-bookings on one slot. diff --git a/docs/api/README.md b/docs/api/README.md index 4357cc13..d0a8e90e 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -172,8 +172,8 @@ against `tokenVersion` on every `POST /auth/refresh`. | Method & path | Auth | Body | Notes | |---|---|---|---| -| `POST /appointments` | patient | `{ availabilitySlotId }` | **The concurrency-critical endpoint** — see `ARCHITECTURE.md §5.1` and `backend/tests/integration/booking.test.mts`. `409` if the slot is no longer open. Load-tested under contention (Phase 9, issue #86) — see `backend/tests/load/README.md`. | -| `GET /appointments/mine` | patient | — | Caller's own appointments, most recent first. | +| `POST /appointments` | patient | `{ availabilitySlotId, patientProfileId? }` | **The concurrency-critical endpoint** — see `ARCHITECTURE.md §5.1` and `backend/tests/integration/booking.test.mts`. `409` if the slot is no longer open. Load-tested under contention (Phase 9, issue #86) — see `backend/tests/load/README.md`. `patientProfileId` (Phase 8, issue #42) books for a dependent you're an active guardian of; omit it (or pass your own profile id) to book for yourself. Someone else's profile or dependent: `404`, checked before anything is booked. A dependent whose guardianship ended (revoked / reached majority): `403 GUARDIANSHIP_INACTIVE`. | +| `GET /appointments/mine` | patient | — | Caller's own appointments, most recent first. `?patientProfileId=` lists an active dependent's instead (same authorisation as booking). | | `GET /appointments/doctor/today` | doctor | — | Caller's scheduled appointments for the current server-local calendar day, ordered by slot start time. | | `PATCH /appointments/:appointmentId/status` | doctor (treating doctor only) | `{ status: 'completed'\|'cancelled'\|'no_show' }` | Issue #3's lifecycle, and the trigger for the referral cascade (#67): if the appointment came from a referral, the referral moves in the same transaction (completed → referral completed; cancelled → referral back to `pending` and the slot released; no-show → count++, pending until `NO_SHOW_EXPIRY_THRESHOLD` (2) then `expired`). `409` unless the appointment is still `scheduled`. | | `PATCH /appointments/:appointmentId/cancel` | patient (own appointment) | — | Patient-side cancellation, `scheduled` only. Same slot-release + cascade behaviour as above. | @@ -322,6 +322,19 @@ clinical order details are exposed. The facility totals object contains BigInt-safe decimal string. ## Patients (`/patients`) — `backend/src/modules/patients` +### Dependents (Phase 8, issue #42 — `docs/adr/0011`) + +| Method & path | Auth | Body | Notes | +|---|---|---|---| +| `POST /patients/me/dependents` | patient, verified email | `{ fullName, dateOfBirth: 'YYYY-MM-DD' }` | Creates a **new** dependent profile (no login) with you as guardian. `201 { id, fullName, dateOfBirth, guardianSince }` — `id` is the dependent's `patientProfileId` for booking. Strict body: any other field (e.g. `patientProfileId`, `userId`) is `400` — an existing profile can never be linked. `400 DEPENDENT_NOT_A_MINOR` at or over `AGE_OF_MAJORITY_YEARS` (default 18); `400` for a future/invalid date. | +| `GET /patients/me/dependents` | patient | — | Your **active** dependents only (not revoked, still under the age of majority), oldest guardianship first. Never another guardian's. | + +Guardian access ends automatically on the dependent's majority birthday +(00:00 UTC): they drop out of the list and booking for them returns +`403 GUARDIANSHIP_INACTIVE`. Their profile and appointments are kept. There's +no revoke endpoint in v1 (the model's `revokedAt` is honoured everywhere; +guardian/admin revocation is a follow-up). + ### Data export — right of access (Phase 8, issue #40) | Method & path | Auth | Notes | @@ -410,6 +423,11 @@ revocation list yet, issue #18), but every patient route returns `404` for it and booking/payment routes return `403 EMAIL_NOT_VERIFIED`. Clients should discard their tokens on `204`. +A guardian who deletes their account (issue #42, `docs/adr/0011`) has every +guardianship revoked in the same transaction; their dependents' profiles and +appointments are untouched, and the revoked rows are kept as the access +record. + A scheduled purge later hard-deletes deleted accounts that have **no** retained records, `ACCOUNT_PURGE_GRACE_HOURS` (default 24) after deletion.