/** * apps/worker — files pipeline: finalize step (TASK-0102 §6 + §3). * * Persists the terminal state and publishes the event contract: * * READY — status READY + final metadata (sha256/dims/media/pdf + * `pipeline`), `thumbnailKey` (480 variant), `scanReport`; * `FileVersion` upsert (idempotent); `file.uploaded` * emitted EXACTLY ONCE (only when this run actually * transitioned the row to READY — the at-least-once / * re-run dedupe); `file.finalized` (the TASK-0096 search * re-upsert seam); audit `file.processed`. * * VIRUS_DETECTED — status + `scanReport {engine:"clamav",version,infected, * signature}`; `file.virus_detected` event + workspace-admin * notification; audit. NO thumbnails, NO `file.uploaded`, * NO search index update (the object stays stored for * forensics; TASK-0103/0104 enforce download 403). * * FAILED — status + `metadata.pipeline.error` (+ code); audit * `file.process_failed`. */ import { asJson } from "../../../lib/prisma.js"; import { audit, notifyWorkspaceAdmins } from "../../../lib/notify.js"; import { getLogger } from "../../../logger.js"; import type { FilePipelineContext, PipelineMeta, PipelineOutcome } from "../types.js"; import type { File, FileStatus } from "@prisma/client"; const log = getLogger("files.finalize"); const STREAM = "factory:events"; export type FileRowLike = Pick< File, | "id" | "workspaceId" | "name" | "key" | "size" | "mimeType" | "status" | "metadata" | "thumbnailKey" | "uploadedBy" | "blockId" | "pageId" >; export interface FinalizeInput { outcome: PipelineOutcome; /** The row's status BEFORE this run's transition (read by the processor). */ prevStatus: FileStatus; file: FileRowLike; /** The authoritative (re-sniffed) mime for READY. */ finalMime: string; /** The actual downloaded byte size. */ size: number; } export async function finalizeStep( ctx: FilePipelineContext, input: FinalizeInput ): Promise { const { outcome, prevStatus, file, finalMime, size } = input; const { job } = ctx; const meta = (file.metadata as Record | null) ?? {}; const scannedAt = new Date().toISOString(); const steps = [...ctx.stepTimings.entries()].map(([name, ms]) => ({ name, ms })); if (outcome.kind === "virus") { await ctx.prisma.file.update({ where: { id: job.fileId }, data: { status: "VIRUS_DETECTED", scanReport: asJson(outcome.scan as unknown as Record), metadata: asJson({ ...meta, pipeline: { scannedAt, error: "virus_detected" } as PipelineMeta }) } }); await publish(ctx, "file.virus_detected", { workspaceId: file.workspaceId, fileId: job.fileId, name: file.name, signature: outcome.scan.signature, key: file.key }); await notifyWorkspaceAdmins( ctx.prisma, ctx.redis, ctx.env.SMTP_HOST, file.workspaceId, "Malicious file blocked", `Uploaded file "${file.name}" was flagged by ClamAV as ${outcome.scan.signature ?? "malicious"} and has been blocked.`, { type: "file", id: job.fileId }, "AUTOMATION" ); await audit(ctx.prisma, { workspaceId: file.workspaceId, resourceType: "file", resourceId: job.fileId, action: "file.virus_detected", metadata: { signature: outcome.scan.signature, key: file.key, size } }); log.error({ fileId: job.fileId, prevStatus }, "file finalized VIRUS_DETECTED"); return; } if (outcome.kind === "fatal") { const pipeline: Record = { scannedAt, steps, error: outcome.error, ...(ctx.warnings.length ? { warnings: [...ctx.warnings] } : {}) }; await ctx.prisma.file.update({ where: { id: job.fileId }, data: { status: "FAILED", metadata: asJson({ ...meta, pipeline }) } }); await audit(ctx.prisma, { workspaceId: file.workspaceId, resourceType: "file", resourceId: job.fileId, action: "file.process_failed", metadata: { code: outcome.code, error: outcome.error, key: file.key } }); log.error({ fileId: job.fileId, prevStatus, code: outcome.code }, "file finalized FAILED"); return; } // ── READY ────────────────────────────────────────────────────────────── const { extracted, thumbs, scan } = outcome; const pipeline: PipelineMeta = { scannedAt, sha256: ctx.sha256, durationMs: 0, // filled by the processor (total wall time) steps, ...(ctx.warnings.length ? { warnings: [...ctx.warnings] } : {}) }; const metadata: Record = { ...meta, sha256: ctx.sha256, pipeline }; if (extracted.dimensions) metadata.dimensions = extracted.dimensions; if (extracted.media) metadata.media = extracted.media; if (extracted.pdf) metadata.pdf = extracted.pdf; // `thumbnailUrl` (Requirement 5): a SHORT-LIVED signed GET of the 480 // artifact (15 min). Minting it here (not just `thumbnailKey`) is what makes // the acceptance criterion "thumbnailUrl returns 200" true immediately after // READY; the API re-mints on read (TASK-0103) so it never sits long-lived. // Best-effort: a signing failure must not fail the run (the artifact + key // exist and are re-mintable). let thumbnailUrl: string | null = null; if (thumbs.primaryKey) { try { thumbnailUrl = await ctx.s3Thumbs.presignedGet(thumbs.primaryKey, 15 * 60); } catch (err) { log.warn( { fileId: job.fileId, err: (err as Error).message }, "thumbnail presign failed (thumbnailKey still set; re-mint on read)" ); } } await ctx.prisma.file.update({ where: { id: job.fileId }, data: { status: "READY", mimeType: finalMime, size, metadata: asJson(metadata), scanReport: asJson(scan as unknown as Record), thumbnailKey: thumbs.primaryKey, thumbnailUrl } }); // FileVersion: TASK-0101 leaves the row in SCANNING without a version row; // the worker owns the upsert (idempotent on [fileId, version]). await ctx.prisma.fileVersion.upsert({ where: { fileId_version: { fileId: job.fileId, version: job.version } }, update: { key: file.key, size }, create: { fileId: job.fileId, version: job.version, key: file.key, size, createdBy: file.uploadedBy } }); // `file.uploaded` EXACTLY ONCE: only when THIS run made the SCANNING→READY // (or UPLOADING→READY) transition. A re-run of an already-READY file // returns before reaching here, so this guard is the at-least-once dedupe. const isFreshReady = prevStatus === "SCANNING" || prevStatus === "UPLOADING"; if (isFreshReady) { await publish(ctx, "file.uploaded", { workspaceId: file.workspaceId, fileId: job.fileId, name: file.name, mimeType: finalMime, size, ...(file.blockId ? { blockId: file.blockId } : {}), ...(file.pageId ? { pageId: file.pageId } : {}) }); } // TASK-0096 seam: the search re-upsert of the file doc with final metadata. await publish(ctx, "file.finalized", { workspaceId: file.workspaceId, fileId: job.fileId, mimeType: finalMime, size }); await audit(ctx.prisma, { workspaceId: file.workspaceId, resourceType: "file", resourceId: job.fileId, action: "file.processed", metadata: { key: file.key, sha256: ctx.sha256, mime: finalMime, size, steps } }); log.info( { fileId: job.fileId, prevStatus, mime: finalMime, size, thumbs: thumbs.keys.length, fresh: isFreshReady }, "file finalized READY" ); } /** Best-effort stream publish (a lost event is repaired by the reconcile). */ async function publish( ctx: FilePipelineContext, event: string, payload: Record ): Promise { try { await ctx.redis.xadd( STREAM, "MAXLEN", "~", "10000", "*", "event", event, "payload", JSON.stringify(payload), "ts", new Date().toISOString() ); } catch (err) { log.warn({ err: (err as Error).message, event }, "event publish failed (reconcile backstops)"); } }