From d4d04c75f45dffffd09a67eceba02bd9357b2f01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 9 Jul 2026 02:34:06 +0200 Subject: [PATCH] feat(documents): add document_id chat discriminator + verbatim index column (mig 0230) --- .../0230_document_chat/migration.sql | 38 +++++++++++++++++++ prisma/schema.prisma | 29 +++++++++++++- 2 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 prisma/migrations/0230_document_chat/migration.sql diff --git a/prisma/migrations/0230_document_chat/migration.sql b/prisma/migrations/0230_document_chat/migration.sql new file mode 100644 index 000000000..19c08cdd9 --- /dev/null +++ b/prisma/migrations/0230_document_chat/migration.sql @@ -0,0 +1,38 @@ +-- Document vault P4: chat about a document. +-- +-- Two additive, nullable columns — no backfill, applies clean on a 0229 +-- database and on a fresh database: +-- +-- 1. `coach_conversations.document_id` — the discriminator that turns a Coach +-- conversation into a DOCUMENT chat. NULL = a normal Coach conversation +-- (health-record surface). Non-NULL = a chat scoped to exactly one stored +-- document, whose only context is that document's text (no health snapshot, +-- no tools). FK cascades: deleting the document removes its chats. A +-- composite index covers both the Coach rail scan (`document_id IS NULL`, +-- newest-first) and the document sheet scan (`document_id = `, +-- newest-first). +-- +-- 2. `document_content_index.verbatim_text_encrypted` — AES-256-GCM ciphertext +-- of the VERBATIM extracted text (byte-capped, not lowercased/de-accented), +-- stored additionally alongside the normalised `text_encrypted` so a chat +-- can cite the document faithfully. Nullable: rows indexed before P4 carry +-- NULL until re-indexed, and the chat route falls back to `text_encrypted`. + +-- AlterTable +ALTER TABLE "coach_conversations" + ADD COLUMN "document_id" TEXT; + +-- AlterTable +ALTER TABLE "document_content_index" + ADD COLUMN "verbatim_text_encrypted" BYTEA; + +-- CreateIndex — covers the Coach rail (document_id IS NULL) and the document +-- sheet (document_id = ) newest-first scans with one composite. +CREATE INDEX "coach_conversations_user_id_document_id_updated_at_idx" + ON "coach_conversations" ("user_id", "document_id", "updated_at"); + +-- AddForeignKey — a deleted document takes its chats with it. +ALTER TABLE "coach_conversations" + ADD CONSTRAINT "coach_conversations_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 77dfe0df4..cdd222bd4 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -4463,10 +4463,24 @@ model CoachConversation { summaryUpdatedAt DateTime? @map("summary_updated_at") summaryTurnCount Int @default(0) @map("summary_turn_count") - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + // v1.27.33 (Document vault P4 — chat about a document) — the discriminator + // that turns a Coach conversation into a DOCUMENT chat. NULL = a normal Coach + // conversation (health-record surface, snapshot + tools). Non-NULL = a chat + // scoped to exactly one stored document: its only context is that document's + // text, no health snapshot, no tools. The two never mix — the Coach rail + // filters `documentId: null` and the document sheet filters `documentId: `. + // Cascades on document delete so a discarded document takes its chats with it. + documentId String? @map("document_id") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + document InboundDocument? @relation(fields: [documentId], references: [id], onDelete: Cascade) messages CoachMessage[] @@index([userId, updatedAt]) + // v1.27.33 — the document sheet lists a document's chats newest-first; the + // Coach rail lists `documentId IS NULL` newest-first. This composite covers + // both scans without a separate index per surface. + @@index([userId, documentId, updatedAt]) @@map("coach_conversations") } @@ -5847,6 +5861,10 @@ model InboundDocument { /// 1:1 blind content-search index (encrypted extracted text + HMAC token /// array). Absent until the document is indexed; cascades on delete. contentIndex DocumentContentIndex? + /// v1.27.33 (Document vault P4) — "chat about a document" threads. Each is a + /// `CoachConversation` with `documentId` set to this document; cascades on + /// delete so a discarded document takes its chats with it. + chats CoachConversation[] /// share-link memberships (m:n via `ClinicianShareLinkDocument`). /// Cascades: deleting this document removes it from every share it was on. shareLinks ClinicianShareLinkDocument[] @@ -5960,6 +5978,15 @@ model DocumentContentIndex { /// with the Coach columns. The plaintext is recoverable server-side so /// rotation can re-tokenise; the list query never selects it. textEncrypted Bytes @map("text_encrypted") + /// v1.27.33 (Document vault P4 — chat about a document) — AES-256-GCM + /// ciphertext of the VERBATIM extracted text (byte-capped, NOT lowercased or + /// de-accented), stored additionally alongside `textEncrypted` so a document + /// chat can cite the document faithfully (exact casing, section names, units) + /// rather than from the normalised search text. Same `encrypt()`-string-as- + /// UTF-8 Bytes shape as every other PHI column. Nullable: rows indexed before + /// P4 carry NULL until re-indexed, and the chat route falls back to the + /// normalised `textEncrypted` when it is absent. The list query never selects it. + verbatimTextEncrypted Bytes? @map("verbatim_text_encrypted") /// Deduped HMAC-SHA256 (truncated hex) of the normalised tokens — the blind /// index. One-way: opaque without the server-held index subkey. GIN-indexed /// (raw SQL in the migration; Prisma cannot express a GIN index).