mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
feat(documents): add the vault data model (migration 0227)
REFERRAL joins the document-kind enum (Überweisung — LOINC 57133-1 / ePA ANF). inbound_documents gains content_sha256 (plaintext hash for same-user duplicate detection; pre-existing rows stay NULL) and content_codec (base64v1 legacy string codec | binary2 binary codec, explicit column — never header-sniffed), plus a partial unique index that allows at most one LIVE row per (user_id, content_sha256) so a tombstoned document never blocks a re-upload. document_condition_links is the m:n join between a document and an illness/condition episode (a discharge letter legitimately touches two conditions); both sides cascade. app_settings carries the two admin-tunable limits (per-file cap, default 25 MiB; per-user quota, default 1 GiB) and users the nullable per-user quota override. Task: W1-T1.
This commit is contained in:
1 parent
0da11811e2
commit
7f1cd575f4
2 files changed
+142
-5
No files matched your search
@@ -0,0 +1,78 @@
|
||||
-- Document vault: re-enable the parked inbound-documents module with the
|
||||
-- hardened storage contract.
|
||||
--
|
||||
-- 1. `inbound_document_kind` gains REFERRAL (Überweisung — clean LOINC
|
||||
-- 57133-1 / ePA ANF mapping).
|
||||
-- 2. `inbound_documents` gains `content_sha256` (lowercase hex of the
|
||||
-- plaintext, for same-user duplicate detection; pre-existing rows stay
|
||||
-- NULL and never dedupe) and `content_codec` ("base64v1" legacy string
|
||||
-- codec | "binary2" binary codec; the default keeps existing rows
|
||||
-- correct) plus a partial unique index enforcing at most one LIVE row
|
||||
-- per (user_id, content_sha256).
|
||||
-- 3. `document_condition_links` — m:n join between a document and an
|
||||
-- illness/condition episode. Both sides cascade.
|
||||
-- 4. `app_settings` gains the two admin-tunable limits (per-file cap,
|
||||
-- default 25 MiB; per-user quota, default 1 GiB); `users` gains the
|
||||
-- nullable per-user quota override.
|
||||
--
|
||||
-- Additive and idempotent-safe on an existing database; applies clean on a
|
||||
-- fresh database as part of the ordered migration chain.
|
||||
|
||||
-- 1. Kind enum
|
||||
ALTER TYPE "inbound_document_kind" ADD VALUE IF NOT EXISTS 'REFERRAL';
|
||||
|
||||
-- 2. Document columns + live-dedupe partial unique index
|
||||
ALTER TABLE "inbound_documents"
|
||||
ADD COLUMN "content_sha256" VARCHAR(64),
|
||||
ADD COLUMN "content_codec" TEXT NOT NULL DEFAULT 'base64v1';
|
||||
|
||||
-- Partial unique index (documented in schema.prisma — Prisma cannot express
|
||||
-- a partial index, so it lives here as raw SQL). Tombstoned rows are
|
||||
-- excluded: a soft-deleted document must not block re-uploading the same
|
||||
-- file, and restore re-enters the constraint (a conflicting restore 409s).
|
||||
CREATE UNIQUE INDEX "inbound_documents_user_sha_live"
|
||||
ON "inbound_documents" ("user_id", "content_sha256")
|
||||
WHERE "deleted_at" IS NULL AND "content_sha256" IS NOT NULL;
|
||||
|
||||
-- 3. Document ⇄ condition links
|
||||
CREATE TABLE "document_condition_links" (
|
||||
"id" TEXT NOT NULL,
|
||||
"document_id" TEXT NOT NULL,
|
||||
"episode_id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "document_condition_links_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "document_condition_links_document_id_episode_id_key"
|
||||
ON "document_condition_links" ("document_id", "episode_id");
|
||||
|
||||
CREATE INDEX "document_condition_links_episode_id_idx"
|
||||
ON "document_condition_links" ("episode_id");
|
||||
|
||||
CREATE INDEX "document_condition_links_user_id_idx"
|
||||
ON "document_condition_links" ("user_id");
|
||||
|
||||
ALTER TABLE "document_condition_links"
|
||||
ADD CONSTRAINT "document_condition_links_document_id_fkey"
|
||||
FOREIGN KEY ("document_id") REFERENCES "inbound_documents" ("id")
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "document_condition_links"
|
||||
ADD CONSTRAINT "document_condition_links_episode_id_fkey"
|
||||
FOREIGN KEY ("episode_id") REFERENCES "illness_episodes" ("id")
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "document_condition_links"
|
||||
ADD CONSTRAINT "document_condition_links_user_id_fkey"
|
||||
FOREIGN KEY ("user_id") REFERENCES "users" ("id")
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- 4. Admin limits
|
||||
ALTER TABLE "app_settings"
|
||||
ADD COLUMN "document_max_file_bytes" INTEGER NOT NULL DEFAULT 26214400,
|
||||
ADD COLUMN "document_quota_bytes" BIGINT NOT NULL DEFAULT 1073741824;
|
||||
|
||||
ALTER TABLE "users"
|
||||
ADD COLUMN "document_quota_bytes" BIGINT;
|
||||
+64
-5
@@ -537,6 +537,12 @@ model User {
|
||||
// `GET /api/auth/me` as a `modules: { <key>: boolean }` map.
|
||||
modulePreferencesJson Json? @map("module_preferences_json")
|
||||
|
||||
// Document vault — per-user storage-quota override in bytes. NULL = the
|
||||
// instance default (`AppSettings.documentQuotaBytes`) applies. Admin-set
|
||||
// only (the admin user PUT); resolved by
|
||||
// `resolveDocumentLimits()` in `src/lib/documents/upload-policy.ts`.
|
||||
documentQuotaBytes BigInt? @map("document_quota_bytes")
|
||||
|
||||
// v1.18.6 (CCH-03) — "Coach last seen" stamp powering the unread dot
|
||||
// on the Coach FAB. The proactive nudge now lands as an ASSISTANT
|
||||
// message in the conversation rail (CCH-02); the FAB shows a discreet
|
||||
@@ -777,6 +783,7 @@ model User {
|
||||
// approval. Opt-in module, off by default.
|
||||
inboundDocuments InboundDocument[]
|
||||
extractedFacts ExtractedFact[]
|
||||
documentConditionLinks DocumentConditionLink[]
|
||||
// v1.19.0 — full Withings ECG waveform recordings (encrypted at rest).
|
||||
// The paired AFib verdict still lands as an IRREGULAR_RHYTHM_NOTIFICATION
|
||||
// Measurement EVENT row (v1.18.11); this holds the raw signal samples.
|
||||
@@ -3767,6 +3774,16 @@ model AppSettings {
|
||||
// never be disabled. Resolved in `src/lib/modules/gate.ts`.
|
||||
moduleAvailabilityJson Json? @map("module_availability_json")
|
||||
|
||||
// Document vault — the two admin-tunable limits (the only knobs beside the
|
||||
// module key). `documentMaxFileBytes` is the per-file upload cap, default
|
||||
// 25 MiB, clamped at runtime to the hard 100 MiB ceiling (single-shot GCM +
|
||||
// bounded in-memory reads are load-bearing past that). `documentQuotaBytes`
|
||||
// is the instance-wide per-user storage quota, default 1 GiB; a per-user
|
||||
// override lives on `User.documentQuotaBytes`. Usage counts every
|
||||
// non-purged row (tombstones still hold TOAST bytes).
|
||||
documentMaxFileBytes Int @default(26214400) @map("document_max_file_bytes")
|
||||
documentQuotaBytes BigInt @default(1073741824) @map("document_quota_bytes")
|
||||
|
||||
@@map("app_settings")
|
||||
}
|
||||
|
||||
@@ -5400,11 +5417,12 @@ model IllnessEpisode {
|
||||
/// Soft-delete tombstone. NULL = live; non-NULL = deleted at the instant.
|
||||
deletedAt DateTime? @map("deleted_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
/// Parent condition for a flare/recurrence (self-relation).
|
||||
parent IllnessEpisode? @relation("IllnessFlares", fields: [parentConditionId], references: [id], onDelete: SetNull)
|
||||
flares IllnessEpisode[] @relation("IllnessFlares")
|
||||
dayLogs IllnessDayLog[]
|
||||
parent IllnessEpisode? @relation("IllnessFlares", fields: [parentConditionId], references: [id], onDelete: SetNull)
|
||||
flares IllnessEpisode[] @relation("IllnessFlares")
|
||||
dayLogs IllnessDayLog[]
|
||||
documentLinks DocumentConditionLink[]
|
||||
|
||||
@@index([userId, deletedAt])
|
||||
@@index([userId, onsetAt])
|
||||
@@ -5693,6 +5711,9 @@ enum InboundDocumentKind {
|
||||
LAB_RESULT
|
||||
IMAGING
|
||||
PRESCRIPTION
|
||||
/// Überweisung / referral letter — a common German artefact with a clean
|
||||
/// LOINC (57133-1) / ePA (ANF) mapping. Added with the document vault.
|
||||
REFERRAL
|
||||
INSURANCE
|
||||
VACCINATION
|
||||
OTHER
|
||||
@@ -5760,6 +5781,21 @@ model InboundDocument {
|
||||
/// shared with the note columns. The original is sensitive and never
|
||||
/// stored in the clear or logged.
|
||||
contentEncrypted Bytes @map("content_encrypted")
|
||||
/// Lowercase-hex SHA-256 of the PLAINTEXT document bytes, computed at
|
||||
/// upload for same-user duplicate detection. Nullable: rows stored before
|
||||
/// the vault release are not backfilled (only new uploads dedupe). A
|
||||
/// partial unique index (raw SQL in migration 0227, not expressible in
|
||||
/// Prisma) enforces at most one LIVE row per (user_id, content_sha256):
|
||||
/// CREATE UNIQUE INDEX inbound_documents_user_sha_live
|
||||
/// ON inbound_documents (user_id, content_sha256)
|
||||
/// WHERE deleted_at IS NULL AND content_sha256 IS NOT NULL;
|
||||
contentSha256 String? @map("content_sha256") @db.VarChar(64)
|
||||
/// Storage codec of `contentEncrypted`. "base64v1" = legacy string path
|
||||
/// (base64-of-binary → `encrypt()` string → UTF-8 bytes); "binary2" = the
|
||||
/// binary AES-256-GCM layout written by `encryptBytes()` (no base64
|
||||
/// detour). Explicit column, never header-sniffed; the default keeps every
|
||||
/// pre-existing row correct.
|
||||
contentCodec String @default("base64v1") @map("content_codec")
|
||||
status InboundDocumentStatus @default(STORED)
|
||||
/// Provider type that ran the extraction (e.g. "anthropic", "openai",
|
||||
/// "local"). Diagnostic only. Null on a store-only document.
|
||||
@@ -5777,7 +5813,8 @@ model InboundDocument {
|
||||
/// Failure reason when status = FAILED (a short code, never the document).
|
||||
errorReason String? @map("error_reason")
|
||||
|
||||
facts ExtractedFact[]
|
||||
facts ExtractedFact[]
|
||||
conditionLinks DocumentConditionLink[]
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
@@ -5841,3 +5878,25 @@ model ExtractedFact {
|
||||
@@index([userId])
|
||||
@@map("extracted_facts")
|
||||
}
|
||||
|
||||
/// m:n join between a stored document and an illness/condition episode
|
||||
/// (a discharge letter legitimately touches two conditions). Never
|
||||
/// mandatory at upload; both sides cascade so a deleted document or
|
||||
/// episode takes its links with it. `userId` is denormalised for the
|
||||
/// owner-scoped queries the vault list runs.
|
||||
model DocumentConditionLink {
|
||||
id String @id @default(cuid())
|
||||
documentId String @map("document_id")
|
||||
episodeId String @map("episode_id")
|
||||
userId String @map("user_id")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
document InboundDocument @relation(fields: [documentId], references: [id], onDelete: Cascade)
|
||||
episode IllnessEpisode @relation(fields: [episodeId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([documentId, episodeId])
|
||||
@@index([episodeId])
|
||||
@@index([userId])
|
||||
@@map("document_condition_links")
|
||||
}
|
||||
Reference in new issue
Block a user