Skip to content
Open
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
29 changes: 28 additions & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,23 @@ immediately by a new request). Sections run one at a time and the bundle is
flushed in bounded chunks to `PatientExportChunk` (§4.11), so memory is
bounded by one page of rows plus one chunk regardless of history length.

### 3.1 Account deletion and retention holds (Phase 8, issue #41)

`DELETE /patients/me` is orchestrated by `patients` and anonymises in one
transaction: the `User` row through `auth`'s service (`anonymizeAccount`,
which takes the caller's transaction client) and the `PatientProfile`
through `patients`' own repository. Nothing is cascaded; see
`docs/adr/0007` for the per-model retention table.

The scheduled purge (`src/jobs/account-purge.ts`) may only hard-delete a
*tombstone*: an anonymised patient that no module still holds records for.
"Holds records" is answered by each owning module through its own service,
registered in `src/retention-holds.ts` (wiring, outside `modules/`, because
`patients` can't import modules that already import it). The RESTRICT
foreign keys are the backstop. **A new module with patient-linked records
must do both:** reference `PatientProfile` with `onDelete: Restrict`, and add
a hold.

## 4. Data models

Canonical source of truth: `backend/prisma/schema.prisma`. This section is the human-readable mirror — if they drift, the schema file wins and this doc should be corrected in the same PR.
Expand All @@ -107,6 +124,10 @@ User
Backfilled to created_at for pre-existing rows in the
add_email_verification migration, so accounts from before this phase
are treated as verified)
deleted_at (nullable — Phase 8 issue #41, docs/adr/0007; set once,
irreversibly, when the account is deleted. The row is anonymised in
place: email -> deleted+<id>@deleted.invalid, phone null, password
unusable, email_verified_at null. Login and refresh refuse it.)
created_at, updated_at

EmailVerificationToken (Phase 8 issue #34)
Expand All @@ -118,7 +139,11 @@ EmailVerificationToken (Phase 8 issue #34)
guarantee, so two concurrent redemptions can't both win.

PatientProfile (1:1 User)
id, user_id, full_name, date_of_birth, guardian_user_id (nullable — see PRD edge case: minors)
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)
anonymized_at (nullable — issue #41; set with full_name -> 'Deleted patient',
date_of_birth/guardian_user_id -> null. The row stays so Appointment and
PaymentIntent keep their FK; both of those reference it ON DELETE RESTRICT.)

DoctorProfile (1:1 User)
id, user_id, full_name, verification_status (enum: pending|verified|rejected)
Expand Down Expand Up @@ -452,6 +477,8 @@ surfaces as `deleted_user`.
4. JWT refresh rotation without a revocation list — a stolen refresh token has no clean kill switch until this is added.
5. **Discovered 2026-10-15 while building email verification (Phase 8, issue #34):** the email/SMS provider is still undecided (Phase 6, issue #17), so verification links can't actually be delivered in a hosted environment yet — `backend/src/modules/auth/services/email-dispatcher.ts` is a no-op seam until a provider is integrated. Until then `backend/scripts/mark-email-verified.mjs` is the documented out-of-band path for an operator with DB access. Not a reason to skip the guard: the guard is what makes the flow safe once delivery exists, and shipping it locked-off would just mean shipping it untested.

6. **Discovered 2026-10-17 (issue #41):** after `DELETE /patients/me`, the caller's access token (stateless JWT, 15-minute TTL) still passes `authenticate` until it expires — there's no revocation list (item 4 above, issue #18). It can't reach anything: every patient route resolves the caller through `getMyPatientProfile`, which refuses an anonymised profile; booking/payments refuse it via `requireVerifiedEmail` (verification is cleared); refresh refuses `deleted_at`. Closing the window itself is #18's job. Also: Postgres reports `ON DELETE RESTRICT` as SQLSTATE 23001, which Prisma surfaces as an *unknown* request error, not `P2003` — `patients/services` `isForeignKeyRefusal` handles both; `middleware/error-handler.ts` currently maps neither to a 4xx.

### 6.2 What edge cases are we missing (architecture-level)?
1. What happens to `AvailabilitySlot.status = 'held'` if a booking transaction fails/times out mid-flight — is there a TTL to release the hold? (Needed or slots leak into permanent limbo.)
2. Referral `expired` status exists in the enum but no expiry job is designed yet — orphaned referrals (PRD 8.1.3 sibling problem). **Partly addressed 2026-10-17 (Phase 3, issue #67):** repeated no-shows now drive a referral to `expired` (`NO_SHOW_EXPIRY_THRESHOLD = 2`, with `referring_doctor_notified_at` set as the seam Phase 6's notification layer will read). What is still missing is **time-based** expiry — a pending referral nobody ever accepts, or one whose target doctor never offers a slot, has no TTL and no worker. Tracked as issue #8; per §6.3.2 it should ship as a simple interval/cron, not a queue.
Expand Down
3 changes: 2 additions & 1 deletion ARCHITECTURE_ESSENTIALS.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ Quick-reference only. Full reasoning lives in `ARCHITECTURE.md`. Full product sc
- Auth: JWT access + refresh, roles = `patient | doctor | facility | admin`. The refresh cookie is `SameSite=None` cross-site, so `/auth/refresh` + `/auth/logout` require an `X-CSRF-Token` header (a per-session token bound into the refresh JWT) and an allowlisted `Origin` — see `backend/src/middleware/csrf.ts`. CORS is an explicit per-environment allowlist (`CORS_ORIGINS`).
- Frontend: React + TypeScript + Vite, React Query + Zustand.
- Video/voice, SMS/email, file storage: **third-party, behind an interface** — never build custom.
- Jobs: keep it simple (cron/interval) until a real job exists; don't pre-install a queue. Existing interval jobs: login-throttle prune (`src/server.ts`) and patient-export worker/cleanup (`src/jobs/patient-exports.ts`), both started only from `server.ts`.
- Jobs: keep it simple (cron/interval) until a real job exists; don't pre-install a queue. Existing interval jobs: login-throttle prune (`src/server.ts`), patient-export worker/cleanup (`src/jobs/patient-exports.ts`) and deleted-account tombstone purge (`src/jobs/account-purge.ts`), all started only from `server.ts`.
- New module holding patient data? Register a patient-export section (ARCHITECTURE.md §3.1) — right of access must stay complete.

## Hard rules (violating these = request changes on the PR)
Expand All @@ -30,6 +30,7 @@ Quick-reference only. Full reasoning lives in `ARCHITECTURE.md`. Full product sc
`User` → `AccessLog` (Phase 8, issue #45 — PHI read-access log: who viewed which clinical record, when; fire-and-forget writes, fail-open).
`LoginAttempt` (Phase 8, issue #35 — Postgres-backed login throttle buckets, one per normalised email and per client IP; counters move only via a conditional upsert).
`PatientProfile` → `PatientExport` → `PatientExportChunk` (Phase 8, issue #40 — right-of-access export jobs + their chunked JSON bundle; each module contributes a section via its own service, registered in `src/export-sections.ts` — see ARCHITECTURE.md §3.1).
Account deletion (Phase 8, issue #41 — docs/adr/0007): anonymise in place (`User.deletedAt`, `PatientProfile.anonymizedAt`), never cascade. `PatientProfile.userId` is ON DELETE RESTRICT; new patient-linked clinical tables must be RESTRICT too **and** register a retention hold in `src/retention-holds.ts`.
`FacilityProfile` → `WalletLink` (payout address) ; `Prescription` or `LabOrder` → exactly one typed `PaymentIntent` FK (issue #85)

## Payments (Phase 5.5 — see docs/adr/0004-custodial-payments-v1.md)
Expand Down
1 change: 1 addition & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ roadmap. See each phase section below for status and issue links.
- [ ] Admin account lifecycle: secure bootstrap CLI, admin invites, and account deactivation. Tracked as [issue #39](https://github.com/DocForum/docforum-core/issues/39) (High, 200 pts) — **in progress**.
- [ ] 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/0007` — 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 0007 table).
- [ ] 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
5 changes: 5 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,11 @@ PATIENT_EXPORT_CLEANUP_INTERVAL_SECONDS=300
PATIENT_EXPORT_STALE_AFTER_SECONDS=900
PATIENT_EXPORT_MAX_ATTEMPTS=3

# Account deletion purge (Phase 8, issue #41, docs/adr/0007). Only record-free
# tombstones are purged; the grace period is operational, not legal.
ACCOUNT_PURGE_GRACE_HOURS=24
ACCOUNT_PURGE_INTERVAL_SECONDS=21600

# 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,12 @@
-- DropForeignKey
ALTER TABLE "PatientProfile" DROP CONSTRAINT "PatientProfile_userId_fkey";

-- AlterTable
ALTER TABLE "PatientProfile" ADD COLUMN "anonymizedAt" TIMESTAMP(3);

-- AlterTable
ALTER TABLE "User" ADD COLUMN "deletedAt" TIMESTAMP(3);

-- AddForeignKey
ALTER TABLE "PatientProfile" ADD CONSTRAINT "PatientProfile_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

26 changes: 23 additions & 3 deletions backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,13 @@ model User {
// token for the user, since none of them carry the new value — the
// interim kill switch issue #33 asked for while #18 doesn't exist yet.
tokenVersion Int @default(0)
// Phase 8 (issue #41, docs/adr/0007): set once, irreversibly, when the
// account is deleted. The row is anonymised in place (email tombstoned,
// phone cleared, password unusable) rather than deleted, because clinical
// and payment records still reference its patient profile. Refresh is
// refused once set. Distinct from admin deactivation (#39), which is
// reversible.
deletedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

Expand Down Expand Up @@ -266,10 +273,19 @@ model PatientProfile {
// resolved here. Don't build on top of this field assuming it means
// anything enforced yet.
guardianUserId String?
// Phase 8 (issue #41, docs/adr/0007): 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

user User @relation(fields: [userId], references: [id], onDelete: Cascade)
// RESTRICT, not CASCADE (issue #41): a patient's User row is never
// hard-deleted while this profile exists — deletion is anonymisation
// (ADR 0007), 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)
appointments Appointment[]
paymentIntents PaymentIntent[]
prescriptions Prescription[]
Expand Down Expand Up @@ -466,7 +482,9 @@ model Appointment {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

patientProfile PatientProfile @relation(fields: [patientProfileId], references: [id])
// Explicit RESTRICT (was the Prisma default — same SQL): an appointment is
// a clinical record and must outlive any account deletion (ADR 0007).
patientProfile PatientProfile @relation(fields: [patientProfileId], references: [id], onDelete: Restrict)
doctorProfile DoctorProfile @relation(fields: [doctorProfileId], references: [id])
availabilitySlot AvailabilitySlot? @relation(fields: [availabilitySlotId], references: [id])
referral Referral? @relation("ReferralOriginatedAppointments", fields: [referralId], references: [id])
Expand Down Expand Up @@ -745,7 +763,9 @@ model PaymentIntent {
labOrder LabOrder? @relation(fields: [labOrderId], references: [id])

patientProfileId String
patientProfile PatientProfile @relation(fields: [patientProfileId], references: [id])
// Explicit RESTRICT (was the Prisma default — same SQL): a payment record
// must outlive any account deletion (ADR 0007).
patientProfile PatientProfile @relation(fields: [patientProfileId], references: [id], onDelete: Restrict)
facilityId String
facility FacilityProfile @relation(fields: [facilityId], references: [id])

Expand Down
3 changes: 3 additions & 0 deletions backend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,15 @@ import { escrowEventsRouter } from './modules/payments/routes/escrow-events';
import { requestId } from './middleware/request-id';
import { referralsRouter } from './modules/referrals/routes';
import { consultationsRouter } from './modules/consultations/routes';
import { registerRetentionHolds } from './retention-holds';

export function createApp() {
const app = express();

// Phase 8, issue #40: idempotent, so building the app twice is harmless.
registerPatientExportSections();
// Phase 8, issue #41: idempotent, so building the app twice is harmless.
registerRetentionHolds();

// Phase 8, issue #35: login throttling keys on the client IP, so the
// proxy chain has to be resolved deliberately rather than guessed. See
Expand Down
9 changes: 9 additions & 0 deletions backend/src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,15 @@ export const config = {
staleAfterSeconds: Number(process.env.PATIENT_EXPORT_STALE_AFTER_SECONDS ?? 15 * 60),
maxAttempts: Number(process.env.PATIENT_EXPORT_MAX_ATTEMPTS ?? 3),
},

// Account deletion purge (Phase 8, issue #41, docs/adr/0007). Only
// tombstones with no retained records are ever purged; the grace period is
// operational (lets requests in flight under a still-valid access token
// finish), not a legal retention period — that one is an open decision.
accountPurge: {
graceHours: Number(process.env.ACCOUNT_PURGE_GRACE_HOURS ?? 24),
intervalSeconds: Number(process.env.ACCOUNT_PURGE_INTERVAL_SECONDS ?? 6 * 60 * 60),
},
// 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.
Expand Down
1 change: 1 addition & 0 deletions backend/src/jobs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ safe.
| Login-throttle prune (issue #35) | `src/server.ts` | Deletes stale `LoginAttempt` buckets. |
| Patient-export worker (issue #40) | `patient-exports.ts` | Generates pending exports one at a time; woken immediately by a new request, plus every `PATIENT_EXPORT_WORKER_INTERVAL_SECONDS`. Requeues claims older than `PATIENT_EXPORT_STALE_AFTER_SECONDS` (up to `PATIENT_EXPORT_MAX_ATTEMPTS`). |
| Patient-export cleanup (issue #40) | `patient-exports.ts` | Every `PATIENT_EXPORT_CLEANUP_INTERVAL_SECONDS`: expires `ready` exports past `expiresAt` and deletes their chunks, plus any chunk that can never be served. |
| Deleted-account purge (issue #41, ADR 0007) | `account-purge.ts` | Every `ACCOUNT_PURGE_INTERVAL_SECONDS`: hard-deletes anonymised patients that no module holds records for (`src/retention-holds.ts`), once `ACCOUNT_PURGE_GRACE_HOURS` has passed. Never deletes clinical, order or payment rows. Logs counts and error types only. |

Job logs carry error *types* only, never messages — a driver error can echo
the patient data it was handling.
33 changes: 33 additions & 0 deletions backend/src/jobs/account-purge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// Deleted-account tombstone purge (Phase 8, issue #41, docs/adr/0007). A
// plain interval, per ARCHITECTURE_ESSENTIALS.md ("keep jobs simple until a
// real job exists — don't pre-install a queue"); move it onto a shared job
// runner if one is ever introduced. Started from src/server.ts only, so test
// suites that build the app never start a timer.
import { config } from '../config';
import { purgeDeletedPatientAccounts } from '../modules/patients/services';

export function startAccountPurgeJob(): () => void {
let running = false;
const run = () => {
if (running) return;
running = true;
void purgeDeletedPatientAccounts()
.then(({ purged, held, failed }) => {
if (purged > 0 || failed > 0) {
// Counts only — never ids or personal fields.
// eslint-disable-next-line no-console
console.log(`[jobs] account purge: ${purged} purged, ${held} retained (records), ${failed} failed`);
}
})
.catch((err) => {
// eslint-disable-next-line no-console
console.error(`[jobs] account purge failed: ${err instanceof Error ? err.name : typeof err}`);
})
.finally(() => {
running = false;
});
};
const timer = setInterval(run, config.accountPurge.intervalSeconds * 1000);
timer.unref();
return () => clearInterval(timer);
}
Loading
Loading