mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
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:
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;
|
||||
@@ -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")
|
||||
}
|
||||
Reference in new issue
Block a user