import { Prisma, QCResult } from "@prisma/client"; import { z } from "zod"; import { db } from "@/lib/db"; import { PipelineError } from "./errors"; import { applyTransition } from "./transitions"; import { recalcShotStatus } from "@/lib/shot-status"; /** * QC workflow (RenderPipeline2 §10) — technical/final-delivery QC on shots the * client has usually already approved. One service, two front doors: the AE * panel (API key) and the web QC page (session). * * PASS means "ready to upload" and nothing else: it never shares to the client * portal, never changes approval state, never creates client-visible media * (§10.0). FAIL does move the comp task to CHANGES — the shot genuinely needs * rework, and the existing REVISIONS derivation should surface that. */ export const qcSubmitSchema = z.object({ result: z.enum(["PASS", "FAIL"]), notes: z.string().max(4000).optional().nullable(), /** Panel sends the artist's email; web callers pass the session user instead. */ reviewerEmail: z.string().email().optional().nullable(), reviewerName: z.string().max(200).optional().nullable(), }); export type QCSubmitInput = z.infer; export interface QCActor { source: "AE_PANEL" | "WEB"; userId?: string | null; name?: string | null; } /** Statuses a QC decision can be recorded from. */ const QC_ELIGIBLE = ["READY_FOR_QC", "QC_FAILED", "READY_FOR_DELIVERY"] as const; export async function submitQCReview(exportId: string, input: QCSubmitInput, actor: QCActor) { const reviewer = input.reviewerEmail ? await db.user.findUnique({ where: { email: input.reviewerEmail }, select: { id: true, name: true } }) : null; const reviewedById = actor.userId ?? reviewer?.id ?? null; const reviewerName = actor.name ?? reviewer?.name ?? input.reviewerName ?? input.reviewerEmail ?? null; return db.$transaction(async (tx) => { const exportRow = await tx.export.findUnique({ where: { id: exportId }, include: { shot: { select: { shotCode: true } } }, }); if (!exportRow) throw new PipelineError(404, "Export not found"); if (!QC_ELIGIBLE.includes(exportRow.status as (typeof QC_ELIGIBLE)[number])) { throw new PipelineError( 409, `Export is ${exportRow.status} — QC can only be recorded on ${QC_ELIGIBLE.join(", ")}` ); } const result = input.result as QCResult; const review = await tx.qCReview.create({ data: { exportId, result, notes: input.notes?.trim() || null, reviewedById, reviewerName, source: actor.source, }, }); const who = reviewerName ?? "unknown reviewer"; let taskUpdated: { id: string; status: string } | null = null; if (result === "PASS") { // Re-passing an export that is already READY_FOR_DELIVERY is a no-op // transition-wise; the QCReview row is still the record of the decision. const updated = exportRow.status === "READY_FOR_DELIVERY" ? exportRow : await applyTransition(tx, exportRow, "READY_FOR_DELIVERY", { type: "USER", id: reviewedById, note: `QC PASS by ${who}${input.notes ? ` — ${input.notes.slice(0, 200)}` : ""}`, }); return { export: { id: updated.id, status: updated.status }, review: { id: review.id, result, createdAt: review.createdAt }, task: null, }; } // FAIL — the shot needs rework, so the comp task goes back to CHANGES. const updated = exportRow.status === "QC_FAILED" ? exportRow : await applyTransition(tx, exportRow, "QC_FAILED", { type: "USER", id: reviewedById, note: `QC FAIL by ${who}${input.notes ? ` — ${input.notes.slice(0, 200)}` : ""}`, }); if (exportRow.taskId) { const task = await tx.task.findUnique({ where: { id: exportRow.taskId }, select: { id: true, status: true } }); if (task && task.status !== "CHANGES") { await tx.task.update({ where: { id: task.id }, data: { status: "CHANGES" } }); taskUpdated = { id: task.id, status: "CHANGES" }; // Shot.status is derived from its tasks and does not recompute on its // own, so without this the shot never actually reached REVISIONS. await recalcShotStatus(exportRow.shotId, tx); } } return { export: { id: updated.id, status: updated.status }, review: { id: review.id, result, createdAt: review.createdAt }, task: taskUpdated, }; }); } export interface QCQueueFilter { projectCode?: string | null; projectId?: string | null; episode?: string | null; limit?: number; } /** * E5 — everything waiting for a human. QC_FAILED is deliberately excluded: a * failed export is the artist's problem until they queue a new version. */ export async function getQCQueue(filter: QCQueueFilter) { const limit = Math.min(200, Math.max(1, filter.limit ?? 50)); const where: Prisma.ExportWhereInput = { status: "READY_FOR_QC", ...(filter.projectId ? { projectId: filter.projectId } : {}), ...(filter.projectCode ? { shot: { project: { code: filter.projectCode } } } : {}), ...(filter.episode ? { shot: { ...(filter.projectCode ? { project: { code: filter.projectCode } } : {}), episode: filter.episode } } : {}), }; const rows = await db.export.findMany({ where, orderBy: { statusChangedAt: "asc" }, // oldest first — nothing should sit in QC take: limit, include: { shot: { select: { id: true, shotCode: true, episode: true, scene: true, project: { select: { code: true, name: true } } } }, validations: { where: { status: { in: ["FAIL", "WARN"] } }, select: { checkName: true, status: true, message: true } }, }, }); return { queue: rows.map((e) => ({ exportId: e.id, shotCode: e.shot.shotCode, episode: e.shot.episode, scene: e.shot.scene, projectCode: e.shot.project.code, versionString: e.versionString, readySince: e.statusChangedAt, // Everything the panel needs to import the actual EXRs for full-res QC outputDir: e.outputDir, outputPattern: e.outputPattern, frameStart: e.frameStart, frameEnd: e.frameEnd, fps: e.fps, deliveryMovPath: e.deliveryMovPath, thumbnailUrl: e.thumbnailKey ? `/api/files/${e.thumbnailKey}` : null, previewUrl: e.previewMovKey ? `/api/files/${e.previewMovKey}` : null, // Warnings the automated pass raised — the human should look harder at these warnings: e.validations.map((v) => ({ checkName: v.checkName, status: v.status, message: v.message })), })), }; }