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:
Marc-André Bombeck committed 2026-07-10 01:15:32 +02:00
1 parent 54c78b527e
commit ee76672798
34 files changed
+1342 -160

No files matched your search

+5
View File
@@ -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 });
});
});
+12 -6
View File
@@ -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++) {
+26
View File
@@ -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),
};
+161
View File
@@ -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 };
}
}
+150
View File
@@ -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 };
}
+163
View File
@@ -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,
);
}
}
+29 -1
View File
@@ -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"],
+6
View File
@@ -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;
}