From f20ad79043924e928c8af18a82c76c90d8e9ea5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 4 Jun 2026 00:36:31 +0200 Subject: [PATCH] feat(db): add ClinicianShareLink model (migration 0110) Owner-minted, scoped, time-limited, revocable read view of the health record for a clinician holding the raw hls_ token. The token is stored only as an HMAC-SHA256 hash; the scope (range start, sections, resource types) is frozen at creation and never widened. expiresAt is mandatory (capped at SHARE_LINK_MAX_DAYS, default 90); only revokedAt, lastAccessAt and accessCount mutate after mint. Cascades on user delete. Schema + migration only; no behaviour yet. --- .../migration.sql | 50 ++++++++++++++++ prisma/schema.prisma | 58 +++++++++++++++++++ 2 files changed, 108 insertions(+) create mode 100644 prisma/migrations/0110_v1110_clinician_share_links/migration.sql diff --git a/prisma/migrations/0110_v1110_clinician_share_links/migration.sql b/prisma/migrations/0110_v1110_clinician_share_links/migration.sql new file mode 100644 index 000000000..de34c5680 --- /dev/null +++ b/prisma/migrations/0110_v1110_clinician_share_links/migration.sql @@ -0,0 +1,50 @@ +-- v1.11.0 — clinician share links. +-- +-- A user mints a link (`hls_<48 hex>`) they hand a clinician, who opens it in +-- any browser with no account and sees a scoped, time-limited, revocable +-- read-only view of the health record. The raw token is stored ONLY as an +-- HMAC-SHA256 hash (`token_hash`, same scheme as `api_tokens.token_hash`); +-- the plaintext is returned to the owner exactly once at creation. +-- +-- The scope is FROZEN at creation: `range_start`, `sections_json` and +-- `resource_types` are write-once — a share can NEVER widen later. Only +-- `revoked_at`, `last_access_at` and `access_count` mutate after mint, which +-- mirrors the append-only spirit of `consent_receipts`. `expires_at` is +-- mandatory (capped at SHARE_LINK_MAX_DAYS, default 90) — no never-expiring +-- share. Deleting the user cascades every share away (clean GDPR-erasure). +-- +-- Purely additive: one new table, no enum changes, no backfill. Forward-only; +-- dropping the table loses only the share links (read access is revoked). + +CREATE TABLE IF NOT EXISTS "clinician_share_links" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "token_hash" TEXT NOT NULL, + "label" TEXT NOT NULL, + "range_start" TIMESTAMP(3) NOT NULL, + "range_end" TIMESTAMP(3), + "sections_json" JSONB NOT NULL, + "resource_types" TEXT[], + "allow_fhir_api" BOOLEAN NOT NULL DEFAULT false, + "expires_at" TIMESTAMP(3) NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "revoked_at" TIMESTAMP(3), + "last_access_at" TIMESTAMP(3), + "access_count" INTEGER NOT NULL DEFAULT 0, + + CONSTRAINT "clinician_share_links_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX IF NOT EXISTS "clinician_share_links_token_hash_key" + ON "clinician_share_links" ("token_hash"); + +CREATE INDEX IF NOT EXISTS "clinician_share_links_user_id_created_at_idx" + ON "clinician_share_links" ("user_id", "created_at" DESC); + +CREATE INDEX IF NOT EXISTS "clinician_share_links_expires_at_idx" + ON "clinician_share_links" ("expires_at"); + +ALTER TABLE "clinician_share_links" + ADD CONSTRAINT "clinician_share_links_user_id_fkey" + FOREIGN KEY ("user_id") REFERENCES "users" ("id") + ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 14a888a8e..c87c19038 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -409,6 +409,10 @@ model User { // the 0–100 map to the user's own training load without re-integrating // the 42-day chronic window of HR series each night. strainTrimpCache StrainTrimpCache[] + // v1.11.0 — owner-minted clinician share links. Each row grants a + // scoped, time-limited, revocable read view of the health record to a + // clinician holding the raw `hls_` token (stored only as an HMAC hash). + clinicianShareLinks ClinicianShareLink[] // v1.7.0 profile — optional patient-identity fields surfaced on the // health-record export cover (PDF) and the FHIR `Patient` resource. @@ -2643,3 +2647,57 @@ model PushAttempt { @@index([userId, createdAt(sort: Desc)]) @@map("push_attempts") } + +// ─── Clinician share links (scoped, time-limited, revocable read view) ─── +// +// v1.11.0 — a user mints a link they hand a clinician (email, paper, QR). +// The clinician opens it in any browser with no account and sees a +// read-only view of the health record. The raw `hls_` token is stored +// ONLY as an HMAC-SHA256 hash (`tokenHash`, same scheme as +// `ApiToken.tokenHash`, keyed by `API_TOKEN_HMAC_KEY`); the plaintext is +// returned to the owner exactly once at creation and never persisted. +// +// The scope is FROZEN at creation: `rangeStart`, `sectionsJson` and +// `resourceTypes` are write-once and a share can NEVER widen later — only +// `revokedAt`, `lastAccessAt` and `accessCount` mutate after mint. This +// mirrors the append-only spirit of `ConsentReceipt`. `expiresAt` is +// mandatory (capped at `SHARE_LINK_MAX_DAYS`, default 90) — there is no +// never-expiring share. Deleting the user cascades every share away so +// the GDPR-erasure path stays clean. +model ClinicianShareLink { + id String @id @default(cuid()) + userId String @map("user_id") + /// HMAC-SHA256 of the raw `hls_` token (same scheme as `ApiToken.tokenHash`). + tokenHash String @unique @map("token_hash") + /// Human label the owner sets (e.g. a clinic/specialty note). Plaintext, bounded. + label String + + // ── Frozen scope (write-once at creation; never widened) ── + /// Reporting-window start (absolute, not "last N days"). Frozen so a + /// rolling share can never reach data older than the owner chose. + rangeStart DateTime @map("range_start") + /// Window end. Null = rolling ("up to now"); set = frozen snapshot window. + rangeEnd DateTime? @map("range_end") + /// Section toggles (the `DoctorReportPrefs` shape the export uses). JSON. + sectionsJson Json @map("sections_json") + /// FHIR resource types this link may serve (a subset of the catalogue), + /// e.g. `["Observation","MedicationStatement"]`. + resourceTypes String[] @map("resource_types") + /// Whether the FHIR API is reachable via this link at all (vs the view only). + allowFhirApi Boolean @default(false) @map("allow_fhir_api") + + /// REQUIRED — no never-expiring share (capped at `SHARE_LINK_MAX_DAYS`). + expiresAt DateTime @map("expires_at") + createdAt DateTime @default(now()) @map("created_at") + + // ── Mutable lifecycle/access counters (the only columns that change) ── + revokedAt DateTime? @map("revoked_at") + lastAccessAt DateTime? @map("last_access_at") + accessCount Int @default(0) @map("access_count") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId, createdAt(sort: Desc)]) + @@index([expiresAt]) + @@map("clinician_share_links") +}