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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-04 00:49:31 +02:00
1 parent ea3dab33f7
commit f20ad79043
2 files changed
+108

No files matched your search

@@ -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;
+58
View File
@@ -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")
}