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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,41 @@ Each module under `backend/src/modules/<name>/` owns its own `routes/`, `control
- `notifications` — outbound notification dispatch, provider-agnostic.
- `audit` — the append-only `AuditEvent` trail (Phase 8, issue #43). Owns one write helper, `auditService.record(tx, actor, event)`, that every module calls **inside the transaction of the state change it describes** (it refuses the root client), and the admin-only `GET /admin/audit-events`. Controllers build the actor with `auditActor(req)` (`src/middleware/request-id.ts`: user id, role, `X-Request-Id`). Wired today: appointment booking, doctor verification, payment fund/release/refund. **Phase 3/4 transitions (referrals, prescriptions, lab orders, fulfillment) must call `record()` in the same transaction when they're built** — that wiring is not done here.

### 3.1 Patient data export registry (Phase 8, issue #40)

Right of access (`POST /patients/me/export`) is coordinated by `patients`,
but **each module contributes its own section through its own `services/`
interface** — `patients` never reads another module's tables to build an
export. The pieces:

- `modules/patients/services/export-registry.ts` — the `PatientExportSection`
contract (`name`, `version`, `pages(subject, pageSize)` yielding pages of
plain JSON items), `registerExportSection`, and a `paginate` keyset helper.
- `src/export-sections.ts` — the one explicit list of registered sections,
called from `createApp()`. It's wiring, outside `modules/`, so `patients`
doesn't have to import every module that already imports `patients`.
- Current sections: `account` (auth: email/phone/role, never the password
hash), `profile` (patients), `appointments` (appointments, with slot times
from `availability` and the doctor's name from `doctors`, both via their
services), `payments` (payments: amounts, status, on-chain tx hash /
escrow id / contract id; the platform's signing secrets never live in a
row and can't reach an export).

**Adding a module with patient data (referrals, prescriptions, lab orders,
…):** export a `PatientExportSection` from that module's `services/` and add
it to `src/export-sections.ts`. Rules for a section: an explicit field
allowlist (never spread a Prisma row — a column added later must not leak by
default); page through your own tables with `paginate` (at most `pageSize`
rows in memory); batch any cross-module lookups per page through the other
module's service; JSON-safe values only (BigInt → string). Bump `version`
when the item shape changes. A section that throws fails the whole export —
there is no "partial" bundle.

Generation runs in `src/jobs/patient-exports.ts` (plain intervals, woken
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.

## 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 Down Expand Up @@ -230,6 +265,32 @@ Transitions recorded (action: from → to):
- `appointment.booked`: `null → scheduled` (metadata: slot id, doctor profile id, origin)
- `doctor.verification_status_changed`: previous → new verification status (read under `FOR UPDATE`; a no-op set records nothing)
- `payment_intent.funded` `created → escrowed`, `payment_intent.released` `escrowed → released`, `payment_intent.refunded` `escrowed → refunded` (metadata: order type, amount, escrow id, tx hash, contract id — public on-chain references only). The status write is now conditional on the observed state, so a racing second action gets `409` and records nothing.
### 4.11 Patient data export (Phase 8, issue #40)
```
PatientExport
id, patient_profile_id (FK -> PatientProfile, cascade — a derived copy)
status (enum: pending|processing|ready|failed|expired)
schema_version (nullable int — bundle format written), attempt (int, fencing token)
started_at, generated_at, expires_at (nullable)
size_bytes, chunk_count (nullable — set on ready)
failure_code, failed_section (nullable — machine-readable only, never an error message)
created_at, updated_at
# Every transition is a conditional UPDATE on the expected status (and
attempt), so two workers can't both generate one job and a stale claim
can't publish over a newer one. One in-flight job per patient (request
locks the PatientProfile row FOR UPDATE).
# The row is kept after expiry (no patient data in it) so status reads
answer "expired" rather than 404.

PatientExportChunk
export_id (FK -> PatientExport, cascade), attempt, seq — composite PK
data (text — one slice of the JSON bundle, ~PATIENT_EXPORT_CHUNK_BYTES)
# Only chunks of the export's current attempt are ever served; the cleanup
job deletes other attempts' and expired/failed exports' chunks.
```
Bundle format (schemaVersion 1): `{ format: "docforum.patient-export",
schemaVersion, exportId, generatedAt, sections: { <name>: { version, items } } }`
— see §3.1 for where sections come from.

## 5. Key flows (sequence-level, not code)

Expand Down Expand Up @@ -271,6 +332,7 @@ Transitions recorded (action: from → to):
1. Polymorphic fulfillment design (§4.7) may itself be premature abstraction — two small, boring, separate tables might beat one clever polymorphic one at this stage. Flagged as a design smell to revisit, not shipped as-is.
2. BullMQ/Redis job queue before there's a single job that needs it — fine to stub with a simple cron/interval check initially and introduce a real queue when reminder/expiry logic actually exists.
3. Designing for horizontal service extraction (module boundary purity, §3) before there's a single reason to split — worth doing lightly (it's cheap discipline), but not worth extra ceremony (e.g., separate repos, separate CI, internal API versioning) yet.
4. **Added 2026-10-17 (issue #40):** export bundles are stored as text chunks in Postgres (`PatientExportChunk`, §4.11), not in object storage as §2's file-storage row prescribes — no object-storage provider is integrated yet, and a filesystem store would break on Render's ephemeral disk and on any second instance. The chunks are read/written only by `patients/repositories`, are short-lived (`PATIENT_EXPORT_TTL_HOURS`, default 24) and are deleted by the cleanup job, so moving to S3 later swaps one storage seam rather than the design. Revisit when lab-result storage (Phase 5, issue #16) picks a provider.

## 7. Non-functional / cross-cutting

Expand Down
4 changes: 3 additions & 1 deletion ARCHITECTURE_ESSENTIALS.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ 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.
- 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`.
- 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)
1. **Never mutate a `Prescription` or `LabOrder` after issuance.** Corrections = new row with `supersedes_*_id`.
Expand All @@ -27,6 +28,7 @@ Quick-reference only. Full reasoning lives in `ARCHITECTURE.md`. Full product sc
`FulfillmentRecord` (facility-owned) references an order but doesn't mutate it.
`User` → `EmailVerificationToken` (Phase 8, issue #34 — hashed/expiring, single-use; `User.emailVerifiedAt` gates booking + payment mutations, never login/browsing).
`LoginAttempt` (Phase 8, issue #35 — Postgres-backed login throttle buckets, one per normalised email and per client IP; counters move only via a conditional upsert).
`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).
`FacilityProfile` → `WalletLink` (payout address) ; `PatientProfile`/`FacilityProfile` → `PaymentIntent` (opaque `orderType`/`orderId` — see docs/adr/0004)

## Payments (Phase 5.5 — see docs/adr/0004-custodial-payments-v1.md)
Expand Down
12 changes: 12 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ for status and issue links.
- [x] Login brute-force protection: per-account and per-IP throttling with backoff. Postgres-backed buckets (`LoginAttempt`), a single conditional upsert so concurrent failures are counted atomically, exponential backoff with `Retry-After`, a bounded temporary lock, an admin clear endpoint, a prune interval in `src/server.ts`, and a `TRUST_PROXY` setting so the IP isn't Render's proxy address. Tracked as [issue #35](https://github.com/DocForum/docforum-core/issues/35) (High, 200 pts) — **Done**, merged as PR #138.
- [ ] CSRF protection for the cookie-authenticated refresh/logout endpoints, plus an explicit per-environment CORS allowlist. A per-session CSRF token bound into the refresh token (not a double-submit cookie — a cookie set by this API is third-party to `docforum.github.io`, so the web client can't read it), a required `X-CSRF-Token` header on `/auth/refresh` + `/auth/logout`, an `Origin` allowlist check, and `CORS_ORIGINS` replacing the single configured origin. Tracked as [issue #37](https://github.com/DocForum/docforum-core/issues/37) (High, 200 pts) — **in progress**, branch `feat/csrf-protection-for-cookie-authenticated-refresh`.
- [ ] 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, doctor verification and payment fund/release/refund. Referrals, prescriptions, lab orders and fulfillment (Phases 3–5) must call `record()` when they land; the login-throttle admin clear still logs to console only. Tracked as [issue #43](https://github.com/DocForum/docforum-core/issues/43) (High, 200 pts) — **in review**, branch `feat/43-auditevent-foundation`.
- [ ] 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`.
Expand Down Expand Up @@ -382,6 +383,17 @@ for status and issue links.
tests assert a
forged cross-origin request is rejected while the legitimate cross-site
client still succeeds.
- 2026-10-17 — Issue #40 (patient data export, right of access) opened as a
PR: export job + status + streamed download routes under
`/patients/me/export`, owner-only (doctors/admins `403`, someone else's id
`404`). An exporter registry (`src/export-sections.ts`) where each module
contributes its own section through its service — documented in
ARCHITECTURE.md §3.1 so new modules know to register. Bundles are written
page by page into bounded Postgres chunks (no object-storage provider yet
— recorded as ARCHITECTURE.md §6.3.4), expire after 24 h by default, and
are deleted by an interval cleanup job. Job state moves only through
conditional UPDATEs with an attempt fencing token, so concurrent workers
and crashed claims can't publish a partial bundle.
- 2026-10-17 — Issue #43 (AuditEvent foundation) opened as a PR: the
`AuditEvent` table (append-only, enforced by DB triggers; no FKs on
purpose), an `audit` module whose `record()` refuses to run outside a
Expand Down
9 changes: 9 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,15 @@ LOGIN_THROTTLE_LOCK_SECONDS=900
LOGIN_THROTTLE_PRUNE_INTERVAL_SECONDS=3600
LOGIN_THROTTLE_PRUNE_AFTER_SECONDS=86400

# Patient data export (Phase 8, issue #40). Defaults are in src/config/index.ts.
PATIENT_EXPORT_TTL_HOURS=24
PATIENT_EXPORT_PAGE_SIZE=200
PATIENT_EXPORT_CHUNK_BYTES=262144
PATIENT_EXPORT_WORKER_INTERVAL_SECONDS=15
PATIENT_EXPORT_CLEANUP_INTERVAL_SECONDS=300
PATIENT_EXPORT_STALE_AFTER_SECONDS=900
PATIENT_EXPORT_MAX_ATTEMPTS=3

# 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,45 @@
-- CreateEnum
CREATE TYPE "PatientExportStatus" AS ENUM ('pending', 'processing', 'ready', 'failed', 'expired');

-- CreateTable
CREATE TABLE "PatientExport" (
"id" TEXT NOT NULL,
"patientProfileId" TEXT NOT NULL,
"status" "PatientExportStatus" NOT NULL DEFAULT 'pending',
"schemaVersion" INTEGER,
"attempt" INTEGER NOT NULL DEFAULT 0,
"startedAt" TIMESTAMP(3),
"generatedAt" TIMESTAMP(3),
"expiresAt" TIMESTAMP(3),
"sizeBytes" INTEGER,
"chunkCount" INTEGER,
"failureCode" TEXT,
"failedSection" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,

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

-- CreateTable
CREATE TABLE "PatientExportChunk" (
"exportId" TEXT NOT NULL,
"attempt" INTEGER NOT NULL,
"seq" INTEGER NOT NULL,
"data" TEXT NOT NULL,

CONSTRAINT "PatientExportChunk_pkey" PRIMARY KEY ("exportId","attempt","seq")
);

-- CreateIndex
CREATE INDEX "PatientExport_patientProfileId_createdAt_idx" ON "PatientExport"("patientProfileId", "createdAt");

-- CreateIndex
CREATE INDEX "PatientExport_status_expiresAt_idx" ON "PatientExport"("status", "expiresAt");

-- AddForeignKey
ALTER TABLE "PatientExport" ADD CONSTRAINT "PatientExport_patientProfileId_fkey" FOREIGN KEY ("patientProfileId") REFERENCES "PatientProfile"("id") ON DELETE CASCADE ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "PatientExportChunk" ADD CONSTRAINT "PatientExportChunk_exportId_fkey" FOREIGN KEY ("exportId") REFERENCES "PatientExport"("id") ON DELETE CASCADE ON UPDATE CASCADE;

68 changes: 68 additions & 0 deletions backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,74 @@ model PatientProfile {
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
appointments Appointment[]
paymentIntents PaymentIntent[]
dataExports PatientExport[]
}

// Phase 8 (issue #40): right-of-access export jobs. One row per request;
// the generated bundle itself lives in PatientExportChunk (below), not in
// this row. Lifecycle: pending -> processing -> ready -> expired, or
// -> failed. A job only moves forward through conditional UPDATEs
// (`WHERE status = ...`), the same shape as the booking guarantee, so two
// workers can never both generate one export.
enum PatientExportStatus {
pending
processing
ready
failed
expired
}

model PatientExport {
id String @id @default(uuid())
// The owner. Every read is scoped by this, never by the export id alone.
patientProfileId String
status PatientExportStatus @default(pending)
// Bundle format version actually written (set when generation starts).
schemaVersion Int?
// Fencing token: incremented on every claim. Chunks are keyed by it, and
// the final `ready` transition is conditional on it, so a worker whose
// claim went stale can never publish a half-written or superseded bundle.
attempt Int @default(0)
startedAt DateTime?
generatedAt DateTime?
// Set when the job becomes `ready`; downloads are refused at or after it
// even if the cleanup job hasn't run yet.
expiresAt DateTime?
sizeBytes Int?
// How many chunks the published attempt wrote. A download that finds
// fewer (cleanup ran mid-stream) aborts instead of handing out a
// silently truncated JSON document.
chunkCount Int?
// Machine-readable failure only (e.g. SECTION_FAILED + section name) —
// never an exception message, which could echo patient data.
failureCode String?
failedSection String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

patientProfile PatientProfile @relation(fields: [patientProfileId], references: [id], onDelete: Cascade)
chunks PatientExportChunk[]

@@index([patientProfileId, createdAt])
@@index([status, expiresAt])
}

// The export bundle, stored as ordered text chunks of one JSON document
// (issue #40). Written incrementally while sections are paged, read back
// one chunk at a time on download, so neither side ever holds a whole
// patient history in memory. Kept in Postgres rather than object storage
// because no object-storage provider is integrated yet (ARCHITECTURE.md §2
// and §6.3.4) — only patients/repositories reads or writes it, so moving to
// S3 later replaces that storage seam, not the export design.
model PatientExportChunk {
exportId String
attempt Int
seq Int
data String

export PatientExport @relation(fields: [exportId], references: [id], onDelete: Cascade)

@@id([exportId, attempt, seq])
}

model DoctorProfile {
Expand Down
Loading
Loading