Files
HealthLog/src/lib/jobs/document-thumbnail.ts
T
Marc-André Bombeck ee76672798 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.
2026-07-10 01:15:32 +02:00

164 lines
5.4 KiB
TypeScript

/**
* 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 };
}
}