From 7403c418115db65affe07cd16d4557b881da1d62 Mon Sep 17 00:00:00 2001 From: Zainab Wahab Date: Sat, 17 Oct 2026 07:48:45 +0100 Subject: [PATCH] feat(patients): patient data export (right of access) as a complete, machine-readable bundle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds POST /patients/me/export, GET /patients/me/export/:id and GET /patients/me/export/:id/download. Owner-only: the subject is always the authenticated patient, doctors/admins get 403, and another patient's export id is a 404. - Exporter registry: each module contributes its own section through its service (account/auth, profile/patients, appointments, payments), wired explicitly in src/export-sections.ts. Documented in ARCHITECTURE.md §3.1. - Versioned JSON bundle (format, schemaVersion, exportId, generatedAt, sections). Payments include tx hash / escrow id; field allowlists keep password hashes and platform secrets out. - Bounded memory: sections page via keyset pagination, the bundle is flushed as ~256 KiB chunks into PatientExportChunk and streamed back one chunk at a time with backpressure. - Lifecycle pending -> processing -> ready -> expired / failed, moved only by conditional UPDATEs with an attempt fencing token. One in-flight job per patient. A failing section fails the whole export and leaves no artifact. - Interval worker + cleanup in src/jobs/patient-exports.ts; downloads are refused at expiresAt, cleanup deletes the stored chunks. Migration: 20261017062203_add_patient_export. Closes #40 Co-Authored-By: Claude Opus 5.5 (1M context) --- ARCHITECTURE.md | 63 +++ ARCHITECTURE_ESSENTIALS.md | 4 +- ROADMAP.md | 14 +- backend/.env.example | 9 + .../migration.sql | 45 ++ backend/prisma/schema.prisma | 68 +++ backend/src/app.ts | 6 + backend/src/config/index.ts | 23 + backend/src/export-sections.ts | 25 + backend/src/jobs/README.md | 15 +- backend/src/jobs/patient-exports.ts | 55 ++ .../appointments/repositories/index.ts | 12 + .../modules/appointments/services/index.ts | 30 ++ backend/src/modules/auth/services/index.ts | 24 + .../availability/repositories/index.ts | 7 + .../modules/availability/services/index.ts | 8 + .../src/modules/doctors/repositories/index.ts | 4 + backend/src/modules/doctors/services/index.ts | 8 + .../src/modules/patients/controllers/index.ts | 53 +- .../modules/patients/repositories/index.ts | 159 ++++++ backend/src/modules/patients/routes/index.ts | 20 +- .../patients/services/export-bundle.ts | 120 +++++ .../patients/services/export-registry.ts | 84 +++ .../src/modules/patients/services/index.ts | 273 ++++++++++ .../modules/payments/repositories/index.ts | 17 + .../src/modules/payments/services/index.ts | 36 ++ backend/src/server.ts | 4 + .../tests/integration/patient-export.test.mts | 492 ++++++++++++++++++ backend/tests/unit/patient-export.test.ts | 213 ++++++++ docs/api/README.md | 64 ++- 30 files changed, 1945 insertions(+), 10 deletions(-) create mode 100644 backend/prisma/migrations/20261017062203_add_patient_export/migration.sql create mode 100644 backend/src/export-sections.ts create mode 100644 backend/src/jobs/patient-exports.ts create mode 100644 backend/src/modules/patients/services/export-bundle.ts create mode 100644 backend/src/modules/patients/services/export-registry.ts create mode 100644 backend/tests/integration/patient-export.test.mts create mode 100644 backend/tests/unit/patient-export.test.ts diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 8fb9ba97..620d6430 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -54,6 +54,41 @@ Each module under `backend/src/modules//` owns its own `routes/`, `control - `pharmacy-orders` — fulfillment-side record for prescriptions at a specific pharmacy (kept distinct from `prescriptions` itself — see data model note in §4.7). - `notifications` — outbound notification dispatch, provider-agnostic. +### 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.10), 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. @@ -197,6 +232,33 @@ LoginAttempt (issue #35) `User` fields added in Phase 8 live in §4.1; the `email_verified_at` / `EmailVerificationToken` pair (issue #34) is in §4.1 as well. +### 4.10 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: { : { version, items } } }` +— see §3.1 for where sections come from. + ## 5. Key flows (sequence-level, not code) ### 5.1 Booking (concurrency-safe) @@ -237,6 +299,7 @@ LoginAttempt (issue #35) 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.10), 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 diff --git a/ARCHITECTURE_ESSENTIALS.md b/ARCHITECTURE_ESSENTIALS.md index a5393e8a..0077ab7f 100644 --- a/ARCHITECTURE_ESSENTIALS.md +++ b/ARCHITECTURE_ESSENTIALS.md @@ -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`. @@ -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) diff --git a/ROADMAP.md b/ROADMAP.md index 501c0f56..1e3cbda2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,6 +1,6 @@ # ROADMAP.md — DocForum -Last updated: 2026-09-26 +Last updated: 2026-10-17 Status: Phase 0, Phase 1, and Phase 5.5 (Payments, custodial v1 — see `docs/adr/0004-custodial-payments-v1.md`) built and tested. Phases 2-6 and 8-9 scoped into GitHub issues (#1-#22, #21 closed); Phase 7 is @@ -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.10 — 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. - [ ] 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`. - [ ] Privacy access-control audit: confirm intake/consultation notes are visible only per PRD §7 (patient, treating doctor(s) in-thread, fulfilling facility for that specific order only) — write tests, not just code review. Still blocked — meaningfully auditable only once Phase 3/4/5 access paths exist. Not yet scoped into an issue. @@ -380,3 +381,14 @@ 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. diff --git a/backend/.env.example b/backend/.env.example index 78469632..e815beaa 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -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: diff --git a/backend/prisma/migrations/20261017062203_add_patient_export/migration.sql b/backend/prisma/migrations/20261017062203_add_patient_export/migration.sql new file mode 100644 index 00000000..f8ac4b86 --- /dev/null +++ b/backend/prisma/migrations/20261017062203_add_patient_export/migration.sql @@ -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; + diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 5ceed14b..d1f5ee8c 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -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 { diff --git a/backend/src/app.ts b/backend/src/app.ts index c9cd9bed..3c5193d3 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -8,10 +8,15 @@ import { doctorsRouter } from './modules/doctors/routes'; import { availabilityRouter } from './modules/availability/routes'; import { appointmentsRouter } from './modules/appointments/routes'; import { paymentsRouter } from './modules/payments/routes'; +import { patientsRouter } from './modules/patients/routes'; +import { registerPatientExportSections } from './export-sections'; export function createApp() { const app = express(); + // Phase 8, issue #40: idempotent, so building the app twice is harmless. + registerPatientExportSections(); + // Phase 8, issue #35: login throttling keys on the client IP, so the // proxy chain has to be resolved deliberately rather than guessed. See // config.trustProxy for why neither `true` nor the default is safe here. @@ -31,6 +36,7 @@ export function createApp() { app.use('/availability', availabilityRouter); app.use('/appointments', appointmentsRouter); app.use('/payments', paymentsRouter); + app.use('/patients', patientsRouter); // Must be registered last — Express only routes errors here once every // other handler has had a chance to run. diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts index 1e54027c..9a01e58f 100644 --- a/backend/src/config/index.ts +++ b/backend/src/config/index.ts @@ -86,6 +86,29 @@ export const config = { pruneIntervalSeconds: Number(process.env.LOGIN_THROTTLE_PRUNE_INTERVAL_SECONDS ?? 60 * 60), pruneAfterSeconds: Number(process.env.LOGIN_THROTTLE_PRUNE_AFTER_SECONDS ?? 24 * 60 * 60), }, + // Patient data export (Phase 8, issue #40). Env-configurable so a + // deployment can tune retention and memory bounds without a code change. + patientExport: { + // How long a `ready` export stays downloadable before the cleanup job + // deletes its artifact. Downloads are refused at expiry even if cleanup + // hasn't run yet. + ttlHours: Number(process.env.PATIENT_EXPORT_TTL_HOURS ?? 24), + // Rows fetched per section page while generating — the memory bound on + // the read side. + pageSize: Number(process.env.PATIENT_EXPORT_PAGE_SIZE ?? 200), + // Target size of one stored artifact chunk — the memory bound on the + // write side and on download (one chunk is in memory at a time). + chunkBytes: Number(process.env.PATIENT_EXPORT_CHUNK_BYTES ?? 256 * 1024), + // Worker cadence (src/jobs/patient-exports.ts). A new request also + // wakes the worker immediately; the interval is the restart/backlog + // safety net. + workerIntervalSeconds: Number(process.env.PATIENT_EXPORT_WORKER_INTERVAL_SECONDS ?? 15), + cleanupIntervalSeconds: Number(process.env.PATIENT_EXPORT_CLEANUP_INTERVAL_SECONDS ?? 5 * 60), + // A `processing` job whose claim is older than this is presumed dead + // (process crashed mid-generation) and is retried, up to maxAttempts. + staleAfterSeconds: Number(process.env.PATIENT_EXPORT_STALE_AFTER_SECONDS ?? 15 * 60), + maxAttempts: Number(process.env.PATIENT_EXPORT_MAX_ATTEMPTS ?? 3), + }, // 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/export-sections.ts b/backend/src/export-sections.ts new file mode 100644 index 00000000..f4f7b1f8 --- /dev/null +++ b/backend/src/export-sections.ts @@ -0,0 +1,25 @@ +// Composition root for the patient data export registry (Phase 8, issue +// #40). Each module owns and exports its own section from its services/; +// this file is the one explicit list of what an export contains, so a +// section can't silently go missing because its module happened not to be +// imported. Lives outside modules/ on purpose: it's wiring, not business +// logic, and keeps the patients module from importing every module that +// itself imports patients. +// +// New module with patient data? Export a PatientExportSection from its +// services/ and add it here. See ARCHITECTURE.md §3. +import { accountExportSection } from './modules/auth/services'; +import { appointmentsExportSection } from './modules/appointments/services'; +import { paymentsExportSection } from './modules/payments/services'; +import { profileExportSection, registerExportSection } from './modules/patients/services'; + +export const PATIENT_EXPORT_SECTIONS = [ + accountExportSection, + profileExportSection, + appointmentsExportSection, + paymentsExportSection, +]; + +export function registerPatientExportSections(): void { + for (const section of PATIENT_EXPORT_SECTIONS) registerExportSection(section); +} diff --git a/backend/src/jobs/README.md b/backend/src/jobs/README.md index 93851247..f75c7e05 100644 --- a/backend/src/jobs/README.md +++ b/backend/src/jobs/README.md @@ -6,4 +6,17 @@ Per ARCHITECTURE_ESSENTIALS.md: keep this simple (cron/interval) until a real job exists — do not introduce a queue (e.g. BullMQ/Redis) preemptively. See ARCHITECTURE.md §6.3.2 for the overengineering flag on this exact point. -No jobs implemented yet. See ROADMAP.md. +Jobs are started from `src/server.ts` only, never from `app.ts`, so test +suites that build the app don't start timers. Each job is a thin scheduler +around a module's service function; the state transitions themselves are +conditional UPDATEs in that module, so running more than one process is +safe. + +| Job | File | What it does | +|---|---|---| +| 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. | + +Job logs carry error *types* only, never messages — a driver error can echo +the patient data it was handling. diff --git a/backend/src/jobs/patient-exports.ts b/backend/src/jobs/patient-exports.ts new file mode 100644 index 00000000..8d7a7249 --- /dev/null +++ b/backend/src/jobs/patient-exports.ts @@ -0,0 +1,55 @@ +// Patient data export jobs (Phase 8, issue #40). Plain intervals, per +// ARCHITECTURE_ESSENTIALS.md ("keep jobs simple until a real job exists — +// don't pre-install a queue"). Started from src/server.ts only, so test +// suites that just build the app never start a timer; tests drive the +// service functions directly. +// +// Safe with more than one process: every state change underneath is a +// conditional UPDATE in the patients module, so two workers racing for the +// same job end with exactly one generating it. +import { config } from '../config'; +import { expireExports, onExportRequested, runExportWorker } from '../modules/patients/services'; + +/** Runs `task` unless a previous run is still going (no overlap within one + * process — generation is sequential by design). */ +function nonOverlapping(name: string, task: () => Promise) { + let running = false; + return () => { + if (running) return; + running = true; + void task() + .catch((err) => { + // Error type only: a database error message can echo row data. + // eslint-disable-next-line no-console + console.error(`[jobs] ${name} failed: ${err instanceof Error ? err.name : typeof err}`); + }) + .finally(() => { + running = false; + }); + }; +} + +export function startPatientExportJobs(): () => void { + const work = nonOverlapping('patient-export worker', () => runExportWorker()); + const cleanup = nonOverlapping('patient-export cleanup', async () => { + const { expired, orphanChunksDeleted } = await expireExports(); + if (expired > 0 || orphanChunksDeleted > 0) { + // eslint-disable-next-line no-console + console.log(`[jobs] expired ${expired} patient export(s), removed ${orphanChunksDeleted} orphan chunk(s)`); + } + }); + + onExportRequested(work); + const workTimer = setInterval(work, config.patientExport.workerIntervalSeconds * 1000); + const cleanupTimer = setInterval(cleanup, config.patientExport.cleanupIntervalSeconds * 1000); + workTimer.unref(); + cleanupTimer.unref(); + // Pick up anything left pending by a previous process. + work(); + + return () => { + onExportRequested(null); + clearInterval(workTimer); + clearInterval(cleanupTimer); + }; +} diff --git a/backend/src/modules/appointments/repositories/index.ts b/backend/src/modules/appointments/repositories/index.ts index 90a08027..4f2be7f8 100644 --- a/backend/src/modules/appointments/repositories/index.ts +++ b/backend/src/modules/appointments/repositories/index.ts @@ -10,6 +10,18 @@ export function createAppointment( return db.appointment.create({ data }); } +/** One keyset page of a patient's appointments, oldest first (patient data + * export, issue #40). Appointment columns only — slot/doctor details come + * from those modules' services, not a join. */ +export function findAppointmentsPageForPatient(patientProfileId: string, cursor: string | undefined, take: number) { + return prisma.appointment.findMany({ + where: { patientProfileId }, + orderBy: [{ createdAt: 'asc' }, { id: 'asc' }], + take, + ...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}), + }); +} + export function findAppointmentsForPatient(patientProfileId: string) { return prisma.appointment.findMany({ where: { patientProfileId }, diff --git a/backend/src/modules/appointments/services/index.ts b/backend/src/modules/appointments/services/index.ts index f8e50f13..5f0219f5 100644 --- a/backend/src/modules/appointments/services/index.ts +++ b/backend/src/modules/appointments/services/index.ts @@ -3,6 +3,7 @@ import { HttpError } from '../../../http-error'; import * as appointmentsRepo from '../repositories'; import * as availabilityService from '../../availability/services'; import * as patientsService from '../../patients/services'; +import * as doctorsService from '../../doctors/services'; /** * Books a slot for the calling patient. This is the correctness-critical @@ -48,3 +49,32 @@ export async function listMyAppointments(userId: string) { const patientProfile = await patientsService.getMyPatientProfile(userId); return appointmentsRepo.findAppointmentsForPatient(patientProfile.id); } + +/** This module's contribution to the patient data export (issue #40). + * Slot times and the doctor's name are fetched per page through the + * availability and doctors services — never by joining their tables. */ +export const appointmentsExportSection: patientsService.PatientExportSection = { + name: 'appointments', + version: 1, + pages(subject, pageSize) { + return patientsService.paginate( + pageSize, + (cursor, take) => appointmentsRepo.findAppointmentsPageForPatient(subject.patientProfileId, cursor, take), + async (rows) => { + const [slots, doctors] = await Promise.all([ + availabilityService.getSlotTimesByIds(rows.map((row) => row.availabilitySlotId)), + doctorsService.getDoctorSummariesByIds(rows.map((row) => row.doctorProfileId)), + ]); + return rows.map((row) => ({ + id: row.id, + status: row.status, + origin: row.origin, + slot: slots.get(row.availabilitySlotId) ?? null, + doctor: doctors.get(row.doctorProfileId) ?? null, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + })); + }, + ); + }, +}; diff --git a/backend/src/modules/auth/services/index.ts b/backend/src/modules/auth/services/index.ts index 88e3cf2a..6b3ea13c 100644 --- a/backend/src/modules/auth/services/index.ts +++ b/backend/src/modules/auth/services/index.ts @@ -27,6 +27,7 @@ import { } from './login-throttle'; import { csrfTokensMatch, generateCsrfToken } from '../../../middleware/csrf'; import type { Role } from '@prisma/client'; +import type { PatientExportSection } from '../../patients/services'; // Re-exported so middleware/auth.ts (shared infra, not a peer module) can // verify tokens through this module's service surface rather than @@ -326,3 +327,26 @@ export async function refresh(refreshToken: string, csrfToken?: string | null): const tokens = issueSession(user); return { user: toPublicUser(user), ...tokens }; } + +/** This module's contribution to the patient data export (issue #40): the + * login identity itself. Explicit allowlist — never the password hash, and + * no verification/throttle bookkeeping. */ +export const accountExportSection: PatientExportSection = { + name: 'account', + version: 1, + async *pages(subject) { + const user = await findUserById(subject.userId); + if (!user) return; + yield [ + { + id: user.id, + email: user.email, + phone: user.phone, + role: user.role, + emailVerifiedAt: user.emailVerifiedAt, + createdAt: user.createdAt, + updatedAt: user.updatedAt, + }, + ]; + }, +}; diff --git a/backend/src/modules/availability/repositories/index.ts b/backend/src/modules/availability/repositories/index.ts index 9cc9a73a..f236cf6b 100644 --- a/backend/src/modules/availability/repositories/index.ts +++ b/backend/src/modules/availability/repositories/index.ts @@ -14,6 +14,13 @@ export function findSlotById(db: DbClient, slotId: string) { return db.availabilitySlot.findUnique({ where: { id: slotId } }); } +export function findSlotTimesByIds(slotIds: string[]) { + return prisma.availabilitySlot.findMany({ + where: { id: { in: slotIds } }, + select: { id: true, startTime: true, endTime: true }, + }); +} + export function findExistingSlotRanges(doctorProfileId: string, startTime: Date, endTime: Date) { return prisma.availabilitySlot.findMany({ where: { doctorProfileId, startTime: { gte: startTime }, endTime: { lte: endTime } }, diff --git a/backend/src/modules/availability/services/index.ts b/backend/src/modules/availability/services/index.ts index 0b666f91..c9843dfe 100644 --- a/backend/src/modules/availability/services/index.ts +++ b/backend/src/modules/availability/services/index.ts @@ -52,6 +52,14 @@ export async function generateSlots(input: GenerateSlotsInput) { * Never import availability/repositories from another module directly. */ export { claimOpenSlot, findSlotById as getSlot } from '../repositories'; +/** Batch slot-time lookup for other modules (patient data export, issue + * #40). Times only — no status or doctor internals. */ +export async function getSlotTimesByIds(slotIds: string[]): Promise> { + if (slotIds.length === 0) return new Map(); + const rows = await availabilityRepo.findSlotTimesByIds([...new Set(slotIds)]); + return new Map(rows.map((row) => [row.id, { startTime: row.startTime, endTime: row.endTime }])); +} + export async function assertCanGenerateFor(userId: string, doctorProfileId: string) { await doctorsService.assertOwnsDoctorProfile(userId, doctorProfileId); } diff --git a/backend/src/modules/doctors/repositories/index.ts b/backend/src/modules/doctors/repositories/index.ts index b8d18f5c..cda5cb82 100644 --- a/backend/src/modules/doctors/repositories/index.ts +++ b/backend/src/modules/doctors/repositories/index.ts @@ -9,6 +9,10 @@ export function findDoctorProfileById(id: string) { return prisma.doctorProfile.findUnique({ where: { id } }); } +export function findDoctorSummariesByIds(ids: string[]) { + return prisma.doctorProfile.findMany({ where: { id: { in: ids } }, select: { id: true, fullName: true } }); +} + export function updateVerificationStatus(id: string, status: VerificationStatus) { return prisma.doctorProfile.update({ where: { id }, data: { verificationStatus: status } }); } diff --git a/backend/src/modules/doctors/services/index.ts b/backend/src/modules/doctors/services/index.ts index 90d63c42..573bc3e4 100644 --- a/backend/src/modules/doctors/services/index.ts +++ b/backend/src/modules/doctors/services/index.ts @@ -14,6 +14,14 @@ export async function getDoctorProfileById(doctorProfileId: string) { return profile; } +/** Batch id -> { id, fullName } lookup for other modules (patient data + * export, issue #40). Public-facing fields only. */ +export async function getDoctorSummariesByIds(ids: string[]): Promise> { + if (ids.length === 0) return new Map(); + const rows = await doctorsRepo.findDoctorSummariesByIds([...new Set(ids)]); + return new Map(rows.map((row) => [row.id, { id: row.id, fullName: row.fullName }])); +} + /** Only a doctor acting on their own profile may generate slots for it — * no admin-on-behalf-of override in Phase 1. */ export async function assertOwnsDoctorProfile(userId: string, doctorProfileId: string) { diff --git a/backend/src/modules/patients/controllers/index.ts b/backend/src/modules/patients/controllers/index.ts index 40f5ed94..e529de78 100644 --- a/backend/src/modules/patients/controllers/index.ts +++ b/backend/src/modules/patients/controllers/index.ts @@ -1,2 +1,51 @@ -// patients controllers — placeholder. -export {}; +import type { Request, Response } from 'express'; +import { once } from 'node:events'; +import * as patientsService from '../services'; +import { HttpError } from '../../../http-error'; +import { getParam } from '../../../request-params'; + +// Phase 8, issue #40 — right of access. The subject is always `req.user`; +// no route here accepts a patient id. + +export async function requestExport(req: Request, res: Response) { + if (!req.user) throw new HttpError(401, 'Authentication required.'); + const { export: view } = await patientsService.requestMyExport(req.user.id); + res.set('Location', `/patients/me/export/${view.id}`); + // 202 for a new job and for an already-running one alike: either way the + // client polls the returned id. + res.status(202).json(view); +} + +export async function getExport(req: Request, res: Response) { + if (!req.user) throw new HttpError(401, 'Authentication required.'); + const view = await patientsService.getMyExport(req.user.id, getParam(req, 'exportId')); + res.status(200).json(view); +} + +/** Streams the stored bundle chunk by chunk, honouring backpressure, so a + * large export never sits in memory whole. */ +export async function downloadExport(req: Request, res: Response) { + if (!req.user) throw new HttpError(401, 'Authentication required.'); + const download = await patientsService.openMyExportDownload(req.user.id, getParam(req, 'exportId')); + + res.status(200); + res.set({ + 'Content-Type': 'application/json; charset=utf-8', + 'Content-Length': String(download.sizeBytes), + 'Content-Disposition': `attachment; filename="${download.filename}"`, + 'Cache-Control': 'no-store', + }); + + try { + for await (const chunk of download.chunks()) { + if (res.destroyed) return; + // Wait for the socket to drain, or give up if the client went away. + if (!res.write(chunk)) await Promise.race([once(res, 'drain'), once(res, 'close')]); + } + res.end(); + } catch (err) { + // Headers are already sent, so there's no error envelope to send: cut + // the connection rather than end a truncated document cleanly. + res.destroy(err instanceof Error ? err : new Error('EXPORT_STREAM_FAILED')); + } +} diff --git a/backend/src/modules/patients/repositories/index.ts b/backend/src/modules/patients/repositories/index.ts index 88786928..8f8d28b9 100644 --- a/backend/src/modules/patients/repositories/index.ts +++ b/backend/src/modules/patients/repositories/index.ts @@ -1,5 +1,164 @@ import { prisma } from '../../../db/prisma-client'; +import type { PatientExportStatus } from '@prisma/client'; export function findPatientProfileByUserId(userId: string) { return prisma.patientProfile.findUnique({ where: { userId } }); } + +// --- Patient data export (Phase 8, issue #40) ---------------------------- +// PatientExport / PatientExportChunk are owned by this module. Every +// status change below is a conditional UPDATE (`updateMany` with the +// expected current state in the WHERE clause) — never read-then-write — so +// concurrent workers, cleanup and requests can't step on each other. + +const IN_FLIGHT: PatientExportStatus[] = ['pending', 'processing']; + +/** + * Returns the patient's in-flight export if there is one, otherwise creates + * a new `pending` one. The profile row is locked (`FOR UPDATE`) for the + * duration, which serialises concurrent requests from the same patient, so + * a double-click can't queue two generations. + */ +export function findInFlightOrCreateExport(patientProfileId: string) { + return prisma.$transaction(async (tx) => { + await tx.$queryRaw`SELECT "id" FROM "PatientProfile" WHERE "id" = ${patientProfileId} FOR UPDATE`; + const existing = await tx.patientExport.findFirst({ + where: { patientProfileId, status: { in: IN_FLIGHT } }, + orderBy: { createdAt: 'desc' }, + }); + if (existing) return { record: existing, created: false }; + const record = await tx.patientExport.create({ data: { patientProfileId } }); + return { record, created: true }; + }); +} + +/** Owner-scoped lookup: an export id belonging to someone else is + * indistinguishable from one that doesn't exist. */ +export function findExportForPatient(exportId: string, patientProfileId: string) { + return prisma.patientExport.findFirst({ where: { id: exportId, patientProfileId } }); +} + +export function findPatientProfileOwner(patientProfileId: string) { + return prisma.patientProfile.findUnique({ where: { id: patientProfileId }, select: { id: true, userId: true } }); +} + +export function findOldestPendingExportIds(limit: number) { + return prisma.patientExport + .findMany({ where: { status: 'pending' }, orderBy: { createdAt: 'asc' }, take: limit, select: { id: true } }) + .then((rows) => rows.map((row) => row.id)); +} + +export interface ClaimedExport { + id: string; + attempt: number; + patientProfileId: string; +} + +/** pending -> processing, bumping the attempt (fencing token), as one + * statement: the attempt returned is exactly the one this caller claimed. + * Null if the job wasn't pending (someone else got there first). */ +export async function claimPendingExport( + exportId: string, + schemaVersion: number, + startedAt: Date, +): Promise { + const rows = await prisma.$queryRaw` + UPDATE "PatientExport" + SET "status" = 'processing', "attempt" = "attempt" + 1, "startedAt" = ${startedAt}, + "schemaVersion" = ${schemaVersion}, "updatedAt" = NOW() + WHERE "id" = ${exportId} AND "status" = 'pending' + RETURNING "id", "attempt", "patientProfileId" + `; + return rows[0] ?? null; +} + +export function createExportChunk(exportId: string, attempt: number, seq: number, data: string) { + return prisma.patientExportChunk.create({ data: { exportId, attempt, seq, data } }); +} + +export function findExportChunk(exportId: string, attempt: number, seq: number) { + return prisma.patientExportChunk.findUnique({ where: { exportId_attempt_seq: { exportId, attempt, seq } } }); +} + +export function deleteExportChunks(exportId: string, attempt?: number) { + return prisma.patientExportChunk + .deleteMany({ where: attempt === undefined ? { exportId } : { exportId, attempt } }) + .then((result) => result.count); +} + +/** processing -> ready, only for the attempt that is still current. */ +export async function markExportReady( + exportId: string, + attempt: number, + data: { generatedAt: Date; expiresAt: Date; sizeBytes: number; chunkCount: number }, +): Promise { + const result = await prisma.patientExport.updateMany({ + where: { id: exportId, status: 'processing', attempt }, + data: { status: 'ready', ...data, failureCode: null, failedSection: null }, + }); + return result.count === 1; +} + +/** processing -> failed, only for the attempt that is still current. */ +export async function markExportFailed( + exportId: string, + attempt: number, + failure: { failureCode: string; failedSection: string | null }, +): Promise { + const result = await prisma.patientExport.updateMany({ + where: { id: exportId, status: 'processing', attempt }, + data: { status: 'failed', ...failure }, + }); + return result.count === 1; +} + +export function findStaleProcessingExports(startedBefore: Date, limit: number) { + return prisma.patientExport.findMany({ + where: { status: 'processing', startedAt: { lt: startedBefore } }, + select: { id: true, attempt: true }, + take: limit, + }); +} + +/** processing -> pending for a claim presumed dead; conditional on the + * attempt so a worker that finished in the meantime isn't undone. */ +export async function requeueStaleExport(exportId: string, attempt: number): Promise { + const result = await prisma.patientExport.updateMany({ + where: { id: exportId, status: 'processing', attempt }, + data: { status: 'pending', startedAt: null }, + }); + return result.count === 1; +} + +export function findExpiredReadyExportIds(now: Date, limit: number) { + return prisma.patientExport + .findMany({ where: { status: 'ready', expiresAt: { lte: now } }, select: { id: true }, take: limit }) + .then((rows) => rows.map((row) => row.id)); +} + +/** ready -> expired and the artifact deleted, in one transaction. Only the + * row's own chunks are touched; the metadata row stays (no patient data in + * it) so a later status read answers "expired" rather than "not found". */ +export async function expireExport(exportId: string, now: Date): Promise { + return prisma.$transaction(async (tx) => { + const result = await tx.patientExport.updateMany({ + where: { id: exportId, status: 'ready', expiresAt: { lte: now } }, + data: { status: 'expired' }, + }); + if (result.count !== 1) return false; + await tx.patientExportChunk.deleteMany({ where: { exportId } }); + return true; + }); +} + +/** Chunks that can never be served: any attempt other than the export's + * current one (a superseded or crashed claim), and anything belonging to a + * failed or expired export. */ +export function deleteUnservableExportChunks(): Promise { + return prisma.$executeRaw` + DELETE FROM "PatientExportChunk" c + USING "PatientExport" e + WHERE c."exportId" = e."id" + AND (c."attempt" <> e."attempt" OR e."status" IN ('failed', 'expired')) + `; +} diff --git a/backend/src/modules/patients/routes/index.ts b/backend/src/modules/patients/routes/index.ts index af7994a3..b4eaa657 100644 --- a/backend/src/modules/patients/routes/index.ts +++ b/backend/src/modules/patients/routes/index.ts @@ -1,3 +1,17 @@ -// patients routes — placeholder. See PRD.md / ARCHITECTURE.md §3 for this module's scope. -// TODO(Phase: see ROADMAP.md): define routes for patients. -export {}; +import { Router } from 'express'; +import { authenticate, requireRole } from '../../../middleware/auth'; +import * as patientsController from '../controllers'; + +export const patientsRouter = Router(); + +// Patient data export (Phase 8, issue #40). Patient-only and self-only: +// doctors and admins get 403 here, and an export id that belongs to another +// patient is a 404. There is no admin-on-behalf-of route. +patientsRouter.post('/me/export', authenticate, requireRole('patient'), patientsController.requestExport); +patientsRouter.get('/me/export/:exportId', authenticate, requireRole('patient'), patientsController.getExport); +patientsRouter.get( + '/me/export/:exportId/download', + authenticate, + requireRole('patient'), + patientsController.downloadExport, +); diff --git a/backend/src/modules/patients/services/export-bundle.ts b/backend/src/modules/patients/services/export-bundle.ts new file mode 100644 index 00000000..0a937ea0 --- /dev/null +++ b/backend/src/modules/patients/services/export-bundle.ts @@ -0,0 +1,120 @@ +// Writes the patient export bundle (Phase 8, issue #40) as a stream of +// bounded text chunks. Pure apart from the injected sink, so the format and +// the memory bound are unit-tested without a database. +// +// Bundle layout (schemaVersion 1): +// { +// "format": "docforum.patient-export", +// "schemaVersion": 1, +// "exportId": "", +// "generatedAt": "", +// "sections": { +// "": { "version": , "items": [ ... ] }, +// ... +// } +// } +import type { ExportSubject, PatientExportSection } from './export-registry'; + +export const EXPORT_FORMAT = 'docforum.patient-export'; +export const EXPORT_SCHEMA_VERSION = 1; + +export interface ChunkSink { + writeChunk(seq: number, data: string): Promise; +} + +/** Buffers writes and hands the sink a chunk once the buffer reaches + * `maxChunkBytes`. A chunk can exceed the limit by at most one write (one + * serialized item), so memory stays bounded by limit + largest item. */ +export class ChunkWriter { + private buffer: string[] = []; + private bufferBytes = 0; + private nextSeq = 0; + private written = 0; + + constructor( + private readonly sink: ChunkSink, + private readonly maxChunkBytes: number, + ) { + if (!Number.isInteger(maxChunkBytes) || maxChunkBytes < 1) { + throw new Error('maxChunkBytes must be a positive integer.'); + } + } + + async write(text: string): Promise { + this.buffer.push(text); + this.bufferBytes += Buffer.byteLength(text, 'utf8'); + if (this.bufferBytes >= this.maxChunkBytes) await this.flush(); + } + + async flush(): Promise { + if (this.bufferBytes === 0) return; + const data = this.buffer.join(''); + const seq = this.nextSeq; + this.buffer = []; + this.bufferBytes = 0; + await this.sink.writeChunk(seq, data); + this.nextSeq += 1; + this.written += Buffer.byteLength(data, 'utf8'); + } + + get chunkCount(): number { + return this.nextSeq; + } + + get totalBytes(): number { + return this.written; + } +} + +/** Raised when one section fails. Carries only the section name — the + * underlying error stays server-side, since its message may echo data. */ +export class ExportSectionError extends Error { + constructor( + public readonly section: string, + public readonly cause: unknown, + ) { + super(`Export section "${section}" failed.`); + this.name = 'ExportSectionError'; + } +} + +export interface WriteBundleInput { + exportId: string; + generatedAt: Date; + subject: ExportSubject; + sections: PatientExportSection[]; + pageSize: number; + writer: ChunkWriter; +} + +/** Streams every section into the writer. Any section failure aborts the + * whole bundle — a partial export must never be published as complete. */ +export async function writeExportBundle(input: WriteBundleInput): Promise { + const { writer } = input; + await writer.write( + `{"format":${JSON.stringify(EXPORT_FORMAT)},"schemaVersion":${EXPORT_SCHEMA_VERSION},` + + `"exportId":${JSON.stringify(input.exportId)},"generatedAt":${JSON.stringify(input.generatedAt.toISOString())},` + + `"sections":{`, + ); + + for (const [index, section] of input.sections.entries()) { + await writer.write( + `${index === 0 ? '' : ','}${JSON.stringify(section.name)}:{"version":${section.version},"items":[`, + ); + let first = true; + try { + for await (const page of section.pages(input.subject, input.pageSize)) { + for (const item of page) { + await writer.write((first ? '' : ',') + JSON.stringify(item)); + first = false; + } + } + } catch (err) { + throw new ExportSectionError(section.name, err); + } + await writer.write(']}'); + } + + await writer.write('}}'); + await writer.flush(); +} diff --git a/backend/src/modules/patients/services/export-registry.ts b/backend/src/modules/patients/services/export-registry.ts new file mode 100644 index 00000000..f42856c7 --- /dev/null +++ b/backend/src/modules/patients/services/export-registry.ts @@ -0,0 +1,84 @@ +// Patient data export registry (Phase 8, issue #40 — right of access). +// +// The patients module coordinates an export, but it owns none of the data +// beyond the profile itself. Every other module contributes its own section +// through its own services/ interface: it decides what the patient is +// entitled to see, pages it out of its own tables, and maps it to plain +// JSON. The patients module never queries another module's tables to build +// an export (ARCHITECTURE_ESSENTIALS.md hard rule 4). +// +// Adding a section for a new module (referrals, prescriptions, ...): +// 1. export a `PatientExportSection` from that module's services/; +// 2. add it to the list in src/export-sections.ts. +// See ARCHITECTURE.md §3 "Patient data export registry". + +/** Who an export is about. `userId` is the login that owns the profile. */ +export interface ExportSubject { + patientProfileId: string; + userId: string; +} + +export interface PatientExportSection { + /** Stable key in the bundle's `sections` object. Lowercase, no spaces. */ + name: string; + /** Bumped by the owning module whenever its item shape changes. */ + version: number; + /** + * Yields the subject's records as pages of plain, JSON-safe objects (no + * BigInt, no Prisma rows spread wholesale — an explicit allowlist of + * fields, so a column added later can't leak into exports by default). + * Must page: a page holds at most `pageSize` items, and the next page is + * only fetched once the previous one has been written out. + */ + pages(subject: ExportSubject, pageSize: number): AsyncIterable; +} + +const SECTION_NAME = /^[a-z][a-z0-9_]*$/; +const sections = new Map(); + +/** Registering the same section object twice is a no-op (createApp may run + * more than once in a process); two different sections claiming one name + * is a wiring bug and fails loudly. */ +export function registerExportSection(section: PatientExportSection): void { + if (!SECTION_NAME.test(section.name)) { + throw new Error(`Invalid export section name: ${JSON.stringify(section.name)}`); + } + const existing = sections.get(section.name); + if (existing && existing !== section) { + throw new Error(`Export section "${section.name}" is already registered by another module.`); + } + sections.set(section.name, section); +} + +/** Test seam: remove a section registered by a test. */ +export function unregisterExportSection(name: string): void { + sections.delete(name); +} + +/** Sorted by name, so the bundle layout is deterministic. */ +export function listExportSections(): PatientExportSection[] { + return [...sections.values()].sort((a, b) => a.name.localeCompare(b.name)); +} + +/** + * Keyset pagination helper for sections backed by a table with a unique, + * monotonic-enough `id` cursor. `fetchPage` returns at most `take` rows + * after `cursor` (exclusive) in a stable order; `mapPage` turns one page of + * rows into export items (and is where a section batches any lookups it + * needs from other modules' services). + */ +export async function* paginate( + pageSize: number, + fetchPage: (cursor: string | undefined, take: number) => Promise, + mapPage: (rows: Row[]) => Promise | Item[], +): AsyncGenerator { + if (!Number.isInteger(pageSize) || pageSize < 1) throw new Error('pageSize must be a positive integer.'); + let cursor: string | undefined; + for (;;) { + const rows = await fetchPage(cursor, pageSize); + if (rows.length === 0) return; + yield await mapPage(rows); + if (rows.length < pageSize) return; + cursor = rows[rows.length - 1]!.id; + } +} diff --git a/backend/src/modules/patients/services/index.ts b/backend/src/modules/patients/services/index.ts index e3df270c..7adc0679 100644 --- a/backend/src/modules/patients/services/index.ts +++ b/backend/src/modules/patients/services/index.ts @@ -1,8 +1,281 @@ import { HttpError } from '../../../http-error'; +import { config } from '../../../config'; import * as patientsRepo from '../repositories'; +import { ChunkWriter, EXPORT_SCHEMA_VERSION, ExportSectionError, writeExportBundle } from './export-bundle'; +import { listExportSections, type PatientExportSection } from './export-registry'; +import type { PatientExport, PatientExportStatus } from '@prisma/client'; + +// The registry is part of this module's service surface: other modules +// import `PatientExportSection`/`paginate` from here to build their section, +// and src/export-sections.ts registers them. +export { + paginate, + registerExportSection, + unregisterExportSection, + listExportSections, +} from './export-registry'; +export type { ExportSubject, PatientExportSection } from './export-registry'; +export { EXPORT_FORMAT, EXPORT_SCHEMA_VERSION } from './export-bundle'; export async function getMyPatientProfile(userId: string) { const profile = await patientsRepo.findPatientProfileByUserId(userId); if (!profile) throw new HttpError(404, 'No patient profile for this account.'); return profile; } + +/** This module's own export section: the profile itself. A single item. */ +export const profileExportSection: PatientExportSection = { + name: 'profile', + version: 1, + async *pages(subject) { + const profile = await patientsRepo.findPatientProfileByUserId(subject.userId); + if (!profile || profile.id !== subject.patientProfileId) return; + yield [ + { + id: profile.id, + fullName: profile.fullName, + dateOfBirth: profile.dateOfBirth, + createdAt: profile.createdAt, + updatedAt: profile.updatedAt, + }, + ]; + }, +}; + +// --- Patient data export (Phase 8, issue #40) ---------------------------- + +export interface PatientExportView { + id: string; + status: PatientExportStatus; + schemaVersion: number | null; + createdAt: Date; + startedAt: Date | null; + generatedAt: Date | null; + expiresAt: Date | null; + sizeBytes: number | null; + failure: { code: string; section: string | null } | null; + downloadUrl: string | null; +} + +/** A `ready` export whose deadline has passed is reported (and treated) as + * expired immediately — the cleanup job only catches up on the storage. */ +function effectiveStatus(record: PatientExport, now: Date): PatientExportStatus { + if (record.status === 'ready' && (!record.expiresAt || record.expiresAt.getTime() <= now.getTime())) { + return 'expired'; + } + return record.status; +} + +function toView(record: PatientExport, now = new Date()): PatientExportView { + const status = effectiveStatus(record, now); + return { + id: record.id, + status, + schemaVersion: record.schemaVersion, + createdAt: record.createdAt, + startedAt: record.startedAt, + generatedAt: record.generatedAt, + expiresAt: record.expiresAt, + sizeBytes: status === 'ready' ? record.sizeBytes : null, + failure: record.failureCode ? { code: record.failureCode, section: record.failedSection } : null, + downloadUrl: status === 'ready' ? `/patients/me/export/${record.id}/download` : null, + }; +} + +type ExportRequestedListener = () => void; +let exportRequestedListener: ExportRequestedListener | null = null; + +/** src/jobs registers here so a new request starts generating at once + * instead of waiting for the next interval tick. Unset in tests, which + * drive the worker explicitly. */ +export function onExportRequested(listener: ExportRequestedListener | null): void { + exportRequestedListener = listener; +} + +/** + * Starts (or returns the already-running) export for the caller's own + * profile. The subject is always derived from the authenticated user — + * there is deliberately no way to name another patient here. + */ +export async function requestMyExport(userId: string): Promise<{ export: PatientExportView; created: boolean }> { + const profile = await getMyPatientProfile(userId); + const { record, created } = await patientsRepo.findInFlightOrCreateExport(profile.id); + if (created) exportRequestedListener?.(); + return { export: toView(record), created }; +} + +async function findMyExport(userId: string, exportId: string): Promise { + const profile = await getMyPatientProfile(userId); + const record = await patientsRepo.findExportForPatient(exportId, profile.id); + // 404, not 403, for someone else's id: don't confirm it exists. + if (!record) throw new HttpError(404, 'Export not found.'); + return record; +} + +export async function getMyExport(userId: string, exportId: string): Promise { + return toView(await findMyExport(userId, exportId)); +} + +export interface ExportDownload { + filename: string; + sizeBytes: number; + /** Yields the stored chunks in order, one database read per chunk. Throws + * if the artifact disappears mid-stream (cleanup raced the download). */ + chunks(): AsyncGenerator; +} + +export async function openMyExportDownload(userId: string, exportId: string): Promise { + const record = await findMyExport(userId, exportId); + const status = effectiveStatus(record, new Date()); + if (status === 'expired') { + throw new HttpError(410, 'This export has expired. Request a new one.', 'EXPORT_EXPIRED'); + } + if (status !== 'ready' || record.chunkCount === null || record.sizeBytes === null) { + throw new HttpError(409, `This export is '${status}', not ready to download.`, 'EXPORT_NOT_READY'); + } + const { attempt, chunkCount } = record; + return { + filename: `docforum-export-${record.id}.json`, + sizeBytes: record.sizeBytes, + async *chunks() { + for (let seq = 0; seq < chunkCount; seq += 1) { + const chunk = await patientsRepo.findExportChunk(record.id, attempt, seq); + if (!chunk) throw new Error('EXPORT_ARTIFACT_MISSING'); + yield chunk.data; + } + }, + }; +} + +function exportTtlMs(): number { + return config.patientExport.ttlHours * 60 * 60 * 1000; +} + +/** + * Generates one claimed export. Sections run strictly one after another and + * page through their data, and the bundle is flushed to storage chunk by + * chunk, so memory is bounded by pageSize rows + one chunk regardless of how + * long the patient's history is. + */ +async function generateClaimedExport( + record: patientsRepo.ClaimedExport, + sections: PatientExportSection[], +): Promise { + const { id: exportId, attempt } = record; + const writer = new ChunkWriter( + { writeChunk: (seq, data) => patientsRepo.createExportChunk(exportId, attempt, seq, data).then(() => undefined) }, + config.patientExport.chunkBytes, + ); + const generatedAt = new Date(); + + 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')); + await writeExportBundle({ + exportId, + generatedAt, + subject: { patientProfileId: owner.id, userId: owner.userId }, + sections, + pageSize: config.patientExport.pageSize, + writer, + }); + } catch (err) { + const section = err instanceof ExportSectionError ? err.section : null; + // Log the error *type* only — a driver or section error message can + // echo the data it was handling. + const cause = err instanceof ExportSectionError ? err.cause : err; + // eslint-disable-next-line no-console + console.error( + `[jobs] patient export ${exportId} attempt ${attempt} failed${section ? ` in section "${section}"` : ''}: ${ + cause instanceof Error ? cause.name : typeof cause + }`, + ); + await patientsRepo.deleteExportChunks(exportId, attempt); + await patientsRepo.markExportFailed(exportId, attempt, { + failureCode: section ? 'SECTION_FAILED' : 'GENERATION_FAILED', + failedSection: section, + }); + return 'failed'; + } + + const published = await patientsRepo.markExportReady(exportId, attempt, { + generatedAt, + expiresAt: new Date(generatedAt.getTime() + exportTtlMs()), + sizeBytes: writer.totalBytes, + chunkCount: writer.chunkCount, + }); + if (!published) { + // Our claim was superseded (presumed dead and requeued). The newer + // attempt owns the export; drop what we wrote. + await patientsRepo.deleteExportChunks(exportId, attempt); + return 'processing'; + } + return 'ready'; +} + +/** Claims and generates one export by id. Returns the resulting status, or + * null if the job wasn't `pending` (someone else claimed it). Exposed for + * tests; production goes through `runExportWorker`. */ +export async function processExport( + exportId: string, + sections: PatientExportSection[] = listExportSections(), +): Promise { + const claimed = await patientsRepo.claimPendingExport(exportId, EXPORT_SCHEMA_VERSION, new Date()); + if (!claimed) return null; + return generateClaimedExport(claimed, sections); +} + +/** + * Requeues `processing` jobs whose claim is older than the stale timeout + * (the worker died mid-generation), or fails them once they've used up + * their attempts. Conditional on the attempt number, so a slow-but-alive + * worker that finishes first wins and is not undone. + */ +export async function recoverStaleExports(now = new Date()): Promise<{ requeued: number; failed: number }> { + const staleBefore = new Date(now.getTime() - config.patientExport.staleAfterSeconds * 1000); + const stale = await patientsRepo.findStaleProcessingExports(staleBefore, 100); + let requeued = 0; + let failed = 0; + for (const job of stale) { + if (job.attempt >= config.patientExport.maxAttempts) { + if (await patientsRepo.markExportFailed(job.id, job.attempt, { failureCode: 'GENERATION_TIMEOUT', failedSection: null })) { + failed += 1; + } + } else if (await patientsRepo.requeueStaleExport(job.id, job.attempt)) { + requeued += 1; + } + } + return { requeued, failed }; +} + +/** One worker pass: recover dead claims, then generate pending exports + * oldest-first, one at a time (never in parallel — generation is the + * expensive part, and a burst of requests must not multiply memory use). */ +export async function runExportWorker(maxJobs = 10): Promise<{ processed: number }> { + await recoverStaleExports(); + let processed = 0; + for (const exportId of await patientsRepo.findOldestPendingExportIds(maxJobs)) { + if ((await processExport(exportId)) !== null) processed += 1; + } + return { processed }; +} + +/** Cleanup pass: expire `ready` exports past their deadline (deleting their + * artifact), then sweep chunks that can never be served. Only this module's + * export rows are touched. */ +export async function expireExports(now = new Date()): Promise<{ expired: number; orphanChunksDeleted: number }> { + let expired = 0; + for (;;) { + const ids = await patientsRepo.findExpiredReadyExportIds(now, 100); + if (ids.length === 0) break; + let progressed = 0; + for (const id of ids) { + if (await patientsRepo.expireExport(id, now)) progressed += 1; + } + expired += progressed; + if (ids.length < 100 || progressed === 0) break; + } + const orphanChunksDeleted = await patientsRepo.deleteUnservableExportChunks(); + return { expired, orphanChunksDeleted }; +} diff --git a/backend/src/modules/payments/repositories/index.ts b/backend/src/modules/payments/repositories/index.ts index 7958616d..5582f1ce 100644 --- a/backend/src/modules/payments/repositories/index.ts +++ b/backend/src/modules/payments/repositories/index.ts @@ -36,6 +36,23 @@ export function findPaymentIntentById(id: string) { return prisma.paymentIntent.findUnique({ where: { id } }); } +/** One keyset page of a patient's payment intents, oldest first (patient + * data export, issue #40). */ +export function findPaymentIntentsPageForPatient(patientProfileId: string, cursor: string | undefined, take: number) { + return prisma.paymentIntent.findMany({ + where: { patientProfileId }, + orderBy: [{ createdAt: 'asc' }, { id: 'asc' }], + take, + ...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}), + }); +} + +/** Same FacilityProfile read this module already does for its own + * payee lookups (see findFacilityProfileByUserId). */ +export function findFacilityNamesByIds(ids: string[]) { + return prisma.facilityProfile.findMany({ where: { id: { in: ids } }, select: { id: true, name: true } }); +} + export function markEscrowed(id: string, input: { escrowId: bigint; stellarTxHash: string; sorobanContractId: string }) { return prisma.paymentIntent.update({ where: { id }, diff --git a/backend/src/modules/payments/services/index.ts b/backend/src/modules/payments/services/index.ts index c36cc05e..daf9ff0c 100644 --- a/backend/src/modules/payments/services/index.ts +++ b/backend/src/modules/payments/services/index.ts @@ -13,6 +13,7 @@ import { HttpError } from '../../../http-error'; import { config } from '../../../config'; import * as paymentsRepo from '../repositories'; import { loadEscrowSdk } from './escrow-sdk-loader'; +import { paginate, type PatientExportSection } from '../../patients/services'; async function requirePaymentsConfig() { const { platformPayerSecret, platformReleaserSecret, escrowContractId, escrowTokenId } = config.payments; @@ -126,3 +127,38 @@ export async function refundPaymentIntent(id: string) { return paymentsRepo.markStatus(id, 'refunded'); } + +/** + * This module's contribution to the patient data export (issue #40). An + * explicit field allowlist: on-chain references (tx hash, escrow id, + * contract id) are the patient's payment record and are included; the + * platform's signing secrets never touch a PaymentIntent row and can't + * appear here. BigInts are strings, matching the HTTP API. + */ +export const paymentsExportSection: PatientExportSection = { + name: 'payments', + version: 1, + pages(subject, pageSize) { + return paginate( + pageSize, + (cursor, take) => paymentsRepo.findPaymentIntentsPageForPatient(subject.patientProfileId, cursor, take), + async (rows) => { + const facilities = await paymentsRepo.findFacilityNamesByIds([...new Set(rows.map((row) => row.facilityId))]); + const facilityNames = new Map(facilities.map((facility) => [facility.id, facility.name])); + return rows.map((row) => ({ + id: row.id, + orderType: row.orderType, + orderId: row.orderId, + status: row.status, + amountStroops: row.amountStroops.toString(), + facility: { id: row.facilityId, name: facilityNames.get(row.facilityId) ?? null }, + escrowId: row.escrowId?.toString() ?? null, + stellarTxHash: row.stellarTxHash, + sorobanContractId: row.sorobanContractId, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + })); + }, + ); + }, +}; diff --git a/backend/src/server.ts b/backend/src/server.ts index 52cd7e2c..7030fd12 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -1,6 +1,7 @@ import { createApp } from './app'; import { config } from './config'; import { pruneLoginAttempts } from './modules/auth/services'; +import { startPatientExportJobs } from './jobs/patient-exports'; const app = createApp(); @@ -28,3 +29,6 @@ const pruneTimer = setInterval(() => { }); }, config.loginThrottle.pruneIntervalSeconds * 1000); pruneTimer.unref(); + +// Patient data export worker + expiry cleanup (Phase 8, issue #40). +startPatientExportJobs(); diff --git a/backend/tests/integration/patient-export.test.mts b/backend/tests/integration/patient-export.test.mts new file mode 100644 index 00000000..9874c430 --- /dev/null +++ b/backend/tests/integration/patient-export.test.mts @@ -0,0 +1,492 @@ +// Integration tests for patient data export (Phase 8, issue #40 — right of +// access): owner-only access, the job lifecycle, the bundle contents +// (sections from each module's service, tx hashes, no secrets), bounded +// streaming, failure handling, stale-claim recovery, and expiry/cleanup. +// Self-contained, same embedded-Postgres pattern as booking.test.mts — see +// that file's header for why. Own port (5438) so it runs alongside the +// other integration files. +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 jwt from 'jsonwebtoken'; +import EmbeddedPostgres from 'embedded-postgres'; + +const BACKEND_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const PORT = 5438; +const runId = Date.now(); + +const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'docforum-export-pg-')); +const pg = new EmbeddedPostgres({ + databaseDir: dataDir, + user: 'docforum', + password: 'docforum', + port: PORT, + persistent: false, +}); +const databaseUrl = `postgresql://docforum:docforum@localhost:${PORT}/docforum_test`; + +// Sentinel platform secrets: the bundle must never contain them. +const PAYER_SECRET = `SPAYERSENTINEL${runId}`; +const RELEASER_SECRET = `SRELEASERSENTINEL${runId}`; +// Small pages and chunks so a modest fixture exercises multi-page, +// multi-chunk streaming. +const PAGE_SIZE = 7; +const CHUNK_BYTES = 1024; + +type App = ReturnType; +type Prisma = typeof import('../../src/db/prisma-client.js').prisma; +type PatientsService = typeof import('../../src/modules/patients/services/index.js'); + +const emailedTokens = new Map(); + +describe('patient data export', () => { + let app: App; + let prisma: Prisma; + let patients: PatientsService; + + let doctorToken: string; + let doctorProfileId: string; + let adminToken: string; + let alice: { token: string; userId: string; profileId: string; email: string }; + let bob: { token: string; userId: string; profileId: string; email: string }; + let facilityId: string; + + async function signupVerified(role: 'patient' | 'doctor', label: string) { + 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); + const verified = await request(app).post('/auth/verify-email').send({ token: emailedTokens.get(email) }); + expect(verified.status).toBe(200); + return { token: res.body.accessToken as string, userId: res.body.user.id as string, email }; + } + + async function signupPatient(label: string) { + const user = await signupVerified('patient', label); + const profile = await prisma.patientProfile.findUniqueOrThrow({ where: { userId: user.userId } }); + return { ...user, profileId: profile.id }; + } + + async function download(token: string, exportId: string) { + return request(app) + .get(`/patients/me/export/${exportId}/download`) + .set('Authorization', `Bearer ${token}`) + .buffer(true) + .parse((res, callback) => { + let data = ''; + res.setEncoding('utf8'); + res.on('data', (chunk: string) => (data += chunk)); + res.on('end', () => callback(null, data)); + }); + } + + async function requestAndGenerate(token: string) { + const created = await request(app).post('/patients/me/export').set('Authorization', `Bearer ${token}`); + expect(created.status).toBe(202); + expect(await patients.processExport(created.body.id)).toBe('ready'); + return created.body.id as string; + } + + 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', + }); + + // Must be set before the app module (config, PrismaClient) loads. + process.env.DATABASE_URL = databaseUrl; + process.env.PATIENT_EXPORT_PAGE_SIZE = String(PAGE_SIZE); + process.env.PATIENT_EXPORT_CHUNK_BYTES = String(CHUNK_BYTES); + process.env.PATIENT_EXPORT_TTL_HOURS = '24'; + process.env.PLATFORM_PAYER_SECRET = PAYER_SECRET; + process.env.PLATFORM_RELEASER_SECRET = RELEASER_SECRET; + + 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'); + patients = await import('../../src/modules/patients/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 signupVerified('doctor', 'export-doctor'); + doctorToken = doctor.token; + doctorProfileId = (await prisma.doctorProfile.findUniqueOrThrow({ where: { userId: doctor.userId } })).id; + + const admin = await prisma.user.create({ + data: { email: `admin-${runId}@example.com`, passwordHash: 'x', role: 'admin', emailVerifiedAt: new Date() }, + }); + adminToken = jwt.sign({ sub: admin.id, role: 'admin' }, 'test-access-secret', { expiresIn: '15m' }); + + alice = await signupPatient('alice'); + bob = await signupPatient('bob'); + + // Alice books one appointment through the real booking endpoint... + const tomorrow = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString().slice(0, 10); + const generate = await request(app) + .post(`/availability/doctors/${doctorProfileId}/slots/generate`) + .set('Authorization', `Bearer ${doctorToken}`) + .send({ date: tomorrow, startHour: 9, endHour: 10, slotLengthMinutes: 30 }); + expect(generate.status).toBe(201); + const slots = await request(app).get(`/availability/doctors/${doctorProfileId}/slots`); + const booked = await request(app) + .post('/appointments') + .set('Authorization', `Bearer ${alice.token}`) + .send({ availabilitySlotId: slots.body[0].id }); + expect(booked.status).toBe(201); + + // ...plus enough history to span several pages and chunks. + const base = Date.now() + 2 * 24 * 60 * 60 * 1000; + for (let i = 0; i < 20; i += 1) { + const slot = await prisma.availabilitySlot.create({ + data: { + doctorProfileId, + status: 'booked', + startTime: new Date(base + i * 30 * 60 * 1000), + endTime: new Date(base + (i + 1) * 30 * 60 * 1000), + }, + }); + await prisma.appointment.create({ + data: { patientProfileId: alice.profileId, doctorProfileId, availabilitySlotId: slot.id }, + }); + } + + const facilityUser = await prisma.user.create({ + data: { email: `lab-${runId}@example.com`, passwordHash: 'x', role: 'facility' }, + }); + facilityId = ( + await prisma.facilityProfile.create({ data: { userId: facilityUser.id, name: 'Lagos Lab', type: 'lab' } }) + ).id; + await prisma.paymentIntent.create({ + data: { + orderType: 'lab_order', + orderId: 'order-1', + patientProfileId: alice.profileId, + facilityId, + amountStroops: 12_345_678_901n, + status: 'escrowed', + escrowId: 42n, + stellarTxHash: 'a'.repeat(64), + sorobanContractId: 'CCONTRACT', + }, + }); + await prisma.paymentIntent.create({ + data: { orderType: 'prescription', orderId: 'order-2', patientProfileId: alice.profileId, facilityId, amountStroops: 5n }, + }); + // Bob's payment must never show up in Alice's export. + await prisma.paymentIntent.create({ + data: { + orderType: 'prescription', + orderId: 'bob-order', + patientProfileId: bob.profileId, + facilityId, + amountStroops: 9n, + stellarTxHash: 'b'.repeat(64), + }, + }); + }, 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 request an export, deduplicating while one is in flight', async () => { + const [first, second] = await Promise.all([ + request(app).post('/patients/me/export').set('Authorization', `Bearer ${bob.token}`), + request(app).post('/patients/me/export').set('Authorization', `Bearer ${bob.token}`), + ]); + expect(first.status).toBe(202); + expect(second.status).toBe(202); + expect(first.body.id).toBe(second.body.id); + expect(first.body.status).toBe('pending'); + expect(first.body.downloadUrl).toBeNull(); + expect(first.headers.location).toBe(`/patients/me/export/${first.body.id}`); + expect(await prisma.patientExport.count({ where: { patientProfileId: bob.profileId } })).toBe(1); + + const status = await request(app) + .get(`/patients/me/export/${first.body.id}`) + .set('Authorization', `Bearer ${bob.token}`); + expect(status.status).toBe(200); + expect(status.body.status).toBe('pending'); + + // Download before it's ready is a 409, not a partial file. + const early = await download(bob.token, first.body.id); + expect(early.status).toBe(409); + expect(early.body).toContain('EXPORT_NOT_READY'); + + expect(await patients.processExport(first.body.id)).toBe('ready'); + // Once it's no longer in flight, a new request starts a new job. + const next = await request(app).post('/patients/me/export').set('Authorization', `Bearer ${bob.token}`); + expect(next.body.id).not.toBe(first.body.id); + await patients.processExport(next.body.id); + }); + + it('refuses doctors, admins and anonymous callers on every export route', async () => { + const exportId = (await prisma.patientExport.findFirstOrThrow({ where: { patientProfileId: bob.profileId } })).id; + for (const token of [doctorToken, adminToken]) { + const auth = { Authorization: `Bearer ${token}` }; + expect((await request(app).post('/patients/me/export').set(auth)).status).toBe(403); + expect((await request(app).get(`/patients/me/export/${exportId}`).set(auth)).status).toBe(403); + expect((await request(app).get(`/patients/me/export/${exportId}/download`).set(auth)).status).toBe(403); + } + expect((await request(app).post('/patients/me/export')).status).toBe(401); + expect((await request(app).get(`/patients/me/export/${exportId}`)).status).toBe(401); + // And no patient-id parameter is honoured anywhere: a body naming Bob + // still exports the caller (or nobody, for non-patients). + const sneaky = await request(app) + .post('/patients/me/export') + .set('Authorization', `Bearer ${alice.token}`) + .send({ patientProfileId: bob.profileId }); + const row = await prisma.patientExport.findUniqueOrThrow({ where: { id: sneaky.body.id } }); + expect(row.patientProfileId).toBe(alice.profileId); + await patients.processExport(row.id); + }); + + it("returns 404 for another patient's export id, on status and download", async () => { + const bobsExport = await prisma.patientExport.findFirstOrThrow({ + where: { patientProfileId: bob.profileId, status: 'ready' }, + }); + const status = await request(app) + .get(`/patients/me/export/${bobsExport.id}`) + .set('Authorization', `Bearer ${alice.token}`); + expect(status.status).toBe(404); + expect((await download(alice.token, bobsExport.id)).status).toBe(404); + // ...while Bob can download it. + expect((await download(bob.token, bobsExport.id)).status).toBe(200); + }); + + it('builds a complete, versioned bundle from each module and leaks no secrets', async () => { + const exportId = await requestAndGenerate(alice.token); + const status = await request(app).get(`/patients/me/export/${exportId}`).set('Authorization', `Bearer ${alice.token}`); + expect(status.body).toMatchObject({ status: 'ready', schemaVersion: 1, failure: null }); + expect(status.body.downloadUrl).toBe(`/patients/me/export/${exportId}/download`); + expect(new Date(status.body.expiresAt).getTime()).toBeGreaterThan(Date.now() + 23 * 60 * 60 * 1000); + + const res = await download(alice.token, exportId); + expect(res.status).toBe(200); + expect(res.headers['content-type']).toMatch(/application\/json/); + expect(res.headers['content-disposition']).toContain(`docforum-export-${exportId}.json`); + expect(res.headers['cache-control']).toBe('no-store'); + expect(Number(res.headers['content-length'])).toBe(Buffer.byteLength(res.body)); + + const bundle = JSON.parse(res.body); + expect(bundle.format).toBe('docforum.patient-export'); + expect(bundle.schemaVersion).toBe(1); + expect(bundle.exportId).toBe(exportId); + expect(new Date(bundle.generatedAt).toISOString()).toBe(bundle.generatedAt); + expect(Object.keys(bundle.sections)).toEqual(['account', 'appointments', 'payments', 'profile']); + + expect(bundle.sections.account.items).toEqual([ + expect.objectContaining({ id: alice.userId, email: alice.email, role: 'patient' }), + ]); + expect(bundle.sections.profile.items).toEqual([ + expect.objectContaining({ id: alice.profileId, fullName: 'alice Fullname' }), + ]); + + const appointments = bundle.sections.appointments.items; + expect(appointments).toHaveLength(21); + expect(new Set(appointments.map((a: { id: string }) => a.id)).size).toBe(21); + for (const appointment of appointments) { + expect(appointment.doctor).toEqual({ id: doctorProfileId, fullName: 'export-doctor Fullname' }); + expect(appointment.slot.startTime).toBeTruthy(); + expect(appointment.status).toBe('scheduled'); + } + + const payments = bundle.sections.payments.items; + expect(payments).toHaveLength(2); + expect(payments[0]).toMatchObject({ + orderType: 'lab_order', + status: 'escrowed', + amountStroops: '12345678901', + escrowId: '42', + stellarTxHash: 'a'.repeat(64), + sorobanContractId: 'CCONTRACT', + facility: { id: facilityId, name: 'Lagos Lab' }, + }); + expect(payments[1]).toMatchObject({ orderType: 'prescription', stellarTxHash: null, escrowId: null }); + + // Nothing that isn't Alice's, and nothing operational or secret. + const passwordHash = (await prisma.user.findUniqueOrThrow({ where: { id: alice.userId } })).passwordHash; + expect(res.body).not.toContain('b'.repeat(64)); + expect(res.body).not.toContain('bob-order'); + expect(res.body).not.toContain(PAYER_SECRET); + expect(res.body).not.toContain(RELEASER_SECRET); + expect(res.body).not.toContain(passwordHash); + expect(res.body).not.toMatch(/passwordHash|tokenHash|csrf|LoginAttempt/i); + }); + + it('streams large histories in bounded chunks', async () => { + const exportId = await requestAndGenerate(alice.token); + const record = await prisma.patientExport.findUniqueOrThrow({ where: { id: exportId } }); + const chunks = await prisma.patientExportChunk.findMany({ where: { exportId }, orderBy: { seq: 'asc' } }); + expect(record.chunkCount).toBe(chunks.length); + expect(chunks.length).toBeGreaterThan(3); + // Every chunk is at most the limit plus one serialized appointment. + for (const chunk of chunks) expect(Buffer.byteLength(chunk.data)).toBeLessThan(CHUNK_BYTES + 600); + expect(chunks.reduce((sum, c) => sum + Buffer.byteLength(c.data), 0)).toBe(record.sizeBytes); + + // Sections are paged: storage already holds output by the time a later + // page is requested. + const seenAtPage: number[] = []; + const probe = { + name: 'zz_probe', + version: 1, + async *pages() { + for (let page = 0; page < 4; page += 1) { + seenAtPage.push(await prisma.patientExportChunk.count({ where: { exportId: probeExportId } })); + yield Array.from({ length: PAGE_SIZE }, (_, i) => ({ page, i, pad: 'p'.repeat(100) })); + } + }, + }; + const probeRequest = await request(app).post('/patients/me/export').set('Authorization', `Bearer ${alice.token}`); + const probeExportId = probeRequest.body.id as string; + expect(await patients.processExport(probeExportId, [...patients.listExportSections(), probe])).toBe('ready'); + expect(seenAtPage[0]).toBeGreaterThan(0); + expect(seenAtPage[3]).toBeGreaterThan(seenAtPage[0]); + }); + + it('runs each job exactly once even when two workers race for it', async () => { + const created = await request(app).post('/patients/me/export').set('Authorization', `Bearer ${alice.token}`); + const results = await Promise.all([patients.processExport(created.body.id), patients.processExport(created.body.id)]); + expect(results.filter((r) => r === 'ready')).toHaveLength(1); + expect(results.filter((r) => r === null)).toHaveLength(1); + const record = await prisma.patientExport.findUniqueOrThrow({ where: { id: created.body.id } }); + expect(record.attempt).toBe(1); + }); + + it('marks the job failed, keeps no partial artifact and stores no error text when a section fails', async () => { + const failing = { + name: 'zz_failing', + version: 1, + async *pages() { + yield [{ partial: true }]; + throw new Error('could not read record of alice Fullname'); + }, + }; + patients.registerExportSection(failing); + try { + const created = await request(app).post('/patients/me/export').set('Authorization', `Bearer ${alice.token}`); + expect(await patients.processExport(created.body.id)).toBe('failed'); + + const record = await prisma.patientExport.findUniqueOrThrow({ where: { id: created.body.id } }); + expect(record).toMatchObject({ status: 'failed', failureCode: 'SECTION_FAILED', failedSection: 'zz_failing' }); + expect(record.expiresAt).toBeNull(); + expect(JSON.stringify(record)).not.toContain('alice Fullname'); + expect(await prisma.patientExportChunk.count({ where: { exportId: created.body.id } })).toBe(0); + + const status = await request(app) + .get(`/patients/me/export/${created.body.id}`) + .set('Authorization', `Bearer ${alice.token}`); + expect(status.body).toMatchObject({ + status: 'failed', + downloadUrl: null, + failure: { code: 'SECTION_FAILED', section: 'zz_failing' }, + }); + expect((await download(alice.token, created.body.id)).status).toBe(409); + } finally { + patients.unregisterExportSection('zz_failing'); + } + }); + + it('requeues a dead claim, fences the stale attempt, and fails after max attempts', async () => { + const longAgo = new Date(Date.now() - 60 * 60 * 1000); + const stale = await prisma.patientExport.create({ + data: { patientProfileId: bob.profileId, status: 'processing', attempt: 1, startedAt: longAgo }, + }); + await prisma.patientExportChunk.create({ data: { exportId: stale.id, attempt: 1, seq: 0, data: '{"half":' } }); + const exhausted = await prisma.patientExport.create({ + data: { patientProfileId: bob.profileId, status: 'processing', attempt: 3, startedAt: longAgo }, + }); + + const recovered = await patients.recoverStaleExports(); + expect(recovered).toEqual({ requeued: 1, failed: 1 }); + expect((await prisma.patientExport.findUniqueOrThrow({ where: { id: exhausted.id } })).failureCode).toBe( + 'GENERATION_TIMEOUT', + ); + + expect(await patients.processExport(stale.id)).toBe('ready'); + const record = await prisma.patientExport.findUniqueOrThrow({ where: { id: stale.id } }); + expect(record.attempt).toBe(2); + // The dead attempt's half-written chunk is never served, and cleanup + // removes it. + const res = await download(bob.token, stale.id); + expect(() => JSON.parse(res.body)).not.toThrow(); + await patients.expireExports(); + expect(await prisma.patientExportChunk.count({ where: { exportId: stale.id, attempt: 1 } })).toBe(0); + expect(await prisma.patientExportChunk.count({ where: { exportId: stale.id, attempt: 2 } })).toBe(record.chunkCount); + }); + + it('refuses downloads at expiry, then cleanup deletes only expired artifacts', async () => { + const aliceExport = await requestAndGenerate(alice.token); + const bobExport = await requestAndGenerate(bob.token); + const bobChunks = await prisma.patientExportChunk.count({ where: { exportId: bobExport } }); + expect(bobChunks).toBeGreaterThan(0); + + // Past its deadline but not yet cleaned up: already refused. + await prisma.patientExport.update({ where: { id: aliceExport }, data: { expiresAt: new Date(Date.now() - 1000) } }); + const refused = await download(alice.token, aliceExport); + expect(refused.status).toBe(410); + expect(refused.body).toContain('EXPORT_EXPIRED'); + const status = await request(app).get(`/patients/me/export/${aliceExport}`).set('Authorization', `Bearer ${alice.token}`); + expect(status.body).toMatchObject({ status: 'expired', downloadUrl: null, sizeBytes: null }); + + const result = await patients.expireExports(); + expect(result.expired).toBeGreaterThanOrEqual(1); + expect((await prisma.patientExport.findUniqueOrThrow({ where: { id: aliceExport } })).status).toBe('expired'); + expect(await prisma.patientExportChunk.count({ where: { exportId: aliceExport } })).toBe(0); + // Bob's still-valid export is untouched. + expect(await prisma.patientExportChunk.count({ where: { exportId: bobExport } })).toBe(bobChunks); + expect((await download(bob.token, bobExport)).status).toBe(200); + + // Simulating the clock past every TTL expires everything still ready. + const later = new Date(Date.now() + 25 * 60 * 60 * 1000); + await patients.expireExports(later); + expect(await prisma.patientExport.count({ where: { status: 'ready' } })).toBe(0); + expect(await prisma.patientExportChunk.count()).toBe(0); + expect((await download(bob.token, bobExport)).status).toBe(410); + // Running it again is a no-op. + expect(await patients.expireExports(later)).toEqual({ expired: 0, orphanChunksDeleted: 0 }); + }); + + it('generates through the real job wiring: a request wakes the worker without a manual call', async () => { + const jobs = await import('../../src/jobs/patient-exports.js'); + const stop = jobs.startPatientExportJobs(); + try { + const created = await request(app).post('/patients/me/export').set('Authorization', `Bearer ${bob.token}`); + expect(created.status).toBe(202); + let status = created.body.status as string; + for (let i = 0; i < 50 && status !== 'ready'; i += 1) { + await new Promise((resolve) => setTimeout(resolve, 100)); + const res = await request(app).get(`/patients/me/export/${created.body.id}`).set('Authorization', `Bearer ${bob.token}`); + status = res.body.status; + } + expect(status).toBe('ready'); + } finally { + stop(); + } + }); + + it('leaves booking intact: booked appointments and slots are unchanged by exporting', async () => { + const before = await prisma.appointment.count({ where: { patientProfileId: alice.profileId } }); + await requestAndGenerate(alice.token); + expect(await prisma.appointment.count({ where: { patientProfileId: alice.profileId } })).toBe(before); + expect(await prisma.availabilitySlot.count({ where: { doctorProfileId, status: 'open' } })).toBe(1); + }); +}); diff --git a/backend/tests/unit/patient-export.test.ts b/backend/tests/unit/patient-export.test.ts new file mode 100644 index 00000000..ee85fc09 --- /dev/null +++ b/backend/tests/unit/patient-export.test.ts @@ -0,0 +1,213 @@ +// Patient data export (Phase 8, issue #40): the pure parts — bundle +// format, the chunking memory bound, section-failure handling, the +// registry's wiring rules, and keyset pagination. Database-backed behaviour +// (ownership, lifecycle, expiry) is in tests/integration/patient-export.test.mts. +import { describe, expect, it } from 'vitest'; +import { + ChunkWriter, + EXPORT_FORMAT, + EXPORT_SCHEMA_VERSION, + ExportSectionError, + writeExportBundle, +} from '../../src/modules/patients/services/export-bundle'; +import { + listExportSections, + paginate, + registerExportSection, + unregisterExportSection, + type PatientExportSection, +} from '../../src/modules/patients/services/export-registry'; + +function memorySink() { + const chunks: string[] = []; + return { + chunks, + sink: { + async writeChunk(seq: number, data: string) { + expect(seq).toBe(chunks.length); + chunks.push(data); + }, + }, + }; +} + +function section(name: string, pages: unknown[][]): PatientExportSection { + return { + name, + version: 1, + async *pages() { + for (const page of pages) yield page; + }, + }; +} + +const subject = { patientProfileId: 'profile-1', userId: 'user-1' }; +const generatedAt = new Date('2026-10-17T10:00:00.000Z'); + +describe('writeExportBundle', () => { + it('writes a versioned, timestamped JSON document with every section in order', async () => { + const { chunks, sink } = memorySink(); + const writer = new ChunkWriter(sink, 64 * 1024); + await writeExportBundle({ + exportId: 'export-1', + generatedAt, + subject, + sections: [section('alpha', [[{ a: 1 }, { a: 2 }], [{ a: 3 }]]), section('beta', [])], + pageSize: 2, + writer, + }); + + const bundle = JSON.parse(chunks.join('')); + expect(bundle).toEqual({ + format: EXPORT_FORMAT, + schemaVersion: EXPORT_SCHEMA_VERSION, + exportId: 'export-1', + generatedAt: '2026-10-17T10:00:00.000Z', + sections: { + alpha: { version: 1, items: [{ a: 1 }, { a: 2 }, { a: 3 }] }, + beta: { version: 1, items: [] }, + }, + }); + expect(writer.totalBytes).toBe(Buffer.byteLength(chunks.join(''))); + expect(writer.chunkCount).toBe(chunks.length); + }); + + it('flushes bounded chunks while paging, before later pages are fetched', async () => { + const { chunks, sink } = memorySink(); + const writer = new ChunkWriter(sink, 200); + const chunksSeenWhenPageRequested: number[] = []; + const big: PatientExportSection = { + name: 'big', + version: 1, + async *pages() { + for (let page = 0; page < 10; page += 1) { + chunksSeenWhenPageRequested.push(chunks.length); + yield Array.from({ length: 5 }, (_, i) => ({ page, i, filler: 'x'.repeat(20) })); + } + }, + }; + await writeExportBundle({ exportId: 'e', generatedAt, subject, sections: [big], pageSize: 5, writer }); + + expect(chunks.length).toBeGreaterThan(5); + // Earlier output had already been handed to storage by the time later + // pages were requested — the generator never holds the whole history. + expect(chunksSeenWhenPageRequested.at(-1)).toBeGreaterThan(0); + // Each chunk is at most the limit plus one serialized item. + const maxItem = Buffer.byteLength(JSON.stringify({ page: 9, i: 4, filler: 'x'.repeat(20) })) + 1; + for (const chunk of chunks) expect(Buffer.byteLength(chunk)).toBeLessThan(200 + maxItem); + expect(JSON.parse(chunks.join('')).sections.big.items).toHaveLength(50); + }); + + it('aborts on a failing section with only the section name, never the message', async () => { + const { sink } = memorySink(); + const failing: PatientExportSection = { + name: 'broken', + version: 1, + async *pages() { + yield [{ ok: true }]; + throw new Error('row for Jane Doe, DOB 1990-01-01 could not be read'); + }, + }; + const promise = writeExportBundle({ + exportId: 'e', + generatedAt, + subject, + sections: [section('fine', [[{ x: 1 }]]), failing], + pageSize: 10, + writer: new ChunkWriter(sink, 1024), + }); + await expect(promise).rejects.toBeInstanceOf(ExportSectionError); + await expect(promise).rejects.toMatchObject({ section: 'broken', message: 'Export section "broken" failed.' }); + }); + + it('handles multi-byte characters in the byte accounting', async () => { + const { chunks, sink } = memorySink(); + const writer = new ChunkWriter(sink, 16); + await writeExportBundle({ + exportId: 'e', + generatedAt, + subject, + sections: [section('names', [[{ name: 'Adébáyọ̀ Ọlọ́run' }]])], + pageSize: 1, + writer, + }); + expect(JSON.parse(chunks.join('')).sections.names.items[0].name).toBe('Adébáyọ̀ Ọlọ́run'); + expect(writer.totalBytes).toBe(Buffer.byteLength(chunks.join(''), 'utf8')); + }); +}); + +describe('ChunkWriter', () => { + it('rejects a non-positive chunk size', () => { + expect(() => new ChunkWriter(memorySink().sink, 0)).toThrow(); + }); + + it('writes nothing for an empty flush', async () => { + const { chunks, sink } = memorySink(); + const writer = new ChunkWriter(sink, 10); + await writer.flush(); + expect(chunks).toHaveLength(0); + expect(writer.chunkCount).toBe(0); + }); +}); + +describe('export registry', () => { + it('lists sections sorted by name and treats re-registering the same object as a no-op', () => { + const a = section('unit_zeta', []); + const b = section('unit_alpha', []); + registerExportSection(a); + registerExportSection(b); + registerExportSection(a); + const names = listExportSections().map((s) => s.name); + expect(names.indexOf('unit_alpha')).toBeLessThan(names.indexOf('unit_zeta')); + expect(names.filter((n) => n === 'unit_zeta')).toHaveLength(1); + unregisterExportSection('unit_zeta'); + unregisterExportSection('unit_alpha'); + }); + + it('refuses a second, different section under an existing name', () => { + registerExportSection(section('unit_dup', [])); + expect(() => registerExportSection(section('unit_dup', []))).toThrow(/already registered/); + unregisterExportSection('unit_dup'); + }); + + it('refuses names that are not stable lowercase keys', () => { + expect(() => registerExportSection(section('Bad Name', []))).toThrow(/Invalid export section name/); + }); +}); + +describe('paginate', () => { + const rows = Array.from({ length: 7 }, (_, i) => ({ id: `r${i}` })); + const fetchPage = async (cursor: string | undefined, take: number) => { + const start = cursor ? rows.findIndex((row) => row.id === cursor) + 1 : 0; + return rows.slice(start, start + take); + }; + + it('walks every row exactly once with keyset cursors', async () => { + const calls: (string | undefined)[] = []; + const seen: string[] = []; + for await (const page of paginate( + 3, + (cursor, take) => { + calls.push(cursor); + return fetchPage(cursor, take); + }, + (page) => page.map((row) => row.id), + )) { + expect(page.length).toBeLessThanOrEqual(3); + seen.push(...page); + } + expect(seen).toEqual(rows.map((row) => row.id)); + expect(calls).toEqual([undefined, 'r2', 'r5']); + }); + + it('stops after a full final page with one empty fetch', async () => { + const pages: string[][] = []; + for await (const page of paginate(7, fetchPage, (p) => p.map((row) => row.id))) pages.push(page); + expect(pages).toHaveLength(1); + }); + + it('rejects an invalid page size', async () => { + const iterator = paginate(0, fetchPage, (p) => p); + await expect(iterator.next()).rejects.toThrow(/pageSize/); + }); +}); diff --git a/docs/api/README.md b/docs/api/README.md index ee29323a..6f3ef94e 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -151,9 +151,69 @@ drift apart. See `docs/adr/0003` for why production needs `SameSite=None`. | `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. | | `GET /appointments/mine` | patient | — | Caller's own appointments, most recent first. | +## Patients (`/patients`) — `backend/src/modules/patients` + +### Data export — right of access (Phase 8, issue #40) + +| Method & path | Auth | Notes | +|---|---|---| +| `POST /patients/me/export` | patient | Starts an export of the **caller's own** data. `202` with the export status object and a `Location: /patients/me/export/:id` header. If one is already `pending`/`processing`, returns that one instead of queuing a second. No body is read — there is no way to name another patient. | +| `GET /patients/me/export/:id` | patient (owner) | Status object (below). `404` for an id that isn't the caller's — the same response as one that doesn't exist. | +| `GET /patients/me/export/:id/download` | patient (owner) | Streams the bundle as `application/json` (`Content-Disposition: attachment`, `Cache-Control: no-store`). `409 EXPORT_NOT_READY` while pending/processing/failed; `410 EXPORT_EXPIRED` at or after `expiresAt`, even before the cleanup job has run. | + +Doctors and admins get `403` on all three — there is no on-behalf-of export. + +Status object: + +```json +{ + "id": "uuid", + "status": "pending | processing | ready | failed | expired", + "schemaVersion": 1, + "createdAt": "…", "startedAt": "…", "generatedAt": "…", "expiresAt": "…", + "sizeBytes": 12345, + "failure": null, + "downloadUrl": "/patients/me/export//download" +} +``` + +`downloadUrl` and `sizeBytes` are non-null only while `ready`. A failed +export reports `failure: { code: 'SECTION_FAILED', section: '' }` (or +`GENERATION_FAILED` / `GENERATION_TIMEOUT`, with `section: null`) — request a +new export to retry. Generation is asynchronous; poll the status object. + +Bundle (`schemaVersion` 1): + +```json +{ + "format": "docforum.patient-export", + "schemaVersion": 1, + "exportId": "uuid", + "generatedAt": "ISO-8601", + "sections": { + "account": { "version": 1, "items": [{ "id", "email", "phone", "role", "emailVerifiedAt", "createdAt", "updatedAt" }] }, + "appointments": { "version": 1, "items": [{ "id", "status", "origin", "slot": { "startTime", "endTime" }, "doctor": { "id", "fullName" }, "createdAt", "updatedAt" }] }, + "payments": { "version": 1, "items": [{ "id", "orderType", "orderId", "status", "amountStroops", "facility": { "id", "name" }, "escrowId", "stellarTxHash", "sorobanContractId", "createdAt", "updatedAt" }] }, + "profile": { "version": 1, "items": [{ "id", "fullName", "dateOfBirth", "createdAt", "updatedAt" }] } + } +} +``` + +Sections are sorted by name; new modules add sections (ARCHITECTURE.md §3.1) +without changing the envelope. `amountStroops`/`escrowId` are strings +(bigint). No password hash, tokens, throttle state or platform secrets are +ever included. + +Exports stay downloadable for `PATIENT_EXPORT_TTL_HOURS` (default 24), then +the cleanup job deletes the stored bundle. Other knobs: `PATIENT_EXPORT_PAGE_SIZE`, +`PATIENT_EXPORT_CHUNK_BYTES`, `PATIENT_EXPORT_WORKER_INTERVAL_SECONDS`, +`PATIENT_EXPORT_CLEANUP_INTERVAL_SECONDS`, `PATIENT_EXPORT_STALE_AFTER_SECONDS`, +`PATIENT_EXPORT_MAX_ATTEMPTS` — see `backend/src/config/index.ts` and +`backend/src/jobs/README.md`. + ## Not yet implemented -Everything else in `ARCHITECTURE.md` §3's module list (`patients`, -`facilities`, `consultations`, `referrals`, `prescriptions`, `lab-orders`, +Everything else in `ARCHITECTURE.md` §3's module list (`facilities`, +`consultations`, `referrals`, `prescriptions`, `lab-orders`, `pharmacy-orders`, `payments`, `notifications`) has no routes yet — Phase 2 onward, see `ROADMAP.md`.