From 32f51b4299482ae7570013e526dcacb4b3d342eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 7 Jul 2026 18:34:58 +0200 Subject: [PATCH] feat(share-links): add frozen document-membership join (migration 0229) P3-1-T1. Introduce ClinicianShareLinkDocument, the write-once join binding a stored document to a clinician share link. The document set a share exposes is a relation, not a mutable column, so it inherits the link's frozen-scope discipline. Both foreign keys cascade so a deleted link or document takes its membership with it. --- .../0229_share_link_documents/migration.sql | 45 +++++++++++++++++++ prisma/schema.prisma | 33 +++++++++++++- 2 files changed, 77 insertions(+), 1 deletion(-) create mode 100644 prisma/migrations/0229_share_link_documents/migration.sql diff --git a/prisma/migrations/0229_share_link_documents/migration.sql b/prisma/migrations/0229_share_link_documents/migration.sql new file mode 100644 index 000000000..6f7baac5d --- /dev/null +++ b/prisma/migrations/0229_share_link_documents/migration.sql @@ -0,0 +1,45 @@ +-- Document vault, Phase 3: attach hand-picked documents to a clinician share +-- link. +-- +-- `clinician_share_link_documents` — frozen, write-once membership rows +-- binding a stored document to a share link. The document set a share +-- exposes is modelled as this join, never a mutable column on the link, so +-- it inherits the link's frozen-scope discipline: rows are created in the +-- same transaction as the link, each document is owner-checked at create, +-- and the set is never widened afterwards. The public share serve route +-- serves ONLY a document id present in this set for the resolved token. +-- +-- Both foreign keys cascade: deleting the link or the underlying document +-- removes the membership cleanly (a shared document the owner later deletes +-- simply disappears from the link). +-- +-- Additive; applies clean on a 0228 database and on a fresh database as part +-- of the ordered migration chain. + +CREATE TABLE "clinician_share_link_documents" ( + "id" TEXT NOT NULL, + "share_link_id" TEXT NOT NULL, + "document_id" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "clinician_share_link_documents_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "clinician_share_link_documents_share_link_id_document_id_key" + ON "clinician_share_link_documents" ("share_link_id", "document_id"); + +CREATE INDEX "clinician_share_link_documents_share_link_id_idx" + ON "clinician_share_link_documents" ("share_link_id"); + +CREATE INDEX "clinician_share_link_documents_document_id_idx" + ON "clinician_share_link_documents" ("document_id"); + +ALTER TABLE "clinician_share_link_documents" + ADD CONSTRAINT "clinician_share_link_documents_share_link_id_fkey" + FOREIGN KEY ("share_link_id") REFERENCES "clinician_share_links" ("id") + ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "clinician_share_link_documents" + ADD CONSTRAINT "clinician_share_link_documents_document_id_fkey" + FOREIGN KEY ("document_id") REFERENCES "inbound_documents" ("id") + ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 62555c6c5..d0836588b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -4852,13 +4852,41 @@ model ClinicianShareLink { lastAccessAt DateTime? @map("last_access_at") accessCount Int @default(0) @map("access_count") - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + /// v1.28 — the hand-picked document set this link exposes (0..N). A relation, + /// not a column: frozen write-once at create, owner-checked, never widened. + documents ClinicianShareLinkDocument[] @@index([userId, createdAt(sort: Desc)]) @@index([expiresAt]) @@map("clinician_share_links") } +/// v1.28 (document vault, Phase 3) — frozen, write-once membership binding a +/// stored document to a clinician share link. The document set a share exposes +/// is modelled as this relation, never a mutable column: the rows are created +/// in the same transaction as the link, each `documentId` is validated as the +/// caller's own live document at create time, and the set is never widened +/// afterwards. The public share serve route serves ONLY a document id present +/// in this set for the resolved token — a guessed / foreign id is a flat 404. +/// Both foreign keys cascade: deleting the link or the underlying document +/// removes the membership cleanly (a shared document the owner later deletes +/// simply disappears from the link). +model ClinicianShareLinkDocument { + id String @id @default(cuid()) + shareLinkId String @map("share_link_id") + documentId String @map("document_id") + createdAt DateTime @default(now()) @map("created_at") + + shareLink ClinicianShareLink @relation(fields: [shareLinkId], references: [id], onDelete: Cascade) + document InboundDocument @relation(fields: [documentId], references: [id], onDelete: Cascade) + + @@unique([shareLinkId, documentId]) + @@index([shareLinkId]) + @@index([documentId]) + @@map("clinician_share_link_documents") +} + // v1.11.0 W3 — period-narrative cache (Pillar P1). // // One durable, typed row per (user, period, locale) carrying the latest @@ -5815,6 +5843,9 @@ model InboundDocument { facts ExtractedFact[] conditionLinks DocumentConditionLink[] + /// v1.28 — share-link memberships (m:n via `ClinicianShareLinkDocument`). + /// Cascades: deleting this document removes it from every share it was on. + shareLinks ClinicianShareLinkDocument[] createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at")