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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 33 additions & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ Each module under `backend/src/modules/<name>/` owns its own `routes/`, `control
- `lab-orders` — lab order issuance and versioning.
- `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.
- `audit` — the append-only `AuditEvent` trail (Phase 8, issue #43). Owns one write helper, `auditService.record(tx, actor, event)`, that every module calls **inside the transaction of the state change it describes** (it refuses the root client), and the admin-only `GET /admin/audit-events`. Controllers build the actor with `auditActor(req)` (`src/middleware/request-id.ts`: user id, role, `X-Request-Id`). Wired today: appointment booking, doctor verification, payment fund/release/refund. **Phase 3/4 transitions (referrals, prescriptions, lab orders, fulfillment) must call `record()` in the same transaction when they're built** — that wiring is not done here.
- `audit` — the append-only `AuditEvent` trail (Phase 8, issue #43). Owns one write helper, `auditService.record(tx, actor, event)`, that every module calls **inside the transaction of the state change it describes** (it refuses the root client), and the admin-only `GET /admin/audit-events`. Controllers build the actor with `auditActor(req)` (`src/middleware/request-id.ts`: user id, role, `X-Request-Id`). Wired today: appointment booking, doctor verification, payment fund/release/refund. **Phase 3/4 transitions (referrals, prescriptions, lab orders, fulfillment) must call `record()` in the same transaction when they're built** — that wiring is not done here. Also owns the PHI read-access log (Phase 8, issue #45, §4.11): `auditService.recordAccess()` (fire-and-forget off the read path, fail-open but counted), admin `GET /admin/access-logs` (+ `/user`, `/stats`) and the patient-facing `GET /patients/me/access-log`, whose response strips all viewer PII to a role.

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

Expand Down Expand Up @@ -292,6 +292,38 @@ Bundle format (schemaVersion 1): `{ format: "docforum.patient-export",
schemaVersion, exportId, generatedAt, sections: { <name>: { version, items } } }`
— see §3.1 for where sections come from.

### 4.11 PHI read-access log (Phase 8, issue #45)
```
AccessLog
id, accessed_by (nullable FK → User, SET NULL on delete — the row keeps
its timestamp and entity while the PII pointer is removed)
patient_profile_id (nullable FK → PatientProfile, CASCADE — the record
owner, whose data was viewed; used by the patient-facing query)
entity_type ('IntakeForm' | 'Consultation' | 'Prescription' |
'PrescriptionItem' | 'LabOrder' | 'LabResult' | 'Referral' |
'PaymentIntent' | 'PatientProfile' | 'Appointment'), entity_id
route (nullable — the HTTP route that returned the payload),
detail (nullable — context string, never PHI)
accessed_at (default now)
# indexes: (accessed_at), (accessed_by), (patient_profile_id,
accessed_at), (entity_type, entity_id).
```
Answers "who looked at this" — distinct from `AuditEvent`'s "who changed
this" (§4.10). Reads are far higher volume than state changes, so:
- writes go through `auditService.recordAccess()`, which is
**fire-and-forget** (never awaited on the read path — a unit test pins
sub-50ms return with a hanging insert) and **fail-open** (a logging
failure never fails the read, but is counted and surfaced via
`GET /admin/access-logs/stats` for alerting);
- rows are **retention-managed**, not append-only: `pruneOldAccessLogs()`
(wired into the prune interval in `src/server.ts`) deletes rows older
than `ACCESS_LOG_RETENTION_DAYS` (default 365). That delete is the only
one in the audit repositories and is bounded to the retention cutoff.
Patient-facing query (`GET /patients/me/access-log`) is scoped server-side
to the caller's own profile (resolved from the authenticated user, never
from the request) and strips all viewer PII to a role — a deleted viewer
surfaces as `deleted_user`.

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

### 5.1 Booking (concurrency-safe)
Expand Down
1 change: 1 addition & 0 deletions ARCHITECTURE_ESSENTIALS.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ Quick-reference only. Full reasoning lives in `ARCHITECTURE.md`. Full product sc
`Consultation` → `Prescription`(+`PrescriptionItem`) / `LabOrder`(+`LabResult`)
`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).
`User` → `AccessLog` (Phase 8, issue #45 — PHI read-access log: who viewed which clinical record, when; fire-and-forget writes, fail-open).
`LoginAttempt` (Phase 8, issue #35 — Postgres-backed login throttle buckets, one per normalised email and per client IP; counters move only via a conditional upsert).
`PatientProfile` → `PatientExport` → `PatientExportChunk` (Phase 8, issue #40 — right-of-access export jobs + their chunked JSON bundle; each module contributes a section via its own service, registered in `src/export-sections.ts` — see ARCHITECTURE.md §3.1).
`FacilityProfile` → `WalletLink` (payout address) ; `PatientProfile`/`FacilityProfile` → `PaymentIntent` (opaque `orderType`/`orderId` — see docs/adr/0004)
Expand Down
1 change: 1 addition & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ for status and issue links.
- [ ] Held-slot TTL/release job (ARCHITECTURE.md §6.2.1). Tracked as [issue #19](https://github.com/DocForum/docforum-core/issues/19) (Medium, 150 pts) — unblocked now, operates on Phase 1's `AvailabilitySlot`.
- [x] Access-control test suite: who may read intake, consultation and payment data. Integration tests (`tests/integration/access-control.test.mts`) establishing the test harness and access-control patterns for the three PHI data categories; intake/consultation test cases are scaffolded as placeholders until Phase 2/3 routes exist, payment-intent access cases exercise the live admin-gated routes. Tracked as [issue #44](https://github.com/DocForum/docforum-core/issues/44) — **Done**.
- [ ] 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.
- [ ] PHI read-access log: record who viewed which clinical record. `AccessLog` table (viewer, record owner, entity, route, retention-managed), fire-and-forget fail-open writes off the read path, admin `GET /admin/access-logs` (+ `/user`, `/stats`) and the patient-facing `GET /patients/me/access-log` with all viewer PII stripped to a role. Tracked as [issue #45](https://github.com/DocForum/docforum-core/issues/45) (High) — **in review**, PR #149. Read-path wiring into the Phase 2/3/4/5 controllers (intake, consultation, prescriptions, lab orders) happens as those routes are built.
- [ ] Decide and document what's explicitly out of scope for v1 launch vs. genuinely blocking (e.g., drug-interaction checking, PRD §8.2.8 — needs an explicit go/no-go, not silence). Tracked as [issue #20](https://github.com/DocForum/docforum-core/issues/20) (Trivial, 100 pts) — a decision task, unblocked now.

## Phase 9 — Deploy & CI
Expand Down
4 changes: 4 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ LOGIN_THROTTLE_LOCK_SECONDS=900
LOGIN_THROTTLE_PRUNE_INTERVAL_SECONDS=3600
LOGIN_THROTTLE_PRUNE_AFTER_SECONDS=86400

# PHI read-access log retention (Phase 8, issue #45). AccessLog rows older
# than this many days are deleted by the prune job in src/server.ts.
ACCESS_LOG_RETENTION_DAYS=365

# Patient data export (Phase 8, issue #40). Defaults are in src/config/index.ts.
PATIENT_EXPORT_TTL_HOURS=24
PATIENT_EXPORT_PAGE_SIZE=200
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
-- CreateTable
CREATE TABLE "AccessLog" (
"id" TEXT NOT NULL,
"accessedBy" TEXT,
"patientProfileId" TEXT,
"entityType" TEXT NOT NULL,
"entityId" TEXT NOT NULL,
"route" TEXT,
"detail" TEXT,
"accessedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,

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

-- CreateIndex
CREATE INDEX "AccessLog_accessedAt_idx" ON "AccessLog"("accessedAt");

-- CreateIndex
CREATE INDEX "AccessLog_accessedBy_idx" ON "AccessLog"("accessedBy");

-- CreateIndex
CREATE INDEX "AccessLog_patientProfileId_accessedAt_idx" ON "AccessLog"("patientProfileId", "accessedAt");

-- CreateIndex
CREATE INDEX "AccessLog_entityType_entityId_idx" ON "AccessLog"("entityType", "entityId");

-- AddForeignKey
ALTER TABLE "AccessLog" ADD CONSTRAINT "AccessLog_accessedBy_fkey" FOREIGN KEY ("accessedBy") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "AccessLog" ADD CONSTRAINT "AccessLog_patientProfileId_fkey" FOREIGN KEY ("patientProfileId") REFERENCES "PatientProfile"("id") ON DELETE CASCADE ON UPDATE CASCADE;
110 changes: 77 additions & 33 deletions backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -98,11 +98,11 @@ enum Urgency {
// =========================================================================

model User {
id String @id @default(uuid())
email String @unique
phone String?
passwordHash String
role Role
id String @id @default(uuid())
email String @unique
phone String?
passwordHash String
role Role
// Phase 8 (issue #34): null = never verified. Self-serve signup leaves
// this null; existing rows were backfilled to `createdAt` in the
// add_email_verification migration. Distinct from
Expand All @@ -129,6 +129,9 @@ model User {

emailVerificationTokens EmailVerificationToken[]
adminInvitesSent AdminInvite[]
// Phase 8 (issue #45): PHI read-access log rows this user wrote (as viewer).
// Opposite of AccessLog.accessedByUser.
accessLogs AccessLog[]

@@index([role])
}
Expand Down Expand Up @@ -201,22 +204,25 @@ model LoginAttempt {
}

model PatientProfile {
id String @id @default(uuid())
userId String @unique
fullName String
dateOfBirth DateTime?
id String @id @default(uuid())
userId String @unique
fullName String
dateOfBirth DateTime?
// Nullable, no FK relation on purpose — the minor/dependent consent and
// account-ownership model is an explicit open PRD question (§8.2.5), not
// resolved here. Don't build on top of this field assuming it means
// anything enforced yet.
guardianUserId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

user User @relation(fields: [userId], references: [id], onDelete: Cascade)
appointments Appointment[]
paymentIntents PaymentIntent[]
dataExports PatientExport[]

// Phase 8 (issue #45): who viewed this patient's records.
accessLogs AccessLog[]
}

// Phase 8 (issue #40): right-of-access export jobs. One row per request;
Expand Down Expand Up @@ -341,7 +347,7 @@ model FacilityCapability {
capabilityType CapabilityType
// e.g. a lab test category, or a medication class — free text on purpose,
// no controlled vocabulary decided yet (out of Phase 1 scope).
detail String?
detail String?

facilityProfile FacilityProfile @relation(fields: [facilityProfileId], references: [id], onDelete: Cascade)

Expand Down Expand Up @@ -391,6 +397,44 @@ model Appointment {
// TODO Phase 3: referralId (nullable FK -> Referral).
}

// =========================================================================
// Phase 8 (issue #45): PHI read-access logging — AccessLog
//
// Answers "who looked at this" (distinct from AuditEvent's "who changed
// this"). Written asynchronously off the read path so it never adds to
// p95 latency. Fail-open: a logging failure never fails the read, but is
// counted and surfaced for alerting.
//
// patientProfileId is the record owner (whose data was viewed) — used by
// GET /patients/me/access-log. accessedBy is the viewer.
// =========================================================================

model AccessLog {
id String @id @default(uuid())
// Who viewed. SetNull on user delete keeps the log row (timestamp +
// entity remain) while removing the PII pointer.
accessedBy String?
// Which patient's records were viewed (the record owner, not the viewer).
patientProfileId String?
// Entity that was read: 'IntakeForm' | 'Consultation' | 'Prescription' |
// 'LabOrder' | 'LabResult' | 'Referral' | 'PaymentIntent' | 'PatientProfile'
entityType String
entityId String
// The HTTP route that returned the payload (for correlating with access logs).
route String?
// Free-text detail — no PHI here, just context like "GET /appointments/mine".
detail String?
accessedAt DateTime @default(now())

accessedByUser User? @relation(fields: [accessedBy], references: [id], onDelete: SetNull)
patientProfile PatientProfile? @relation(fields: [patientProfileId], references: [id], onDelete: Cascade)

@@index([accessedAt])
@@index([accessedBy])
@@index([patientProfileId, accessedAt])
@@index([entityType, entityId])
}

// =========================================================================
// Below this line: Phase 0 structural placeholders, not yet implemented.
// See ROADMAP.md for phase order.
Expand All @@ -403,38 +447,38 @@ model IntakeForm {
}

model Consultation {
id String @id @default(uuid())
id String @id @default(uuid())
outcome ConsultationOutcome?
notes String?
createdAt DateTime @default(now())
createdAt DateTime @default(now())
// TODO: appointmentId (unique)
}

model Referral {
id String @id @default(uuid())
status ReferralStatus @default(pending)
snapshotNotes String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id String @id @default(uuid())
status ReferralStatus @default(pending)
snapshotNotes String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// TODO: sourceConsultationId, referringDoctorId, targetSpecialtyId,
// targetDoctorId (nullable), snapshotIntakeId, resultingAppointmentId (nullable)
// IMPORTANT: snapshotNotes / snapshotIntakeId must be copies, not live
// references — see ARCHITECTURE.md §4.4 design note.
}

model Prescription {
id String @id @default(uuid())
status OrderStatus @default(issued)
createdAt DateTime @default(now())
id String @id @default(uuid())
status OrderStatus @default(issued)
createdAt DateTime @default(now())
// TODO: consultationId, patientProfileId, prescribingDoctorId, supersedesPrescriptionId (nullable, self-relation)
}

model PrescriptionItem {
id String @id @default(uuid())
drugName String
dosage String
id String @id @default(uuid())
drugName String
dosage String
frequency String
duration String
duration String
// TODO: prescriptionId
}

Expand Down Expand Up @@ -469,11 +513,11 @@ model FulfillmentRecord {
}

model Notification {
id String @id @default(uuid())
id String @id @default(uuid())
type String
payload Json
readAt DateTime?
createdAt DateTime @default(now())
createdAt DateTime @default(now())
// TODO: userId
}

Expand Down Expand Up @@ -507,15 +551,15 @@ enum OrderTypeForPayment {
}

model PaymentIntent {
id String @id @default(uuid())
orderType OrderTypeForPayment
id String @id @default(uuid())
orderType OrderTypeForPayment
// Opaque — Prescription/LabOrder don't exist as real records yet
// (Phase 4). Not validated against anything until then.
orderId String
status PaymentIntentStatus @default(created)
orderId String
status PaymentIntentStatus @default(created)

patientProfileId String
patientProfile PatientProfile @relation(fields: [patientProfileId], references: [id])
patientProfile PatientProfile @relation(fields: [patientProfileId], references: [id])
facilityId String
facility FacilityProfile @relation(fields: [facilityId], references: [id])

Expand Down
7 changes: 6 additions & 1 deletion backend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { paymentsRouter } from './modules/payments/routes';
import { patientsRouter } from './modules/patients/routes';
import { adminRouter } from './modules/users/routes';
import { registerPatientExportSections } from './export-sections';
import { auditRouter } from './modules/audit/routes';
import { auditRouter, adminAuditRouter, patientAuditRouter } from './modules/audit/routes';
import { requestId } from './middleware/request-id';

export function createApp() {
Expand Down Expand Up @@ -48,6 +48,11 @@ export function createApp() {
app.use('/admin', adminRouter);
app.use('/admin', auditRouter);

// PHI read-access logging (Phase 8, issue #45). Mounted at root so the
// paths match the issue's spec exactly: /admin/access-logs, /patients/me/access-log.
app.use('/admin', adminAuditRouter);
app.use('/patients', patientAuditRouter);

// Must be registered last — Express only routes errors here once every
// other handler has had a chance to run.
app.use(errorHandler);
Expand Down
7 changes: 7 additions & 0 deletions backend/src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,13 @@ 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),
},
// PHI read-access log (Phase 8, issue #45). How long AccessLog rows are
// kept before the prune job (src/server.ts) deletes them. Reads are far
// higher volume than AuditEvent writes, so this gets its own, shorter
// retention instead of AuditEvent's append-forever lifetime.
accessLog: {
retentionDays: Number(process.env.ACCESS_LOG_RETENTION_DAYS ?? 365),
},
// Patient data export (Phase 8, issue #40). Env-configurable so a
// deployment can tune retention and memory bounds without a code change.
patientExport: {
Expand Down
Loading
Loading