mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
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.
164 lines
5.4 KiB
TypeScript
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 };
|
|
}
|
|
}
|