mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
feat(documents): preview thumbnails in the vault overview
Render a small JPEG preview for each stored document and show it at the card's leading edge, falling back to the kind icon when none exists. - Generate previews in the background on upload (new document-thumbnail pg-boss queue) plus a boot-time corpus backfill for existing documents; upload never blocks on or fails because of it. Images are downscaled via @napi-rs/canvas, a PDF's first page is rasterised then downscaled, every other type is skipped. Generation never throws. - Store the preview encrypted at rest in a 1:1 DocumentThumbnail side table (migration 0236) so the list/detail queries that omit the original blob never drag the preview either. Same AES-256-GCM string codec + fail-closed decrypt as the content index; wired into the key-rotation walk. - Serve it through an owner-scoped GET /api/documents/inbound/[id]/thumbnail (auth + module gate + userId-scoped query + fail-closed decrypt), always image/jpeg + nosniff + private, no-store; 404 on miss. The card renders an authed lazy <img>, onError falls back to the icon. - Add hasThumbnail to the document DTO so the card only fetches a preview that exists. Also drop the "search looks inside your documents" caption from the overview (the index-all prompt stays) and the per-card searchable marker; both were noise. Remove the now-unused i18n keys across all six locales.
This commit is contained in:
1 parent
54c78b527e
commit
ee76672798
34 files changed
+1342
-160
No files matched your search
+37
-2
@@ -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 `<img>` 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
|
||||
|
||||
@@ -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 ──────────────
|
||||
|
||||
+1
-4
@@ -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…",
|
||||
|
||||
+1
-4
@@ -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…",
|
||||
|
||||
+1
-4
@@ -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…",
|
||||
|
||||
+1
-4
@@ -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…",
|
||||
|
||||
+1
-4
@@ -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…",
|
||||
|
||||
+1
-4
@@ -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…",
|
||||
|
||||
@@ -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;
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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]", () => {
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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<typeof import("@/lib/modules/gate")>()),
|
||||
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<Response>;
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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 `<img>`
|
||||
* 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",
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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<Response> {
|
||||
// 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
|
||||
// <img>). One grouped query on the 1:1 side table; never the blob column.
|
||||
const thumbnailIds = new Set<string>();
|
||||
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,
|
||||
|
||||
@@ -33,6 +33,7 @@ function doc(overrides: Partial<InboundDocumentDto> = {}): 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("<DocumentCard>", () => {
|
||||
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(
|
||||
<DocumentCard
|
||||
document={doc({ hasContentIndex: true })}
|
||||
document={doc({ hasThumbnail: true })}
|
||||
selected={false}
|
||||
onToggleSelected={noop}
|
||||
onOpen={noop}
|
||||
highlighted={false}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<DocumentCard
|
||||
document={doc({ hasContentIndex: true, contentIndexSource: "vision" })}
|
||||
document={doc({ hasThumbnail: false })}
|
||||
selected={false}
|
||||
onToggleSelected={noop}
|
||||
onOpen={noop}
|
||||
highlighted={false}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<DocumentCard
|
||||
document={doc({ hasContentIndex: false, conditionLinks: [] })}
|
||||
selected={false}
|
||||
onToggleSelected={noop}
|
||||
onOpen={noop}
|
||||
highlighted={false}
|
||||
/>,
|
||||
);
|
||||
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", () => {
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
@@ -34,6 +34,7 @@ function doc(overrides: Partial<InboundDocumentDto> = {}): InboundDocumentDto {
|
||||
servingClass: "inline",
|
||||
hasContentIndex: false,
|
||||
contentIndexSource: null,
|
||||
hasThumbnail: false,
|
||||
createdAt: "2026-03-11T08:00:00.000Z",
|
||||
updatedAt: "2026-03-11T08:00:00.000Z",
|
||||
...overrides,
|
||||
|
||||
@@ -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<ReturnType<typeof setTimeout> | null>(null);
|
||||
@@ -94,10 +97,35 @@ export function DocumentCard({
|
||||
>
|
||||
<CardContent className="flex h-full flex-col gap-2 px-4">
|
||||
<div className="flex items-start gap-2">
|
||||
<Icon
|
||||
className="text-foreground mt-0.5 size-5 shrink-0"
|
||||
aria-hidden
|
||||
/>
|
||||
{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.
|
||||
<span
|
||||
data-slot="document-thumbnail"
|
||||
className="bg-muted mt-0.5 block size-12 shrink-0 overflow-hidden rounded-md"
|
||||
>
|
||||
{/* 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 */}
|
||||
<img
|
||||
src={`/api/documents/inbound/${document.id}/thumbnail`}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onError={() => setThumbFailed(true)}
|
||||
className="size-full object-cover"
|
||||
/>
|
||||
</span>
|
||||
) : (
|
||||
<Icon
|
||||
className="text-foreground mt-0.5 size-5 shrink-0"
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
<p className="min-w-0 flex-1 truncate text-sm font-medium">{title}</p>
|
||||
<Checkbox
|
||||
checked={selected}
|
||||
@@ -122,8 +150,7 @@ export function DocumentCard({
|
||||
{showFilename ? ` · ${document.filename}` : ""}
|
||||
</p>
|
||||
{document.conditionLinks.length > 0 ||
|
||||
document.servingClass === "attachment" ||
|
||||
document.hasContentIndex ? (
|
||||
document.servingClass === "attachment" ? (
|
||||
<div className="mt-auto flex flex-wrap items-center gap-1.5 pt-1">
|
||||
{document.conditionLinks.map((link) => (
|
||||
<span
|
||||
@@ -142,37 +169,6 @@ export function DocumentCard({
|
||||
{t("documents.card.attachmentBadge")}
|
||||
</Badge>
|
||||
) : 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) ? (
|
||||
<span
|
||||
data-slot="document-searchable"
|
||||
data-source="ai-read"
|
||||
className="text-primary inline-flex items-center"
|
||||
title={t("documents.card.aiReadBadge")}
|
||||
>
|
||||
<Sparkles className="size-3.5" aria-hidden />
|
||||
<span className="sr-only">
|
||||
{t("documents.card.aiReadBadge")}
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
data-slot="document-searchable"
|
||||
data-source="local"
|
||||
className="text-muted-foreground inline-flex items-center"
|
||||
title={t("documents.card.searchableBadge")}
|
||||
>
|
||||
<ScanSearch className="size-3.5" aria-hidden />
|
||||
<span className="sr-only">
|
||||
{t("documents.card.searchableBadge")}
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
|
||||
@@ -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}
|
||||
</div>
|
||||
|
||||
{contentSearchActive || showIndexAll ? (
|
||||
{showIndexAll ? (
|
||||
<div
|
||||
data-slot="content-search-hint"
|
||||
className="text-muted-foreground mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs"
|
||||
>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<ScanSearch className="size-3.5 shrink-0" aria-hidden />
|
||||
{contentSearchActive
|
||||
? t("documents.contentIndex.searchHint")
|
||||
: t("documents.contentIndex.indexPrompt")}
|
||||
{t("documents.contentIndex.indexPrompt")}
|
||||
</span>
|
||||
{showIndexAll ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
size="sm"
|
||||
data-slot="content-index-all"
|
||||
onClick={onIndexAll}
|
||||
disabled={indexAllPending}
|
||||
className="text-primary h-auto p-0 text-xs"
|
||||
>
|
||||
{indexAllPending
|
||||
? t("documents.contentIndex.indexAllPending")
|
||||
: t("documents.contentIndex.indexAll")}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
size="sm"
|
||||
data-slot="content-index-all"
|
||||
onClick={onIndexAll}
|
||||
disabled={indexAllPending}
|
||||
className="text-primary h-auto p-0 text-xs"
|
||||
>
|
||||
{indexAllPending
|
||||
? t("documents.contentIndex.indexAllPending")
|
||||
: t("documents.contentIndex.indexAll")}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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")),
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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<RasterResult> {
|
||||
export async function rasterizePdf(
|
||||
buffer: Buffer,
|
||||
maxPages: number = RASTER_MAX_PAGES,
|
||||
): Promise<RasterResult> {
|
||||
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<RasterResult> {
|
||||
});
|
||||
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++) {
|
||||
|
||||
@@ -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<ArrayBuffer> {
|
||||
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),
|
||||
};
|
||||
|
||||
@@ -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<CanvasImage>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<ThumbnailResult> {
|
||||
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 };
|
||||
}
|
||||
}
|
||||
@@ -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<ThumbnailBackfillSummary> {
|
||||
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 };
|
||||
}
|
||||
@@ -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<void> {
|
||||
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 };
|
||||
}
|
||||
}
|
||||
@@ -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<DocumentThumbnailPayload>(
|
||||
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<ThumbnailBackfillPayload>(
|
||||
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<void> {
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/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 `<img>` 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"],
|
||||
|
||||
@@ -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 `<img>`: when false the card
|
||||
* shows its kind icon instead of fetching a thumbnail that 404s.
|
||||
*/
|
||||
hasThumbnail: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user