diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 07a59a596..fda7193dd 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -9374,6 +9374,34 @@ paths: "401": *a1 "422": *a3 "429": *a4 + /api/documents/inbound/{id}/thumbnail: + get: + tags: + - Documents + summary: View the document preview thumbnail + description: "Decrypts and serves the document's small JPEG preview thumbnail (~320px long edge), rendered in the + background from the original and stored encrypted at rest under `DocumentThumbnail.thumbnailEncrypted`. + Owner-scoped + gated on the opt-in `inboundDocuments` module. Always `Content-Type: image/jpeg` + `nosniff` + + `Cache-Control: private, no-store`; loaded as an `` subresource. 404 when no thumbnail exists yet (freshly + uploaded / still rendering / unsupported type) — the caller shows a placeholder. Fails closed (500) on a decrypt + error — it never returns the ciphertext." + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + "200": + description: The decrypted JPEG preview thumbnail bytes. + content: + image/jpeg: + schema: + type: string + format: binary + "401": *a1 + "422": *a3 + "429": *a4 /api/documents/inbound/{id}/extract: post: tags: @@ -26923,6 +26951,8 @@ components: - local-pdf - local-ocr - type: "null" + hasThumbnail: + type: boolean createdAt: type: string updatedAt: @@ -26945,6 +26975,7 @@ components: - servingClass - hasContentIndex - contentIndexSource + - hasThumbnail - createdAt - updatedAt additionalProperties: false @@ -26955,8 +26986,9 @@ components: download-only (`attachment`); render/download by it, never by MIME guess. `hasContentIndex` is true when the document has a content-search index (auto-indexed on upload); `contentIndexSource` says how that index was produced — `vision` means an AI provider read the original, the other values are provider-free local - extractions, and it is null when `hasContentIndex` is false. Treat an unknown `kind` value as OTHER when - decoding. + extractions, and it is null when `hasContentIndex` is false. `hasThumbnail` is true when a preview thumbnail has + been rendered in the background (fetch it from `/api/documents/inbound/{id}/thumbnail`); false means no preview + yet or an unsupported type — show a placeholder. Treat an unknown `kind` value as OTHER when decoding. DocumentConditionLink: type: object properties: @@ -27242,6 +27274,8 @@ components: - local-pdf - local-ocr - type: "null" + hasThumbnail: + type: boolean createdAt: type: string updatedAt: @@ -27268,6 +27302,7 @@ components: - servingClass - hasContentIndex - contentIndexSource + - hasThumbnail - createdAt - updatedAt - facts diff --git a/e2e/documents-ai.spec.ts b/e2e/documents-ai.spec.ts index 096aaa98e..37a97ef0f 100644 --- a/e2e/documents-ai.spec.ts +++ b/e2e/documents-ai.spec.ts @@ -250,12 +250,6 @@ test.describe("document vault — AI assist + content search", () => { await expect(card).toBeVisible({ timeout: 10_000 }); await expect(page).toHaveURL(new RegExp(`q=${CONTENT_BODY_WORD}`)); - // The matched card advertises its searchable body. - const searchable = page - .locator('[data-slot="document-card"]', { hasText: "Radiology note" }) - .locator('[data-slot="document-searchable"]'); - await expect(searchable).toBeVisible(); - // Sanity: the word really is absent from the short fields (server-side // confirmation the union — not an ILIKE — did the work). const viaApi = await page.request.get( @@ -365,7 +359,7 @@ test.describe("document vault — AI assist + content search", () => { test("a vision-indexed document surfaces the AI-read provenance", async ({ page, - }, testInfo) => { + }) => { // CONTENT_DOC_ID is seeded with source "vision" (an AI provider read it). // No mocks: the real list/detail GET threads the provenance through. await page.goto(`/documents?doc=${CONTENT_DOC_ID}`); @@ -377,24 +371,6 @@ test.describe("document vault — AI assist + content search", () => { const status = sheet.locator('[data-slot="content-search-status"]'); await expect(status).toHaveAttribute("data-state", "ai-read"); await expect(status).toHaveText(/Read by AI/); - - // The timeline card carries the same AI-read marker — desktop only. The - // vault timeline is virtualized; on the narrow mobile viewport the seeded - // card renders outside the initial virtual window (not in the DOM), so a - // card-level attribute read there tests the virtualizer, not the marker. - // The marker's render logic is viewport-independent and covered by the SSR - // card test + this desktop assertion; mobile provenance is already proven - // by the status pill above. - if (testInfo.project.name !== "chromium-mobile") { - await page.keyboard.press("Escape"); - const marker = page - .locator('[data-slot="document-card"]', { hasText: "Radiology note" }) - .locator('[data-slot="document-searchable"]') - .first(); - await expect(marker).toHaveAttribute("data-source", "ai-read", { - timeout: 20_000, - }); - } }); // ── (f) Text-mode refuses a non-image before any OCR/upload ────────────── diff --git a/messages/de.json b/messages/de.json index 32b001003..36f3e4fd4 100644 --- a/messages/de.json +++ b/messages/de.json @@ -8177,9 +8177,7 @@ "openLabel": "{title} öffnen", "attachmentBadge": "Nur Download", "uploading": "Wird hochgeladen…", - "dismissFailed": "Fehlgeschlagenen Upload ausblenden", - "searchableBadge": "Inhalt durchsuchbar", - "aiReadBadge": "Von KI gelesen" + "dismissFailed": "Fehlgeschlagenen Upload ausblenden" }, "error": { "fileTooLarge": "Diese Datei ist größer als das Limit von {maxSize}.", @@ -8309,7 +8307,6 @@ "readDone": "Die KI hat dein Dokument gelesen." }, "contentIndex": { - "searchHint": "Die Suche durchsucht jetzt auch den Inhalt deiner Dokumente (ganze Wörter).", "indexPrompt": "Dokumentinhalte durchsuchbar machen.", "indexAll": "Alle für Suche indizieren", "indexAllPending": "Wird gestartet…", diff --git a/messages/en.json b/messages/en.json index 04dea0d72..77629fd9f 100644 --- a/messages/en.json +++ b/messages/en.json @@ -8177,9 +8177,7 @@ "openLabel": "Open {title}", "attachmentBadge": "Download only", "uploading": "Uploading…", - "dismissFailed": "Dismiss failed upload", - "searchableBadge": "Contents searchable", - "aiReadBadge": "Read by AI" + "dismissFailed": "Dismiss failed upload" }, "error": { "fileTooLarge": "This file is larger than the {maxSize} limit.", @@ -8309,7 +8307,6 @@ "readDone": "The AI read your document." }, "contentIndex": { - "searchHint": "Search now looks inside your documents too (whole words).", "indexPrompt": "Make document contents searchable.", "indexAll": "Index all for search", "indexAllPending": "Starting…", diff --git a/messages/es.json b/messages/es.json index da4b1fd5a..a603f02b1 100644 --- a/messages/es.json +++ b/messages/es.json @@ -8177,9 +8177,7 @@ "openLabel": "Abrir {title}", "attachmentBadge": "Solo descarga", "uploading": "Subiendo…", - "dismissFailed": "Descartar subida fallida", - "searchableBadge": "Contenido buscable", - "aiReadBadge": "Leído por IA" + "dismissFailed": "Descartar subida fallida" }, "error": { "fileTooLarge": "Este archivo supera el límite de {maxSize}.", @@ -8309,7 +8307,6 @@ "readDone": "La IA leyó tu documento." }, "contentIndex": { - "searchHint": "La búsqueda ahora también mira dentro de tus documentos (palabras completas).", "indexPrompt": "Haz que el contenido de los documentos sea buscable.", "indexAll": "Indexar todo para búsqueda", "indexAllPending": "Iniciando…", diff --git a/messages/fr.json b/messages/fr.json index 545782c31..c7b04c0e5 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -8177,9 +8177,7 @@ "openLabel": "Ouvrir {title}", "attachmentBadge": "Téléchargement uniquement", "uploading": "Téléversement…", - "dismissFailed": "Ignorer le téléversement échoué", - "searchableBadge": "Contenu consultable", - "aiReadBadge": "Lu par l’IA" + "dismissFailed": "Ignorer le téléversement échoué" }, "error": { "fileTooLarge": "Ce fichier dépasse la limite de {maxSize}.", @@ -8309,7 +8307,6 @@ "readDone": "L’IA a lu votre document." }, "contentIndex": { - "searchHint": "La recherche explore désormais aussi le contenu de vos documents (mots entiers).", "indexPrompt": "Rendre le contenu des documents consultable.", "indexAll": "Tout indexer pour la recherche", "indexAllPending": "Démarrage…", diff --git a/messages/it.json b/messages/it.json index 8e5194643..8a7c8e1b9 100644 --- a/messages/it.json +++ b/messages/it.json @@ -8177,9 +8177,7 @@ "openLabel": "Apri {title}", "attachmentBadge": "Solo download", "uploading": "Caricamento…", - "dismissFailed": "Ignora caricamento non riuscito", - "searchableBadge": "Contenuto ricercabile", - "aiReadBadge": "Letto dall’IA" + "dismissFailed": "Ignora caricamento non riuscito" }, "error": { "fileTooLarge": "Questo file supera il limite di {maxSize}.", @@ -8309,7 +8307,6 @@ "readDone": "L’IA ha letto il tuo documento." }, "contentIndex": { - "searchHint": "La ricerca ora esamina anche il contenuto dei tuoi documenti (parole intere).", "indexPrompt": "Rendi ricercabile il contenuto dei documenti.", "indexAll": "Indicizza tutto per la ricerca", "indexAllPending": "Avvio…", diff --git a/messages/pl.json b/messages/pl.json index 26917e36e..e43a2114d 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -8177,9 +8177,7 @@ "openLabel": "Otwórz {title}", "attachmentBadge": "Tylko do pobrania", "uploading": "Przesyłanie…", - "dismissFailed": "Odrzuć nieudane przesyłanie", - "searchableBadge": "Treść przeszukiwalna", - "aiReadBadge": "Odczytane przez AI" + "dismissFailed": "Odrzuć nieudane przesyłanie" }, "error": { "fileTooLarge": "Ten plik przekracza limit {maxSize}.", @@ -8309,7 +8307,6 @@ "readDone": "AI odczytało Twój dokument." }, "contentIndex": { - "searchHint": "Wyszukiwanie przeszukuje teraz także treść Twoich dokumentów (całe słowa).", "indexPrompt": "Udostępnij treść dokumentów do wyszukiwania.", "indexAll": "Zindeksuj wszystko do wyszukiwania", "indexAllPending": "Uruchamianie…", diff --git a/prisma/migrations/0236_document_thumbnails/migration.sql b/prisma/migrations/0236_document_thumbnails/migration.sql new file mode 100644 index 000000000..e7924eb52 --- /dev/null +++ b/prisma/migrations/0236_document_thumbnails/migration.sql @@ -0,0 +1,50 @@ +-- Document vault: encrypted preview thumbnails. +-- +-- `document_thumbnails` — a 1:1 sibling of `inbound_documents` holding a small +-- JPEG preview (~320px long edge, q70) rendered in the background from the +-- original: images are decoded + downscaled, a PDF's first page is rasterised. +-- The blob rides `thumbnail_encrypted` in the `encrypt()`-string-as-UTF-8 +-- BYTEA shape (base64 of the JPEG) `document_content_index.text_encrypted` +-- uses — a scanned medical preview is PHI, never stored in the clear or +-- logged. The canvas re-encode strips EXIF/GPS from the source, so the +-- preview is metadata-free by construction. +-- +-- A SIDE TABLE, not a column on `inbound_documents`, so the list/detail +-- queries that `omit` the original blob never drag this blob into the SELECT. +-- +-- Both foreign keys cascade: a deleted document (or user) takes its thumbnail +-- with it. Additive; applies clean on a 0234 database and on a fresh database. + +-- CreateTable +CREATE TABLE "document_thumbnails" ( + "id" TEXT NOT NULL, + "document_id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "thumbnail_encrypted" BYTEA NOT NULL, + "width" INTEGER NOT NULL, + "height" INTEGER NOT NULL, + "byte_size" INTEGER NOT NULL, + "source_mime" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "document_thumbnails_pkey" PRIMARY KEY ("id") +); + +-- 1:1 with the document. +CREATE UNIQUE INDEX "document_thumbnails_document_id_key" + ON "document_thumbnails" ("document_id"); + +-- Owner-scoped queries (the backfill discovery + list-page existence probe). +CREATE INDEX "document_thumbnails_user_id_idx" + ON "document_thumbnails" ("user_id"); + +ALTER TABLE "document_thumbnails" + ADD CONSTRAINT "document_thumbnails_document_id_fkey" + FOREIGN KEY ("document_id") REFERENCES "inbound_documents" ("id") + ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "document_thumbnails" + ADD CONSTRAINT "document_thumbnails_user_id_fkey" + FOREIGN KEY ("user_id") REFERENCES "users" ("id") + ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d3a51cf52..f99342f2e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -807,6 +807,7 @@ model User { extractedFacts ExtractedFact[] documentConditionLinks DocumentConditionLink[] documentContentIndexes DocumentContentIndex[] + documentThumbnails DocumentThumbnail[] // 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. @@ -5928,6 +5929,11 @@ 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? + /// 1:1 encrypted preview thumbnail (small JPEG, AES-256-GCM at rest). + /// Absent until the background thumbnail job runs; a side table (not a + /// column) so the list/detail `omit: { contentEncrypted }` queries never + /// drag the blob into the SELECT. Cascades on delete. + thumbnail DocumentThumbnail? /// 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. @@ -6079,3 +6085,40 @@ model DocumentContentIndex { @@index([userId]) @@map("document_content_index") } + +/// 1:1 encrypted preview thumbnail for a stored document. A small JPEG +/// (~320px long edge, q70) rendered in the background from the original — +/// images are decoded + downscaled, a PDF's first page is rasterised — then +/// stored encrypted at rest. Held in a side table (not a column on +/// `InboundDocument`) so the list/detail queries that `omit` the original +/// blob never drag this blob either. The re-encode strips EXIF/GPS from the +/// source, so the preview is metadata-free by construction. Owner-scoped +/// serve route only; cascades on delete. +model DocumentThumbnail { + id String @id @default(cuid()) + documentId String @unique @map("document_id") + userId String @map("user_id") + /// AES-256-GCM ciphertext of the JPEG preview, stored as the + /// `encrypt()`-string-as-UTF-8 Bytes shape (base64 of the JPEG), the same + /// codec `DocumentContentIndex.textEncrypted` uses so the standard + /// key-rotation walk covers it. A scanned medical preview is PHI — never + /// stored in the clear or logged. The list query never selects it. + thumbnailEncrypted Bytes @map("thumbnail_encrypted") + /// Decoded thumbnail dimensions (px) — diagnostic + lets a future consumer + /// size the tile without decoding the blob. + width Int + height Int + /// Plaintext byte length of the JPEG (pre-encryption), for `Content-Length`. + byteSize Int @map("byte_size") + /// Source MIME the preview was rendered from (diagnostic only). + sourceMime String @map("source_mime") + + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + document InboundDocument @relation(fields: [documentId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId]) + @@map("document_thumbnails") +} diff --git a/scripts/rotate-encryption-key.ts b/scripts/rotate-encryption-key.ts index 8d190ad96..1f5fbfbae 100644 --- a/scripts/rotate-encryption-key.ts +++ b/scripts/rotate-encryption-key.ts @@ -614,6 +614,19 @@ async function main() { results.push(result); } + // ───── Document preview thumbnails (Bytes column) ───── + // The small JPEG preview, stored as the `encrypt()`-string-as-UTF-8 Bytes + // shape (base64 of the JPEG) — the same codec as the content index — so the + // shared Bytes walk re-encrypts it under the active key. NOT NULL, so every + // thumbnail row rotates. + results.push( + await rotateBytesColumn( + "DocumentThumbnail", + "thumbnailEncrypted", + prisma.documentThumbnail, + ), + ); + console.log("\n=== Rotation summary ==="); let totalRotated = 0; let totalErrors = 0; diff --git a/src/app/api/documents/inbound/[id]/__tests__/route.test.ts b/src/app/api/documents/inbound/[id]/__tests__/route.test.ts index 3c5a5d443..ecd92e89e 100644 --- a/src/app/api/documents/inbound/[id]/__tests__/route.test.ts +++ b/src/app/api/documents/inbound/[id]/__tests__/route.test.ts @@ -25,6 +25,9 @@ vi.mock("@/lib/db", () => ({ documentContentIndex: { findUnique: vi.fn(), }, + documentThumbnail: { + findUnique: vi.fn(), + }, illnessEpisode: { findMany: vi.fn(), }, @@ -137,6 +140,9 @@ beforeEach(() => { vi.mocked(prisma.documentContentIndex.findUnique).mockResolvedValue( null as never, ); + vi.mocked(prisma.documentThumbnail.findUnique).mockResolvedValue( + null as never, + ); }); describe("PATCH /api/documents/inbound/[id]", () => { diff --git a/src/app/api/documents/inbound/[id]/route.ts b/src/app/api/documents/inbound/[id]/route.ts index 2edea326d..5f94f8b06 100644 --- a/src/app/api/documents/inbound/[id]/route.ts +++ b/src/app/api/documents/inbound/[id]/route.ts @@ -79,12 +79,16 @@ export const GET = apiHandler( }); } - const [links, contentIndex] = await Promise.all([ + const [links, contentIndex, thumbnail] = await Promise.all([ loadConditionLinks(user.id, [document.id]), prisma.documentContentIndex.findUnique({ where: { documentId: document.id }, select: { source: true }, }), + prisma.documentThumbnail.findUnique({ + where: { documentId: document.id }, + select: { id: true }, + }), ]); annotate({ @@ -99,6 +103,7 @@ export const GET = apiHandler( links.get(document.id) ?? [], contentIndex !== null, toContentIndexSource(contentIndex?.source), + thumbnail !== null, ), ); }, @@ -177,12 +182,16 @@ export const PATCH = apiHandler( omit: { contentEncrypted: true }, include: { facts: { orderBy: { createdAt: "asc" } } }, }); - const [links, contentIndex] = await Promise.all([ + const [links, contentIndex, thumbnail] = await Promise.all([ loadConditionLinks(user.id, [document.id]), prisma.documentContentIndex.findUnique({ where: { documentId: document.id }, select: { source: true }, }), + prisma.documentThumbnail.findUnique({ + where: { documentId: document.id }, + select: { id: true }, + }), ]); const touched = [ @@ -211,6 +220,7 @@ export const PATCH = apiHandler( links.get(document.id) ?? [], contentIndex !== null, toContentIndexSource(contentIndex?.source), + thumbnail !== null, ), ); }, diff --git a/src/app/api/documents/inbound/[id]/thumbnail/__tests__/route.test.ts b/src/app/api/documents/inbound/[id]/thumbnail/__tests__/route.test.ts new file mode 100644 index 000000000..5d31b1102 --- /dev/null +++ b/src/app/api/documents/inbound/[id]/thumbnail/__tests__/route.test.ts @@ -0,0 +1,168 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Buffer } from "node:buffer"; +import { NextRequest } from "next/server"; + +/** + * `GET /api/documents/inbound/[id]/thumbnail`. + * + * The route decrypts and serves the small JPEG preview. Load-bearing behaviour + * under test: it is owner-scoped (the query narrows on the session userId, so + * another user's document is a 404), a document with no thumbnail row is a 404, + * a hit serves image/jpeg + nosniff + private no-store, and it fails closed on + * a decrypt error. + */ + +// A reversible crypto stub so encryptThumbnail / decryptThumbnail round-trip +// through the shared string codec without real keys. The thumbnail helpers go +// through the bytes-codec (encryptToBytes / decryptFromBytes), which wraps +// encrypt() / decrypt() — stub those. +const decryptMock = vi.fn((s: string) => s.replace(/^v1\./, "")); +vi.mock("@/lib/crypto", () => ({ + encrypt: (s: string) => `v1.${s}`, + decrypt: (s: string) => decryptMock(s), + encryptBytes: (b: Buffer) => b, + decryptBytes: (b: Buffer) => b, +})); + +vi.mock("@/lib/db", () => ({ + prisma: { + inboundDocument: { findFirst: vi.fn() }, + }, +})); + +vi.mock("@/lib/modules/gate", async (importOriginal) => ({ + ...(await importOriginal()), + requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }), + resolveModuleMap: vi.fn().mockResolvedValue({}), +})); + +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi + .fn() + .mockResolvedValue({ allowed: true, remaining: 2999, resetAt: Date.now() }), + rateLimitHeaders: () => ({}), +})); + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { GET } from "../route"; +import { getSession } from "@/lib/auth/session"; +import { prisma } from "@/lib/db"; +import { encryptThumbnail } from "@/lib/documents/store"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { + id: "user-1", + username: "testuser", + role: "USER" as const, + locale: "en", + }, +}; + +type Ctx = { params: Promise<{ id: string }> }; +const callGet = GET as unknown as ( + req: NextRequest, + ctx: Ctx, +) => Promise; + +function makeReq(id: string): NextRequest { + return new NextRequest( + new URL(`http://localhost/api/documents/inbound/${id}/thumbnail`), + ); +} +function ctx(id: string): Ctx { + return { params: Promise.resolve({ id }) }; +} + +const JPEG_BYTES = Buffer.from([0xff, 0xd8, 0xff, 0x00, 0x11, 0x22]); + +beforeEach(() => { + vi.clearAllMocks(); + decryptMock.mockImplementation((s: string) => s.replace(/^v1\./, "")); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); +}); + +describe("GET /api/documents/inbound/[id]/thumbnail", () => { + it("serves the decrypted JPEG with nosniff + private no-store, owner-scoped", async () => { + vi.mocked(prisma.inboundDocument.findFirst).mockResolvedValue({ + id: "doc-1", + thumbnail: { + thumbnailEncrypted: encryptThumbnail(JPEG_BYTES), + byteSize: JPEG_BYTES.byteLength, + }, + } as never); + + const res = await callGet(makeReq("doc-1"), ctx("doc-1")); + + expect(res.status).toBe(200); + expect(res.headers.get("Content-Type")).toBe("image/jpeg"); + expect(res.headers.get("Content-Disposition")).toBe("inline"); + expect(res.headers.get("X-Content-Type-Options")).toBe("nosniff"); + expect(res.headers.get("Cache-Control")).toBe("private, no-store"); + const out = Buffer.from(await res.arrayBuffer()); + expect(out.equals(JPEG_BYTES)).toBe(true); + + // The query is narrowed on the session userId + live rows only. + const where = vi.mocked(prisma.inboundDocument.findFirst).mock.calls[0][0] + ?.where as { id: string; userId: string; deletedAt: null }; + expect(where.userId).toBe("user-1"); + expect(where.id).toBe("doc-1"); + expect(where.deletedAt).toBeNull(); + }); + + it("404s when the document has no thumbnail yet", async () => { + vi.mocked(prisma.inboundDocument.findFirst).mockResolvedValue({ + id: "doc-2", + thumbnail: null, + } as never); + + const res = await callGet(makeReq("doc-2"), ctx("doc-2")); + expect(res.status).toBe(404); + }); + + it("404s for another user's document (the userId narrows it out)", async () => { + vi.mocked(prisma.inboundDocument.findFirst).mockResolvedValue( + null as never, + ); + + const res = await callGet(makeReq("other-doc"), ctx("other-doc")); + expect(res.status).toBe(404); + const body = (await res.json()) as { error: string }; + expect(body.error).toBeTruthy(); + }); + + it("fails closed (500) on a decrypt error — never serves ciphertext", async () => { + vi.mocked(prisma.inboundDocument.findFirst).mockResolvedValue({ + id: "doc-3", + thumbnail: { + thumbnailEncrypted: encryptThumbnail(JPEG_BYTES), + byteSize: JPEG_BYTES.byteLength, + }, + } as never); + decryptMock.mockImplementation(() => { + throw new Error("unknown key id"); + }); + + const res = await callGet(makeReq("doc-3"), ctx("doc-3")); + expect(res.status).toBe(500); + expect(res.headers.get("Content-Type")).toContain("application/json"); + const body = (await res.json()) as { error: string }; + expect(body.error).toBeTruthy(); + }); +}); diff --git a/src/app/api/documents/inbound/[id]/thumbnail/route.ts b/src/app/api/documents/inbound/[id]/thumbnail/route.ts new file mode 100644 index 000000000..4c0a590e8 --- /dev/null +++ b/src/app/api/documents/inbound/[id]/thumbnail/route.ts @@ -0,0 +1,120 @@ +/** + * Document vault: serve a document's encrypted preview thumbnail. + * + * The thumbnail is a small JPEG (~320px long edge) rendered in the background + * from the original and stored encrypted at rest + * (`DocumentThumbnail.thumbnailEncrypted`, AES-256-GCM). This route is the ONLY + * path that decrypts and serves it. Owner-scoped + module-gated; the bytes are + * PHI: never logged, never served cross-user, fail-closed on a decrypt error. + * + * Serving posture is fixed and singular: we only ever store JPEG, so the + * response is always `Content-Type: image/jpeg` + `nosniff` — zero + * misclassification surface, no `attachment` branch. It is loaded as an `` + * subresource on the vault page (governed by the page CSP `img-src 'self'`), so + * it needs NO proxy carve-out — unlike `/original`, which is framed. + * + * A cache MISS (no thumbnail row yet — freshly uploaded, still rendering, or an + * unsupported type) is a 404; the card falls back to its kind icon. The route + * never generates synchronously, preserving the async never-block contract. + */ +import { NextResponse } from "next/server"; + +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiError } from "@/lib/api-response"; +import { prisma } from "@/lib/db"; +import { decryptThumbnail } from "@/lib/documents/store"; +import { annotate } from "@/lib/logging/context"; +import { requireModuleEnabled } from "@/lib/modules/gate"; +import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; + +type RouteParams = { params: Promise<{ id: string }> }; + +export const dynamic = "force-dynamic"; + +/** + * Decrypting a thumbnail is cheap CPU work on PHI, but a virtualized grid + * legitimately fires many at once — so the per-user ceiling is higher than + * `/original`'s. It is a self-DoS backstop only (the route is owner-scoped, so + * there is no cross-user abuse vector). + */ +const THUMBNAIL_READ_LIMIT_PER_HOUR = 3000; +const THUMBNAIL_READ_WINDOW_MS = 60 * 60 * 1000; + +export const GET = apiHandler( + async (_request: Request, { params }: RouteParams) => { + const { user } = await requireAuth(); + + const gate = await requireModuleEnabled(user.id, "inboundDocuments"); + if (!gate.enabled) return gate.response; + + const rl = await checkRateLimit( + `documents-thumbnail:${user.id}`, + THUMBNAIL_READ_LIMIT_PER_HOUR, + THUMBNAIL_READ_WINDOW_MS, + ); + if (!rl.allowed) { + const response = apiError("Too many requests. Try again later.", 429, { + errorCode: "documents.inbound.rateLimited", + }); + for (const [k, v] of Object.entries(rateLimitHeaders(rl))) { + response.headers.set(k, v); + } + return response; + } + + const { id } = await params; + // Owner-scoped: the userId narrows the document so a cuid guess cannot + // reach another user's thumbnail. A cookie session and a Bearer token both + // resolve through requireAuth(); neither can cross the userId boundary. + // The thumbnail is loaded through the 1:1 relation on the owned document — + // both the document and its thumbnail are userId-scoped. + const document = await prisma.inboundDocument.findFirst({ + where: { id, userId: user.id, deletedAt: null }, + select: { + id: true, + thumbnail: { select: { thumbnailEncrypted: true, byteSize: true } }, + }, + }); + if (!document || !document.thumbnail) { + // Missing document OR no thumbnail yet — both 404; the card shows its + // kind icon. Do not distinguish the two (no existence oracle). + return apiError("Thumbnail not found", 404, { + errorCode: "documents.inbound.notFound", + }); + } + + let bytes: Buffer; + try { + bytes = decryptThumbnail(document.thumbnail.thumbnailEncrypted); + } catch { + // Fail closed — never fall back to the raw ciphertext. The reason (bad / + // missing key id) is logged by the annotation, never the bytes. + annotate({ + action: { name: "documents.inbound.thumbnail.decryptFailed" }, + meta: { documentId: document.id }, + }); + return apiError("Could not read the stored thumbnail", 500, { + errorCode: "documents.inbound.decryptFailed", + }); + } + + annotate({ + action: { name: "documents.inbound.thumbnail.get" }, + meta: { documentId: document.id, bytes: bytes.byteLength }, + }); + + return new NextResponse(bytes as unknown as BodyInit, { + status: 200, + headers: { + // We only ever store JPEG — the type is fixed and authoritative. + "Content-Type": "image/jpeg", + "Content-Length": String(bytes.byteLength), + "Content-Disposition": "inline", + // The type is authoritative — never MIME-sniffed. + "X-Content-Type-Options": "nosniff", + // Private PHI — never cache in a shared / disk cache. + "Cache-Control": "private, no-store", + }, + }); + }, +); diff --git a/src/app/api/documents/inbound/__tests__/route.test.ts b/src/app/api/documents/inbound/__tests__/route.test.ts index 7910c9ba4..6824f6413 100644 --- a/src/app/api/documents/inbound/__tests__/route.test.ts +++ b/src/app/api/documents/inbound/__tests__/route.test.ts @@ -42,6 +42,9 @@ vi.mock("@/lib/db", () => { documentContentIndex: { findMany: vi.fn(), }, + documentThumbnail: { + findMany: vi.fn(), + }, illnessEpisode: { findMany: vi.fn(), }, @@ -66,6 +69,10 @@ vi.mock("@/lib/jobs/document-index", () => ({ enqueueDocumentIndex: vi.fn().mockResolvedValue({ enqueued: true }), })); +vi.mock("@/lib/jobs/document-thumbnail", () => ({ + enqueueDocumentThumbnail: vi.fn().mockResolvedValue({ enqueued: true }), +})); + vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn().mockResolvedValue({ allowed: true, @@ -163,6 +170,7 @@ beforeEach(() => { vi.mocked(prisma.documentConditionLink.findMany).mockResolvedValue( [] as never, ); + vi.mocked(prisma.documentThumbnail.findMany).mockResolvedValue([] as never); vi.mocked(prisma.documentContentIndex.findMany).mockResolvedValue( [] as never, ); diff --git a/src/app/api/documents/inbound/route.ts b/src/app/api/documents/inbound/route.ts index 63cd5cc71..ec2a6e313 100644 --- a/src/app/api/documents/inbound/route.ts +++ b/src/app/api/documents/inbound/route.ts @@ -42,6 +42,7 @@ import { type SerialisableDocument, } from "@/lib/documents/store"; import { enqueueDocumentIndex } from "@/lib/jobs/document-index"; +import { enqueueDocumentThumbnail } from "@/lib/jobs/document-thumbnail"; import { detectDocumentType, resolveDocumentLimits, @@ -371,6 +372,12 @@ async function postUpload(request: Request): Promise { // duplicate upload returns early above and never reaches here. void enqueueDocumentIndex(user.id, document.id); + // Render a preview thumbnail in the background too (pure local compute — no + // egress). Same fire-and-forget contract: the upload never blocks on or fails + // because of it, and a dropped enqueue is recoverable via the boot backfill. + // Only fresh inserts reach here (a duplicate returns early above). + void enqueueDocumentThumbnail(user.id, document.id); + const links = await loadConditionLinks(user.id, [document.id]); return apiSuccess( serialiseDocument( @@ -520,6 +527,17 @@ export const GET = apiHandler(async (request: Request) => { for (const row of indexed) indexSources.set(row.documentId, row.source); } + // Which of the page's documents have a preview thumbnail (gates the card's + // ). One grouped query on the 1:1 side table; never the blob column. + const thumbnailIds = new Set(); + if (page.length > 0) { + const thumbs = await prisma.documentThumbnail.findMany({ + where: { userId: user.id, documentId: { in: page.map((d) => d.id) } }, + select: { documentId: true }, + }); + for (const row of thumbs) thumbnailIds.add(row.documentId); + } + annotate({ action: { name: "documents.inbound.list" }, meta: { @@ -538,6 +556,7 @@ export const GET = apiHandler(async (request: Request) => { linkMap.get(doc.id) ?? [], indexSources.has(doc.id), toContentIndexSource(indexSources.get(doc.id)), + thumbnailIds.has(doc.id), ), ), nextCursor, diff --git a/src/components/documents/__tests__/document-card.test.tsx b/src/components/documents/__tests__/document-card.test.tsx index a76c8b153..29ab71f7d 100644 --- a/src/components/documents/__tests__/document-card.test.tsx +++ b/src/components/documents/__tests__/document-card.test.tsx @@ -33,6 +33,7 @@ function doc(overrides: Partial = {}): InboundDocumentDto { servingClass: "inline", hasContentIndex: false, contentIndexSource: null, + hasThumbnail: false, createdAt: "2025-10-05T08:00:00.000Z", updatedAt: "2025-10-05T08:00:00.000Z", ...overrides, @@ -85,47 +86,33 @@ describe("", () => { expect(html).toContain("befund.docx"); }); - it("marks a content-indexed document as searchable", () => { + it("renders the preview thumbnail image when hasThumbnail is set", () => { const html = render( , ); - expect(html).toContain('data-slot="document-searchable"'); - expect(html).toContain('data-source="local"'); - expect(html).toContain("Contents searchable"); + expect(html).toContain('data-slot="document-thumbnail"'); + expect(html).toContain('src="/api/documents/inbound/doc-1/thumbnail"'); + expect(html).toContain('loading="lazy"'); }); - it("marks an AI-read document with the highlighted read-by-AI badge", () => { + it("shows the kind icon (no thumbnail image) when hasThumbnail is false", () => { const html = render( , ); - expect(html).toContain('data-source="ai-read"'); - expect(html).toContain("Read by AI"); - expect(html).toContain("text-primary"); - }); - - it("shows no searchable marker when the document is not indexed", () => { - const html = render( - , - ); - expect(html).not.toContain('data-slot="document-searchable"'); + expect(html).not.toContain('data-slot="document-thumbnail"'); + expect(html).not.toContain("/thumbnail"); }); it("falls back to the untitled label and rings when highlighted", () => { diff --git a/src/components/documents/__tests__/episode-documents-card.test.tsx b/src/components/documents/__tests__/episode-documents-card.test.tsx index 9d838e54f..500f78a3b 100644 --- a/src/components/documents/__tests__/episode-documents-card.test.tsx +++ b/src/components/documents/__tests__/episode-documents-card.test.tsx @@ -53,6 +53,7 @@ function doc(id: string, title: string): InboundDocumentDto { servingClass: "inline", hasContentIndex: false, contentIndexSource: null, + hasThumbnail: false, createdAt: "2025-10-05T08:00:00.000Z", updatedAt: "2025-10-05T08:00:00.000Z", }; diff --git a/src/components/documents/__tests__/vault-utils.test.ts b/src/components/documents/__tests__/vault-utils.test.ts index 8af11a9c7..c23b70225 100644 --- a/src/components/documents/__tests__/vault-utils.test.ts +++ b/src/components/documents/__tests__/vault-utils.test.ts @@ -34,6 +34,7 @@ function doc(overrides: Partial = {}): InboundDocumentDto { servingClass: "inline", hasContentIndex: false, contentIndexSource: null, + hasThumbnail: false, createdAt: "2026-03-11T08:00:00.000Z", updatedAt: "2026-03-11T08:00:00.000Z", ...overrides, diff --git a/src/components/documents/document-card.tsx b/src/components/documents/document-card.tsx index a74ea0195..4a0657994 100644 --- a/src/components/documents/document-card.tsx +++ b/src/components/documents/document-card.tsx @@ -6,15 +6,17 @@ * with the translated §3.2 reason). All three share the card footprint so an * optimistic entry morphs into the stored row without the grid jumping. * - * Anatomy per the design standards: kind icon `text-foreground size-5`, - * title in foreground (`text-sm font-medium`, truncated), one muted meta - * line (date · size · filename), condition-tag pills, an attachment-class - * badge for download-only formats, and a selection checkbox that appears on - * hover / focus / while selected. The whole card is clickable through an - * invisible overlay button; the checkbox floats above it. + * Anatomy per the design standards: a leading edge that shows a small preview + * thumbnail tile when one has been rendered (`hasThumbnail`) and otherwise the + * kind icon `text-foreground size-5`, title in foreground (`text-sm + * font-medium`, truncated), one muted meta line (date · size · filename), + * condition-tag pills, an attachment-class badge for download-only formats, + * and a selection checkbox that appears on hover / focus / while selected. The + * whole card is clickable through an invisible overlay button; the checkbox + * floats above it. */ -import { Download, ScanSearch, Sparkles, X } from "lucide-react"; -import { useRef } from "react"; +import { Download, X } from "lucide-react"; +import { useRef, useState } from "react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -22,10 +24,7 @@ import { Card, CardContent } from "@/components/ui/card"; import { Checkbox } from "@/components/ui/checkbox"; import { useFormatters, useTranslations } from "@/lib/i18n/context"; import { cn } from "@/lib/utils"; -import { - isAiReadSource, - type InboundDocumentDto, -} from "@/lib/validations/inbound-documents"; +import type { InboundDocumentDto } from "@/lib/validations/inbound-documents"; import { DOCUMENT_KIND_ICONS } from "./document-kind-meta"; import type { UploadQueueItem } from "./use-document-upload"; import { documentDateKey, formatBytes } from "./vault-utils"; @@ -63,6 +62,10 @@ export function DocumentCard({ const { t, locale } = useTranslations(); const format = useFormatters(); + // A preview thumbnail that fails to load (still rendering, decrypt error, + // 404) falls back to the kind icon — never a broken image. + const [thumbFailed, setThumbFailed] = useState(false); + // Touch long-press selects instead of opening; the click that the // browser fires after the release is swallowed once. const longPressTimer = useRef | null>(null); @@ -94,10 +97,35 @@ export function DocumentCard({ >
- + {document.hasThumbnail && !thumbFailed ? ( + // Leading-edge preview tile. `loading="lazy"` + the timeline's + // virtualization means only cards near the viewport fetch their + // thumbnail. Decorative (alt="") — the title beside it names the + // document; an authed same-origin subresource, never cached + // cross-user. onError falls back to the kind icon. + + {/* Authed, private (no-store) same-origin subresource — next/image + would try to proxy/optimize it, which is wrong for a PHI blob + we deliberately never cache. */} + {/* eslint-disable-next-line @next/next/no-img-element */} + setThumbFailed(true)} + className="size-full object-cover" + /> + + ) : ( + + )}

{title}

{document.conditionLinks.length > 0 || - document.servingClass === "attachment" || - document.hasContentIndex ? ( + document.servingClass === "attachment" ? (
{document.conditionLinks.map((link) => ( ) : null} - {document.hasContentIndex ? ( - // Subtle searchable marker — icon-only to keep the row calm; the - // label rides an accessible name. An AI-read document gets the - // highlighted Sparkles (primary) so the richer read is legible at - // a glance; a locally-indexed one keeps the quiet muted glass. - isAiReadSource(document.contentIndexSource) ? ( - - - - {t("documents.card.aiReadBadge")} - - - ) : ( - - - - {t("documents.card.searchableBadge")} - - - ) - ) : null}
) : null} diff --git a/src/components/documents/document-filter-bar.tsx b/src/components/documents/document-filter-bar.tsx index 74312ce90..9febb0244 100644 --- a/src/components/documents/document-filter-bar.tsx +++ b/src/components/documents/document-filter-bar.tsx @@ -10,9 +10,8 @@ * The controls sit on ONE row at every width: the search flexes and can * shrink to nothing while each dropdown trigger stays a fixed compact * control, so the bar never wraps to a second line and never grows a - * horizontal chip scroller. The content-search hint (indexed-content match - * + corpus backfill) rides its own helper line below, only when indexing is - * live. + * horizontal chip scroller. The corpus-backfill prompt rides its own helper + * line below, only while some documents remain un-indexed. * * Purely presentational — the filter state lives in the page URL and is * owned by `documents-view.tsx`; this bar only renders the controls. It @@ -82,7 +81,6 @@ export function DocumentFilterBar({ onToggleYear, activeCount, onClearAll, - contentSearchActive = false, showIndexAll = false, indexAllPending = false, onIndexAll, @@ -101,8 +99,6 @@ export function DocumentFilterBar({ onToggleYear: (year: number) => void; activeCount: number; onClearAll: () => void; - /** Search already matches indexed document CONTENT (whole words). */ - contentSearchActive?: boolean; /** Some documents are not yet indexed — offer the corpus backfill. */ showIndexAll?: boolean; indexAllPending?: boolean; @@ -323,32 +319,28 @@ export function DocumentFilterBar({ ) : null}
- {contentSearchActive || showIndexAll ? ( + {showIndexAll ? (
- {contentSearchActive - ? t("documents.contentIndex.searchHint") - : t("documents.contentIndex.indexPrompt")} + {t("documents.contentIndex.indexPrompt")} - {showIndexAll ? ( - - ) : null} +
) : null} diff --git a/src/components/documents/documents-view.tsx b/src/components/documents/documents-view.tsx index 11cdf20a9..7fa6fa114 100644 --- a/src/components/documents/documents-view.tsx +++ b/src/components/documents/documents-view.tsx @@ -257,8 +257,6 @@ export function DocumentsView() { contentIndex !== undefined && contentIndex.totalCount > 0 && contentIndex.indexedCount < contentIndex.totalCount; - const contentSearchActive = - canIndexContent && (contentIndex?.indexedCount ?? 0) > 0; const handleIndexAll = useCallback(() => { reindexAll.mutate(undefined, { onSuccess: (result) => @@ -586,7 +584,6 @@ export function DocumentsView() { onToggleYear={toggleYear} activeCount={activeCount} onClearAll={clearFilters} - contentSearchActive={contentSearchActive} showIndexAll={showIndexAll} indexAllPending={reindexAll.isPending} onIndexAll={handleIndexAll} diff --git a/src/lib/crypto/encrypted-columns.ts b/src/lib/crypto/encrypted-columns.ts index c05ecd21c..b56d46bb0 100644 --- a/src/lib/crypto/encrypted-columns.ts +++ b/src/lib/crypto/encrypted-columns.ts @@ -229,6 +229,11 @@ export const ENCRYPTED_COLUMNS: readonly EncryptedColumn[] = [ field: "verbatimTextEncrypted", kind: "bytes", }, + // Document vault — the small JPEG preview thumbnail. Stored as the + // `encrypt()`-string-as-UTF-8 Bytes shape (base64 of the JPEG), the same + // codec `DocumentContentIndex.textEncrypted` uses. A scanned medical preview + // is PHI; rotation re-encrypts it on the standard Bytes walk. + { model: "DocumentThumbnail", field: "thumbnailEncrypted", kind: "bytes" }, ] as const; /** Stable `Model.field` key for a registry entry. */ diff --git a/src/lib/documents/__tests__/rasterize-pdf.test.ts b/src/lib/documents/__tests__/rasterize-pdf.test.ts index 547661932..6d8fb89e7 100644 --- a/src/lib/documents/__tests__/rasterize-pdf.test.ts +++ b/src/lib/documents/__tests__/rasterize-pdf.test.ts @@ -92,6 +92,13 @@ describe("rasterizePdf", () => { expect(result.images).toHaveLength(RASTER_MAX_PAGES); }); + it("honours a caller maxPages bound (thumbnail path renders page 1 only)", async () => { + const result = await rasterizePdf(buildPdf(5, true), 1); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.images).toHaveLength(1); + }); + it("returns { ok: false } and never throws on malformed input", async () => { await expect( rasterizePdf(Buffer.from("this is not a pdf")), diff --git a/src/lib/documents/__tests__/thumbnail.test.ts b/src/lib/documents/__tests__/thumbnail.test.ts new file mode 100644 index 000000000..cce047a32 --- /dev/null +++ b/src/lib/documents/__tests__/thumbnail.test.ts @@ -0,0 +1,129 @@ +import { Buffer } from "node:buffer"; + +import { createCanvas } from "@napi-rs/canvas"; +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); + +import { generateThumbnail, THUMB_LONG_EDGE } from "../thumbnail"; + +/** + * Preview-thumbnail generation downscales an image (or a PDF's first page) to a + * small JPEG, bounded to `THUMB_LONG_EDGE` on the long edge, and NEVER throws: + * an unsupported MIME or malformed bytes resolve to `{ ok: false }` so the card + * falls back to its kind icon. The re-encode always emits a fresh JPEG (EXIF/GPS + * stripped by construction). + */ + +/** A solid-colour source image at the given size, encoded to the given type. */ +function sourceImage( + width: number, + height: number, + mime: "image/jpeg" | "image/png" | "image/webp", +): Buffer { + const canvas = createCanvas(width, height); + const ctx = canvas.getContext("2d"); + ctx.fillStyle = "#3366cc"; + ctx.fillRect(0, 0, width, height); + if (mime === "image/png") return canvas.toBuffer("image/png"); + if (mime === "image/webp") return canvas.toBuffer("image/webp"); + return canvas.toBuffer("image/jpeg", 90); +} + +/** JPEG magic bytes. */ +function isJpeg(buf: Buffer): boolean { + return buf.subarray(0, 2).toString("hex") === "ffd8"; +} + +describe("generateThumbnail", () => { + it("downscales a large JPEG to a bounded JPEG preview", async () => { + const result = await generateThumbnail( + sourceImage(1200, 800, "image/jpeg"), + "image/jpeg", + ); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(isJpeg(result.thumbnail.jpeg)).toBe(true); + expect(Math.max(result.thumbnail.width, result.thumbnail.height)).toBe( + THUMB_LONG_EDGE, + ); + // Landscape source keeps its aspect ratio (320 × 213). + expect(result.thumbnail.width).toBe(THUMB_LONG_EDGE); + expect(result.thumbnail.height).toBe(213); + }); + + it("renders a PNG source to a JPEG preview", async () => { + const result = await generateThumbnail( + sourceImage(640, 640, "image/png"), + "image/png", + ); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(isJpeg(result.thumbnail.jpeg)).toBe(true); + expect(result.thumbnail.width).toBe(THUMB_LONG_EDGE); + expect(result.thumbnail.height).toBe(THUMB_LONG_EDGE); + }); + + it("never upscales a small source", async () => { + const result = await generateThumbnail( + sourceImage(100, 60, "image/jpeg"), + "image/jpeg", + ); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.thumbnail.width).toBe(100); + expect(result.thumbnail.height).toBe(60); + }); + + it("renders a PDF's first page to a JPEG preview", async () => { + // A tiny hand-assembled one-page graphics PDF (no text layer needed). + const content = "0 0 1 rg 20 20 160 160 re f"; + const bodies: string[] = []; + bodies[1] = "<< /Type /Catalog /Pages 2 0 R >>"; + bodies[2] = "<< /Type /Pages /Kids [3 0 R] /Count 1 >>"; + bodies[3] = + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R >>"; + bodies[4] = `<< /Length ${Buffer.byteLength(content, "latin1")} >>\nstream\n${content}\nendstream`; + let out = "%PDF-1.4\n"; + const offsets: number[] = []; + for (let i = 1; i <= 4; i++) { + offsets[i] = Buffer.byteLength(out, "latin1"); + out += `${i} 0 obj\n${bodies[i]}\nendobj\n`; + } + const xrefOffset = Buffer.byteLength(out, "latin1"); + out += "xref\n0 5\n0000000000 65535 f \n"; + for (let i = 1; i <= 4; i++) { + out += `${String(offsets[i]).padStart(10, "0")} 00000 n \n`; + } + out += `trailer\n<< /Size 5 /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF`; + + const result = await generateThumbnail( + Buffer.from(out, "latin1"), + "application/pdf", + ); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(isJpeg(result.thumbnail.jpeg)).toBe(true); + expect( + Math.max(result.thumbnail.width, result.thumbnail.height), + ).toBeLessThanOrEqual(THUMB_LONG_EDGE); + }); + + it("returns { ok: false } for an unsupported MIME type", async () => { + await expect( + generateThumbnail(Buffer.from("plain text"), "text/plain"), + ).resolves.toEqual({ ok: false }); + }); + + it("returns { ok: false } and never throws on malformed image bytes", async () => { + await expect( + generateThumbnail(Buffer.from("not a real image"), "image/png"), + ).resolves.toEqual({ ok: false }); + }); + + it("returns { ok: false } on a malformed PDF", async () => { + await expect( + generateThumbnail(Buffer.from("not a pdf"), "application/pdf"), + ).resolves.toEqual({ ok: false }); + }); +}); diff --git a/src/lib/documents/rasterize-pdf.ts b/src/lib/documents/rasterize-pdf.ts index b1ae43765..4ee3f164a 100644 --- a/src/lib/documents/rasterize-pdf.ts +++ b/src/lib/documents/rasterize-pdf.ts @@ -112,12 +112,16 @@ interface PdfjsModule { } /** - * Render the first `RASTER_MAX_PAGES` pages of a PDF to bounded JPEG images. - * Returns `{ ok: false }` on any failure (malformed PDF, render throw, missing - * binary, or zero renderable pages) — the caller degrades to local text or - * un-indexed. Never throws. + * Render the first `RASTER_MAX_PAGES` pages of a PDF to bounded JPEG images + * (or fewer when `maxPages` is passed — the thumbnail path renders page 1 + * only). Returns `{ ok: false }` on any failure (malformed PDF, render throw, + * missing binary, or zero renderable pages) — the caller degrades to local + * text or un-indexed. Never throws. */ -export async function rasterizePdf(buffer: Buffer): Promise { +export async function rasterizePdf( + buffer: Buffer, + maxPages: number = RASTER_MAX_PAGES, +): Promise { let doc: PdfDocument | null = null; try { // Lazy import: keeps pdfjs (DOMMatrix) + @napi-rs/canvas out of the eager @@ -133,7 +137,9 @@ export async function rasterizePdf(buffer: Buffer): Promise { }); doc = await task.promise; - const pageCount = Math.min(doc.numPages, RASTER_MAX_PAGES); + // Never exceed the standing DoS ceiling, even if a caller asks for more. + const cap = Math.min(Math.max(1, maxPages), RASTER_MAX_PAGES); + const pageCount = Math.min(doc.numPages, cap); const images: RasterImage[] = []; for (let pageNumber = 1; pageNumber <= pageCount; pageNumber++) { diff --git a/src/lib/documents/store.ts b/src/lib/documents/store.ts index 7e6d38a00..8ba2e575e 100644 --- a/src/lib/documents/store.ts +++ b/src/lib/documents/store.ts @@ -91,6 +91,28 @@ export function decryptDocumentContent(buf: Uint8Array, codec: string): Buffer { throw new Error(`Unknown document content codec '${codec}'`); } +/** + * Encrypt a JPEG preview thumbnail into the `Bytes` payload the schema stores + * (`DocumentThumbnail.thumbnailEncrypted`). Uses the shared AES-256-GCM string + * codec (base64 of the binary → `encrypt()` string → UTF-8 bytes), the same + * `encrypt()`-string-as-bytes shape `DocumentContentIndex.textEncrypted` uses — + * so the standard key-rotation walk (`rotateBytesColumn`) and corpus scan cover + * it with no binary-codec special-casing. A scanned medical preview is PHI, + * same posture (versioned key id, fail-closed decrypt) as every other column. + */ +export function encryptThumbnail(jpeg: Buffer): Uint8Array { + return encryptToBytes(jpeg.toString("base64")); +} + +/** + * Decrypt a stored thumbnail's `Bytes` payload back to the JPEG. Fail-closed: + * a bad / missing key id throws — the serve route treats a throw as "cannot + * serve" and never falls back to the ciphertext. + */ +export function decryptThumbnail(buf: Uint8Array): Buffer { + return Buffer.from(decryptFromBytes(buf), "base64"); +} + /** * Encrypt a staged fact's FHIR-staged payload into the `Bytes` column the * schema stores. The structured clinical values (diagnosis text, lab values, @@ -137,6 +159,7 @@ export function serialiseDocument( conditionLinks: DocumentConditionLinkDto[] = [], hasContentIndex = false, contentIndexSource: DocumentContentIndexSourceValue | null = null, + hasThumbnail = false, ): InboundDocumentDto { return { id: doc.id, @@ -160,6 +183,7 @@ export function serialiseDocument( servingClass: servingClassFor(doc.mimeType), hasContentIndex, contentIndexSource: hasContentIndex ? contentIndexSource : null, + hasThumbnail, createdAt: doc.createdAt.toISOString(), updatedAt: doc.updatedAt.toISOString(), }; @@ -187,6 +211,7 @@ export function serialiseDocumentDetail( conditionLinks: DocumentConditionLinkDto[] = [], hasContentIndex = false, contentIndexSource: DocumentContentIndexSourceValue | null = null, + hasThumbnail = false, ): InboundDocumentDetailDto { const pendingCount = facts.filter((f) => f.status === "PENDING").length; // `factCount` excludes REJECTED facts (a rejected fact is discarded, not part @@ -199,6 +224,7 @@ export function serialiseDocumentDetail( conditionLinks, hasContentIndex, contentIndexSource, + hasThumbnail, ), facts: facts.map(serialiseFact), }; diff --git a/src/lib/documents/thumbnail.ts b/src/lib/documents/thumbnail.ts new file mode 100644 index 000000000..b06aeb523 --- /dev/null +++ b/src/lib/documents/thumbnail.ts @@ -0,0 +1,161 @@ +/** + * Server-side preview-thumbnail generation for the document vault. + * + * Turns a stored original into a small JPEG the vault card renders at its + * leading edge: an image is decoded + downscaled; a PDF's FIRST page is + * rasterised (via `rasterizePdf`, bounded to one page) then downscaled; every + * other MIME type has no preview (the card keeps its kind icon). + * + * `@napi-rs/canvas` + pdfjs are pulled LAZILY via runtime `import()` — pdfjs's + * module top-level references the browser-only `DOMMatrix` global, so a STATIC + * import would make the Turbopack server chunk evaluate that reference at + * instantiation and throw `DOMMatrix is not defined`, taking down every + * route/worker sharing the chunk. The lazy import (the exact pattern + * `rasterize-pdf.ts` documents) keeps both out of the eager chunk. Rendering + * is pure local compute — no network, nothing written to disk. + * + * Security: the thumbnail is produced by decode → draw onto a FRESH canvas → + * `toBuffer("image/jpeg")`. A canvas re-encode emits a brand-new JPEG carrying + * NO source metadata (EXIF, GPS, orientation, maker notes are all dropped), so + * the preview is metadata-free by construction — a defence-in-depth bonus over + * the original blob. + * + * NEVER throws: a malformed / unrenderable image or PDF, a missing native + * binary, or any render error resolves to `{ ok: false }`, and the caller + * leaves the document without a thumbnail (the card falls back to its kind + * icon). This preserves the "a bad document never aborts an upload/backfill" + * contract. + */ +import { Buffer } from "node:buffer"; + +import { rasterizePdf } from "@/lib/documents/rasterize-pdf"; +import { annotate } from "@/lib/logging/context"; + +/** Longest-edge pixel cap for the preview — a legible card tile at ~10-25 KB. */ +export const THUMB_LONG_EDGE = 320; + +/** JPEG quality (0-100). q70 keeps the preview crisp at a small byte cost. */ +export const THUMB_QUALITY = 70; + +/** Image MIME types the canvas decoder can downscale directly. */ +const THUMBNAILABLE_IMAGE_MIMES = new Set([ + "image/jpeg", + "image/png", + "image/webp", + "image/gif", +]); + +/** The generated preview plus its decoded dimensions (for `Content-Length`). */ +export interface Thumbnail { + jpeg: Buffer; + width: number; + height: number; +} + +/** Best-effort generation outcome — never an exception. */ +export type ThumbnailResult = + { ok: true; thumbnail: Thumbnail } | { ok: false }; + +// Minimal structural types for the slice of `@napi-rs/canvas` this module uses. +// A type-only shape (erased at build) so we never eagerly evaluate the module +// for its types; the real module is pulled at runtime via `import()`. +interface CanvasImage { + width: number; + height: number; +} +interface CanvasContext2D { + drawImage( + image: CanvasImage, + dx: number, + dy: number, + dWidth: number, + dHeight: number, + ): void; +} +interface CanvasLike { + getContext(type: "2d"): CanvasContext2D; + toBuffer(mime: "image/jpeg", quality?: number): Buffer; +} +interface NapiCanvasModule { + createCanvas(width: number, height: number): CanvasLike; + loadImage(source: Buffer | Uint8Array): Promise; +} + +/** + * Downscale a decoded image onto a fresh JPEG whose long edge is ≤ + * `THUMB_LONG_EDGE` (never upscaled). Shared by the image and PDF paths. + */ +function drawScaledJpeg( + napi: NapiCanvasModule, + image: CanvasImage, +): Thumbnail | null { + const srcW = image.width; + const srcH = image.height; + if (srcW <= 0 || srcH <= 0) return null; + const longEdge = Math.max(srcW, srcH); + // Cap the long edge, never upscale (scale ≤ 1). + const scale = Math.min(1, THUMB_LONG_EDGE / longEdge); + const width = Math.max(1, Math.round(srcW * scale)); + const height = Math.max(1, Math.round(srcH * scale)); + const canvas = napi.createCanvas(width, height); + const ctx = canvas.getContext("2d"); + ctx.drawImage(image, 0, 0, width, height); + const jpeg = canvas.toBuffer("image/jpeg", THUMB_QUALITY); + if (jpeg.byteLength === 0) return null; + return { jpeg, width, height }; +} + +/** + * Generate a small JPEG preview for a stored document, or `{ ok: false }` when + * the type is unsupported or rendering fails. Never throws. + */ +export async function generateThumbnail( + bytes: Buffer, + mimeType: string, +): Promise { + try { + // Lazy import: keeps @napi-rs/canvas (+ transitively pdfjs on the PDF path) + // out of the eager server chunk. Resolved on first generation, then cached. + const napi = + (await import("@napi-rs/canvas")) as unknown as NapiCanvasModule; + + if (THUMBNAILABLE_IMAGE_MIMES.has(mimeType)) { + const image = await napi.loadImage(bytes); + const thumb = drawScaledJpeg(napi, image); + if (!thumb) return { ok: false }; + annotate({ + action: { name: "documents.thumbnail.ok" }, + meta: { source: "image", width: thumb.width, height: thumb.height }, + }); + return { ok: true, thumbnail: thumb }; + } + + if (mimeType === "application/pdf") { + // Page 1 only — a preview never renders the whole document. + const raster = await rasterizePdf(bytes, 1); + if (!raster.ok || raster.images.length === 0) return { ok: false }; + const pageJpeg = Buffer.from(raster.images[0]!.dataBase64, "base64"); + const image = await napi.loadImage(pageJpeg); + const thumb = drawScaledJpeg(napi, image); + if (!thumb) return { ok: false }; + annotate({ + action: { name: "documents.thumbnail.ok" }, + meta: { source: "pdf", width: thumb.width, height: thumb.height }, + }); + return { ok: true, thumbnail: thumb }; + } + + // Every other MIME (Office/text/TIFF/HEIC/XML/JSON) — no preview. + return { ok: false }; + } catch (err) { + annotate({ + action: { name: "documents.thumbnail.failed" }, + meta: { + mimeType, + reason: err instanceof Error ? err.name : "unknown", + message: err instanceof Error ? err.message.slice(0, 300) : String(err), + }, + }); + return { ok: false }; + } +} diff --git a/src/lib/jobs/document-thumbnail-backfill.ts b/src/lib/jobs/document-thumbnail-backfill.ts new file mode 100644 index 000000000..399f7d4ec --- /dev/null +++ b/src/lib/jobs/document-thumbnail-backfill.ts @@ -0,0 +1,150 @@ +/** + * Preview-thumbnail backfill for a user's EXISTING stored documents. + * + * For each live document whose type can carry a preview (image or PDF) and + * that has no `DocumentThumbnail` yet, enqueue a per-document thumbnail job. + * This covers pre-feature documents and any dropped upload-time enqueue — a + * missing thumbnail is always recoverable; the card just shows its kind icon + * meanwhile. + * + * Fired on boot (one per-user pass per account holding thumbnailable documents + * that lack a preview). Pure local compute downstream (no egress, no consent + * or budget gate), so — unlike the content-index backfill — it needs no + * consent receipt. Bounded + resumable: an id-cursor forward walk over the + * not-yet-thumbnailed set, capped at `MAX_ENQUEUES_PER_RUN` per job. A document + * drops out of the set the moment its thumbnail row lands, so the walk + * converges and a re-run picks up where the cap left off. + * + * The queue MUST be registered in the maintenance registrar + * (`src/lib/jobs/reminder/register-maintenance.ts`) so pg-boss provisions it. + */ +import { prisma } from "@/lib/db"; +import { getGlobalBoss } from "@/lib/jobs/boss-instance"; +import { enqueueDocumentThumbnail } from "@/lib/jobs/document-thumbnail"; +import { annotate } from "@/lib/logging/context"; + +export const DOCUMENT_THUMBNAIL_BACKFILL_QUEUE = "document-thumbnail-backfill"; + +/** Serial concurrency — cheap discovery + fan-out enqueues. */ +export const DOCUMENT_THUMBNAIL_BACKFILL_CONCURRENCY = 1; + +/** Discovery page size (id-only). */ +const PAGE_SIZE = 100; + +/** Max per-document jobs one backfill pass enqueues before yielding. */ +const MAX_ENQUEUES_PER_RUN = 1000; + +/** MIME types a preview can be rendered from (image + PDF). */ +const THUMBNAILABLE_MIMES = [ + "image/jpeg", + "image/png", + "image/webp", + "image/gif", + "application/pdf", +] as const; + +export interface ThumbnailBackfillPayload { + userId: string; + enqueuedAt?: string; +} + +export interface ThumbnailBackfillSummary { + enqueued: number; +} + +/** + * Enqueue thumbnail jobs for one user's not-yet-thumbnailed documents. + * Idempotent + resumable: an already-thumbnailed document drops out of the + * candidate set, and the pass stops at the per-run cap. + */ +export async function runThumbnailBackfillForUser( + userId: string, +): Promise { + let enqueued = 0; + let cursor: string | null = null; + + for (;;) { + if (enqueued >= MAX_ENQUEUES_PER_RUN) break; + const rows: { id: string }[] = await prisma.inboundDocument.findMany({ + where: { + userId, + deletedAt: null, + mimeType: { in: [...THUMBNAILABLE_MIMES] }, + thumbnail: { is: null }, + }, + select: { id: true }, + orderBy: { id: "asc" }, + take: PAGE_SIZE, + ...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}), + }); + if (rows.length === 0) break; + + for (const { id } of rows) { + if (enqueued >= MAX_ENQUEUES_PER_RUN) break; + const { enqueued: ok } = await enqueueDocumentThumbnail(userId, id); + if (ok) enqueued += 1; + } + + cursor = rows[rows.length - 1]!.id; + if (rows.length < PAGE_SIZE) break; + } + + annotate({ + action: { name: "documents.thumbnail.backfill" }, + meta: { enqueued }, + }); + return { enqueued }; +} + +/** + * Boot-time discovery: enqueue one per-user backfill pass for every account + * that still holds a thumbnailable document without a preview. Coalesced by + * `singletonKey` per user. Fire-and-forget — a missing boss is a no-op. + */ +export async function enqueueBootTimeThumbnailBackfill(): Promise<{ + enqueued: number; +}> { + const boss = getGlobalBoss(); + if (!boss) return { enqueued: 0 }; + + // Distinct users with at least one thumbnailable, not-yet-thumbnailed doc. + const rows = await prisma.inboundDocument.findMany({ + where: { + deletedAt: null, + mimeType: { in: [...THUMBNAILABLE_MIMES] }, + thumbnail: { is: null }, + }, + select: { userId: true }, + distinct: ["userId"], + }); + + let enqueued = 0; + for (const { userId } of rows) { + const payload: ThumbnailBackfillPayload = { + userId, + enqueuedAt: new Date().toISOString(), + }; + try { + const jobId = await boss.send( + DOCUMENT_THUMBNAIL_BACKFILL_QUEUE, + payload, + { + retryLimit: 3, + retryDelay: 60, + retryBackoff: true, + singletonKey: `document-thumbnail-backfill|${userId}`, + }, + ); + if (jobId) enqueued += 1; + } catch { + // A transient send failure is a no-op — the next boot re-discovers the + // still-missing thumbnails. + } + } + + annotate({ + action: { name: "documents.thumbnail.backfill.boot" }, + meta: { users: enqueued }, + }); + return { enqueued }; +} diff --git a/src/lib/jobs/document-thumbnail.ts b/src/lib/jobs/document-thumbnail.ts new file mode 100644 index 000000000..91a8d44ed --- /dev/null +++ b/src/lib/jobs/document-thumbnail.ts @@ -0,0 +1,163 @@ +/** + * Automatic per-document preview thumbnail, enqueued the moment a document is + * stored (`POST /api/documents/inbound`). Fire-and-forget: the upload response + * never blocks on or fails because of thumbnail rendering. + * + * The job decrypts the stored original (owner-scoped), renders a small JPEG + * preview (`generateThumbnail` — images downscaled, a PDF's first page + * rasterised, every other type skipped), and upserts it encrypted into the 1:1 + * `DocumentThumbnail` side table. Pure local compute — no egress, no consent + * or budget gate. Serial concurrency keeps the canvas/pdfjs decode off the + * request pool. + * + * NEVER fails a document for an expected outcome: an unsupported MIME or an + * unrenderable file leaves the document without a thumbnail (the card shows its + * kind icon), and `runDocumentThumbnail` returns cleanly so pg-boss does not + * retry it. Only an unexpected throw (a DB hiccup) propagates for retry. + * + * The queue MUST be registered in the maintenance registrar + * (`src/lib/jobs/reminder/register-maintenance.ts`) so pg-boss provisions it. + */ +import { prisma } from "@/lib/db"; +import { + decryptDocumentContent, + encryptThumbnail, +} from "@/lib/documents/store"; +import { generateThumbnail } from "@/lib/documents/thumbnail"; +import { getGlobalBoss } from "@/lib/jobs/boss-instance"; +import { annotate } from "@/lib/logging/context"; + +export const DOCUMENT_THUMBNAIL_QUEUE = "document-thumbnail"; + +/** Serial concurrency — canvas/pdfjs decode, kept off the request pool. */ +export const DOCUMENT_THUMBNAIL_CONCURRENCY = 1; + +export interface DocumentThumbnailPayload { + userId: string; + documentId: string; + enqueuedAt?: string; +} + +/** + * Render + store one document's preview thumbnail (owner-scoped). Idempotent: + * a document that already has a thumbnail is skipped, and an unsupported / + * unrenderable document is left without one. Never throws for an expected + * outcome. + */ +export async function runDocumentThumbnail( + payload: DocumentThumbnailPayload, +): Promise { + const { userId, documentId } = payload; + if (!userId || !documentId) return; + + // Owner-scoped load; the blob column is selected ONLY here (the job that + // renders the preview), never in the list/detail queries. Skip a document + // that already has a thumbnail so a re-enqueue is a cheap no-op. + const document = await prisma.inboundDocument.findFirst({ + where: { id: documentId, userId, deletedAt: null }, + select: { + id: true, + mimeType: true, + contentEncrypted: true, + contentCodec: true, + thumbnail: { select: { id: true } }, + }, + }); + if (!document || document.thumbnail) { + annotate({ + action: { name: "documents.thumbnail.skipped" }, + meta: { documentId, reason: !document ? "not-found" : "exists" }, + }); + return; + } + + let bytes: Buffer; + try { + bytes = decryptDocumentContent( + document.contentEncrypted, + document.contentCodec, + ); + } catch { + // Fail-closed decrypt: a bad / missing key id leaves the document without a + // thumbnail (the card shows its icon); never log the bytes. + annotate({ + action: { name: "documents.thumbnail.decryptFailed" }, + meta: { documentId }, + }); + return; + } + + const result = await generateThumbnail(bytes, document.mimeType); + if (!result.ok) { + annotate({ + action: { name: "documents.thumbnail.run" }, + meta: { documentId, generated: false }, + }); + return; + } + + const { jpeg, width, height } = result.thumbnail; + // Upsert (not create) so a racing re-enqueue cannot violate the 1:1 unique + // index; the last render wins. `userId` comes from the scoped load, never a + // body — no mass assignment. + await prisma.documentThumbnail.upsert({ + where: { documentId: document.id }, + create: { + documentId: document.id, + userId, + thumbnailEncrypted: encryptThumbnail(jpeg), + width, + height, + byteSize: jpeg.byteLength, + sourceMime: document.mimeType, + }, + update: { + thumbnailEncrypted: encryptThumbnail(jpeg), + width, + height, + byteSize: jpeg.byteLength, + sourceMime: document.mimeType, + }, + }); + + annotate({ + action: { name: "documents.thumbnail.run" }, + meta: { documentId, generated: true, byteSize: jpeg.byteLength }, + }); +} + +/** + * Enqueue a per-document thumbnail job. Coalesced by `singletonKey` per + * document so a duplicate/idempotent re-upload cannot pile up parallel runs. + * Fire-and-forget — a missing boss (worker not up) is a no-op, and a + * `boss.send` failure (a transient DB hiccup) is swallowed to a no-op too, so + * enqueue can NEVER fail an already-stored upload. A dropped enqueue is + * recoverable via the corpus backfill. + */ +export async function enqueueDocumentThumbnail( + userId: string, + documentId: string, +): Promise<{ enqueued: boolean }> { + const boss = getGlobalBoss(); + if (!boss) return { enqueued: false }; + const payload: DocumentThumbnailPayload = { + userId, + documentId, + enqueuedAt: new Date().toISOString(), + }; + try { + const jobId = await boss.send(DOCUMENT_THUMBNAIL_QUEUE, payload, { + retryLimit: 2, + retryDelay: 30, + retryBackoff: true, + singletonKey: `document-thumbnail|${documentId}`, + }); + return { enqueued: Boolean(jobId) }; + } catch (err) { + annotate({ + action: { name: "documents.thumbnail.enqueueFailed" }, + meta: { documentId, reason: err instanceof Error ? err.name : "unknown" }, + }); + return { enqueued: false }; + } +} diff --git a/src/lib/jobs/reminder/register-maintenance.ts b/src/lib/jobs/reminder/register-maintenance.ts index ccdad6fc2..56f27bae3 100644 --- a/src/lib/jobs/reminder/register-maintenance.ts +++ b/src/lib/jobs/reminder/register-maintenance.ts @@ -81,6 +81,19 @@ import { runDocumentIndex, type DocumentIndexPayload, } from "@/lib/jobs/document-index"; +import { + DOCUMENT_THUMBNAIL_QUEUE, + DOCUMENT_THUMBNAIL_CONCURRENCY, + runDocumentThumbnail, + type DocumentThumbnailPayload, +} from "@/lib/jobs/document-thumbnail"; +import { + DOCUMENT_THUMBNAIL_BACKFILL_QUEUE, + DOCUMENT_THUMBNAIL_BACKFILL_CONCURRENCY, + runThumbnailBackfillForUser, + enqueueBootTimeThumbnailBackfill, + type ThumbnailBackfillPayload, +} from "@/lib/jobs/document-thumbnail-backfill"; import { ENCRYPTION_KEY_ROTATE_QUEUE, ENCRYPTION_KEY_ROTATE_CONCURRENCY, @@ -321,6 +334,16 @@ const allQueues = [ // extraction. Without this entry pg-boss never provisions the queue and every // upload enqueue silently no-ops. DOCUMENT_INDEX_QUEUE, + // Document vault — automatic per-document preview thumbnail, enqueued on + // upload. Pure local compute (canvas/pdfjs downscale), no egress. Without + // this entry pg-boss never provisions the queue and every upload enqueue + // silently no-ops. + DOCUMENT_THUMBNAIL_QUEUE, + // Document vault — boot-time preview-thumbnail backfill. Discovers accounts + // holding thumbnailable documents without a preview and fans out per-document + // thumbnail jobs. Without this entry pg-boss never provisions the queue and + // the boot discovery silently no-ops. + DOCUMENT_THUMBNAIL_BACKFILL_QUEUE, ]; const schedules: ScheduleEntry[] = [ @@ -719,6 +742,62 @@ export async function registerMaintenanceQueues( }, ); + // Document vault — automatic per-document preview thumbnail. Enqueued on + // upload (one job per stored document); decodes + downscales the original to + // a small encrypted JPEG. Pure local compute; serial concurrency so the + // canvas/pdfjs decode never crowds the request pool. + await boss.work( + DOCUMENT_THUMBNAIL_QUEUE, + { localConcurrency: DOCUMENT_THUMBNAIL_CONCURRENCY }, + async (jobs) => { + for (const job of jobs) { + const { userId, documentId } = job.data; + if (!userId || !documentId) continue; + try { + await runDocumentThumbnail(job.data); + } catch (err) { + recordError(); + workerLog( + "error", + `[document-thumbnail] user=${userId} document=${documentId} failed`, + err, + ); + throw err; + } + } + }, + ); + + // Document vault — boot-time preview-thumbnail backfill worker. The boot + // discovery sends one per-user job; this handler fans out per-document + // thumbnail jobs for that user's not-yet-thumbnailed documents. Serial + // concurrency; cheap discovery + enqueue only. + await boss.work( + DOCUMENT_THUMBNAIL_BACKFILL_QUEUE, + { localConcurrency: DOCUMENT_THUMBNAIL_BACKFILL_CONCURRENCY }, + async (jobs) => { + for (const job of jobs) { + const { userId } = job.data; + if (!userId) continue; + try { + const { enqueued } = await runThumbnailBackfillForUser(userId); + workerLog( + "info", + `[document-thumbnail-backfill] user=${userId} enqueued=${enqueued}`, + ); + } catch (err) { + recordError(); + workerLog( + "error", + `[document-thumbnail-backfill] user=${userId} failed`, + err, + ); + throw err; + } + } + }, + ); + // v1.25 (W-ENV) — nightly environment fetch. The daily discovery tick (empty // payload) fans out one per-user job per opted-in account; the queue also // serves the on-demand backfill payloads from the settings surface. Serial @@ -822,4 +901,22 @@ export async function enqueueMaintenanceBootDiscovery(): Promise { err, ); } + + // Document vault — preview-thumbnail backfill. Finds every account holding a + // thumbnailable document (image or PDF) without a preview and enqueues one + // per-user backfill pass. Idempotent across reboots: a rendered thumbnail + // drops its document off the discovery predicate. + try { + const { enqueued } = await enqueueBootTimeThumbnailBackfill(); + workerLog( + "info", + `[document-thumbnail-backfill] boot discovery: enqueued=${enqueued}`, + ); + } catch (err) { + workerLog( + "error", + "[document-thumbnail-backfill] boot discovery threw an unexpected error", + err, + ); + } } diff --git a/src/lib/openapi/routes/documents.ts b/src/lib/openapi/routes/documents.ts index 4ee3a82f2..b09bc02d1 100644 --- a/src/lib/openapi/routes/documents.ts +++ b/src/lib/openapi/routes/documents.ts @@ -150,13 +150,14 @@ const inboundDocument = z contentIndexSource: z .enum(["vision", "text-ocr", "local-pdf", "local-ocr"]) .nullable(), + hasThumbnail: z.boolean(), createdAt: z.string(), updatedAt: z.string(), }) .meta({ id: "InboundDocument", description: - "A stored document. The raw bytes are stored encrypted at rest and never returned; this is the metadata + staging summary. `status` is STORED for a freshly uploaded file (no extraction run). `title` is the user label (plaintext); `documentDate` is the user filing date; `reportDate` is the model-transcribed date (null until extraction runs). `servingClass` says how `/original` delivers the file — render inline (`inline`) or download-only (`attachment`); render/download by it, never by MIME guess. `hasContentIndex` is true when the document has a content-search index (auto-indexed on upload); `contentIndexSource` says how that index was produced — `vision` means an AI provider read the original, the other values are provider-free local extractions, and it is null when `hasContentIndex` is false. Treat an unknown `kind` value as OTHER when decoding.", + "A stored document. The raw bytes are stored encrypted at rest and never returned; this is the metadata + staging summary. `status` is STORED for a freshly uploaded file (no extraction run). `title` is the user label (plaintext); `documentDate` is the user filing date; `reportDate` is the model-transcribed date (null until extraction runs). `servingClass` says how `/original` delivers the file — render inline (`inline`) or download-only (`attachment`); render/download by it, never by MIME guess. `hasContentIndex` is true when the document has a content-search index (auto-indexed on upload); `contentIndexSource` says how that index was produced — `vision` means an AI provider read the original, the other values are provider-free local extractions, and it is null when `hasContentIndex` is false. `hasThumbnail` is true when a preview thumbnail has been rendered in the background (fetch it from `/api/documents/inbound/{id}/thumbnail`); false means no preview yet or an unsupported type — show a placeholder. Treat an unknown `kind` value as OTHER when decoding.", }); const inboundDocumentDetail = inboundDocument @@ -641,6 +642,33 @@ export const inboundDocumentPaths: NonNullable = { }, }, }, + "/api/documents/inbound/{id}/thumbnail": { + get: { + tags: ["Documents"], + summary: "View the document preview thumbnail", + description: + "Decrypts and serves the document's small JPEG preview thumbnail (~320px long edge), rendered in the background from the original and stored encrypted at rest under `DocumentThumbnail.thumbnailEncrypted`. Owner-scoped + gated on the opt-in `inboundDocuments` module. Always `Content-Type: image/jpeg` + `nosniff` + `Cache-Control: private, no-store`; loaded as an `` subresource. 404 when no thumbnail exists yet (freshly uploaded / still rendering / unsupported type) — the caller shows a placeholder. Fails closed (500) on a decrypt error — it never returns the ciphertext.", + parameters: [ + { + name: "id", + in: "path", + required: true, + schema: { type: "string" }, + }, + ], + responses: { + "200": { + description: "The decrypted JPEG preview thumbnail bytes.", + content: { + "image/jpeg": { + schema: { type: "string", format: "binary" }, + }, + }, + }, + ...stdResponses, + }, + }, + }, "/api/documents/inbound/{id}/extract": { post: { tags: ["Documents"], diff --git a/src/lib/validations/inbound-documents.ts b/src/lib/validations/inbound-documents.ts index 053308d69..f025fc317 100644 --- a/src/lib/validations/inbound-documents.ts +++ b/src/lib/validations/inbound-documents.ts @@ -280,6 +280,12 @@ export interface InboundDocumentDto { * a locally-indexed one and offer a richer "Read with AI" pass on the latter. */ contentIndexSource: DocumentContentIndexSourceValue | null; + /** + * Whether the document has an encrypted preview thumbnail (rendered in the + * background after upload). Gates the card's ``: when false the card + * shows its kind icon instead of fetching a thumbnail that 404s. + */ + hasThumbnail: boolean; createdAt: string; updatedAt: string; }