7f066c1372
Deploy / deploy (push) Successful in 7m28s
A formal review is shared as a flat, dated folder of the preview MOVs —
{deliveryRoot}/PRODUCER_REVIEWS/20260828_Producer_review, then _02, _03
for further reviews the same day. It reuses the delivery package's
machinery end to end: DeliveryPackage/DeliveryItem, the DELIVERY_BUILD
job, the same claim path and the same hardlink-or-copy builder, with
DeliveryPackage.kind telling the two apart.
Three things separate a review from a delivery:
A review never advances an Export. Reviews happen throughout a shot's
life, long before QC passes it, and the same version is routinely shown
twice — so nothing is marked PACKAGED, and markDelivered() refuses a
review package outright (the button is hidden too).
Selection is by shot, not by export. Shot Status → Bulk Actions → Build
MOV Review Package; the server resolves each shot to its newest export
that actually has a deliveryMovPath, which is not always its newest
export — a shot whose latest version is still rendering belongs in the
review as the version that can be watched. Shots with no MOV at all are
listed in the confirm dialog with the reason rather than silently
shrinking the folder.
The folder is exactly the MOVs. No CSV, no manifest.json written beside
them; the manifest still returns to the server, so the record of what was
shared is kept without cluttering the share. Names follow the same
client-naming rule as a delivery, so a shot reads identically in a review
folder and in the delivery that follows it.
Verification (DeliveryStage.BuildReviewItem) is proportional as it is for
EXRs, adapted to a single file with no validation-time digest: a hard
link is the source and needs nothing, a copy is checked against its
source by length always and by full hash under "always-hash". A missing
MOV fails the item here — in an EXR delivery the MOV is a viewing copy
beside the real deliverable, but in a review it is the deliverable, and a
review folder quietly one shot short is worse than one that says so.
Worker bumped to 1.6.0 for the new manifest field (DeliveryManifest.Kind).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
680 lines
24 KiB
TypeScript
680 lines
24 KiB
TypeScript
import { DeliveryKind, DeliveryStatus, Prisma } from "@prisma/client";
|
|
import { z } from "zod";
|
|
import { db } from "@/lib/db";
|
|
import { PipelineError } from "./errors";
|
|
import { applyTransition } from "./transitions";
|
|
import { getConfigNumber, getConfigValue } from "./config";
|
|
import { clientShotCode } from "@/lib/shot-code";
|
|
|
|
/**
|
|
* Delivery builder (RenderPipeline2 §11). The web app decides *what* goes in a
|
|
* package and records it; a worker does the file work through the same queue
|
|
* and claim path as a render.
|
|
*
|
|
* Version locking: a package references Export rows and denormalises
|
|
* shotCode/versionString at creation, so a later re-render can never mutate
|
|
* delivery history.
|
|
*/
|
|
|
|
// ── Config (§11.2) ───────────────────────────────────────────────────────────
|
|
|
|
export interface DeliveryConfig {
|
|
packageNameTemplate: string;
|
|
/**
|
|
* MOV review packages (§11.6). A formal review is shared as a flat, dated
|
|
* folder of preview MOVs, so it gets its own name template and its own
|
|
* directory under the delivery root — never mixed in with the episode
|
|
* folders that hold shippable EXR packages.
|
|
*/
|
|
reviewNameTemplate: string;
|
|
layout: { packageDir: string; shotDir: string; reportFile: string; reviewDir: string };
|
|
deliveryRoot: string;
|
|
includeMovs: boolean;
|
|
checksumAlgo: string;
|
|
reportFormats: string[];
|
|
copyMode: "auto" | "always-copy";
|
|
/**
|
|
* "auto" verifies copies by hash and hard links by identity — a link is the
|
|
* same data as the source, so re-reading it cannot disagree with what
|
|
* validation already hashed, and at SAN speeds costs ~30 s per shot to prove
|
|
* nothing. "always-hash" reads every delivered file back regardless.
|
|
*/
|
|
verifyMode: "auto" | "always-hash";
|
|
}
|
|
|
|
export const DELIVERY_CONFIG_DEFAULT: DeliveryConfig = {
|
|
packageNameTemplate: "{yymmdd}_Delivery",
|
|
reviewNameTemplate: "{yyyymmdd}_Producer_review",
|
|
layout: {
|
|
packageDir: "{deliveryRoot}/{episode}/{packageName}",
|
|
shotDir: "{packageDir}/{shotCode}",
|
|
reportFile: "{packageDir}/{packageName}_report.csv",
|
|
reviewDir: "{deliveryRoot}/PRODUCER_REVIEWS/{packageName}",
|
|
},
|
|
deliveryRoot: "//SAN/DELIVERY",
|
|
includeMovs: true,
|
|
checksumAlgo: "xxh64",
|
|
reportFormats: ["csv", "json"],
|
|
copyMode: "auto",
|
|
verifyMode: "auto",
|
|
};
|
|
|
|
/**
|
|
* Project config → SystemConfig default → built-in. Merged shallowly per top
|
|
* level key so a project can override just `deliveryRoot` without restating the
|
|
* whole layout.
|
|
*/
|
|
export async function resolveDeliveryConfig(projectDeliveryConfig: unknown): Promise<DeliveryConfig> {
|
|
let systemDefault: Partial<DeliveryConfig> = {};
|
|
const raw = await getConfigValue("delivery.defaultConfig");
|
|
if (raw?.trim() && raw.trim() !== "{}") {
|
|
try {
|
|
systemDefault = JSON.parse(raw) as Partial<DeliveryConfig>;
|
|
} catch {
|
|
console.error("[render-pipeline] delivery.defaultConfig is not valid JSON — ignoring");
|
|
}
|
|
}
|
|
|
|
const project = (projectDeliveryConfig ?? {}) as Partial<DeliveryConfig>;
|
|
return {
|
|
...DELIVERY_CONFIG_DEFAULT,
|
|
...systemDefault,
|
|
...project,
|
|
layout: {
|
|
...DELIVERY_CONFIG_DEFAULT.layout,
|
|
...(systemDefault.layout ?? {}),
|
|
...(project.layout ?? {}),
|
|
},
|
|
};
|
|
}
|
|
|
|
export interface TokenValues {
|
|
showId: string;
|
|
episode: string;
|
|
yymmdd: string;
|
|
yyyymmdd: string;
|
|
packageName: string;
|
|
packageDir: string;
|
|
deliveryRoot: string;
|
|
shotCode: string;
|
|
versionString: string;
|
|
packageNumber: string;
|
|
}
|
|
|
|
export function applyTokens(template: string, tokens: Partial<TokenValues>): string {
|
|
return template.replace(/\{(\w+)\}/g, (whole, key: string) => {
|
|
const value = (tokens as Record<string, string | undefined>)[key];
|
|
return value === undefined ? whole : value;
|
|
});
|
|
}
|
|
|
|
export function dateTokens(date: Date) {
|
|
const yyyy = String(date.getFullYear());
|
|
const mm = String(date.getMonth() + 1).padStart(2, "0");
|
|
const dd = String(date.getDate()).padStart(2, "0");
|
|
return { yymmdd: `${yyyy.slice(2)}${mm}${dd}`, yyyymmdd: `${yyyy}${mm}${dd}` };
|
|
}
|
|
|
|
// ── E18: what is ready ───────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* READY_FOR_DELIVERY exports grouped by episode. Only the newest ready export
|
|
* per shot is offered: an older ready version of the same shot is history, not
|
|
* a second thing to ship.
|
|
*/
|
|
export async function getReadyForDelivery(projectId: string, episode?: string | null) {
|
|
const rows = await db.export.findMany({
|
|
where: {
|
|
projectId,
|
|
status: "READY_FOR_DELIVERY",
|
|
...(episode ? { shot: { episode } } : {}),
|
|
},
|
|
orderBy: [{ shotId: "asc" }, { versionNumber: "desc" }],
|
|
include: {
|
|
shot: { select: { id: true, shotCode: true, episode: true } },
|
|
qcReviews: { orderBy: { createdAt: "desc" }, take: 1, select: { result: true, reviewerName: true, createdAt: true } },
|
|
},
|
|
});
|
|
|
|
const newestPerShot = new Map<string, (typeof rows)[number]>();
|
|
for (const row of rows) {
|
|
if (!newestPerShot.has(row.shotId)) newestPerShot.set(row.shotId, row);
|
|
}
|
|
|
|
const byEpisode = new Map<string, ReturnType<typeof serialiseReady>[]>();
|
|
for (const row of newestPerShot.values()) {
|
|
const key = row.shot.episode ?? "";
|
|
if (!byEpisode.has(key)) byEpisode.set(key, []);
|
|
byEpisode.get(key)!.push(serialiseReady(row));
|
|
}
|
|
|
|
return {
|
|
episodes: [...byEpisode.entries()]
|
|
.sort((a, b) => a[0].localeCompare(b[0]))
|
|
.map(([ep, exports]) => ({
|
|
episode: ep,
|
|
count: exports.length,
|
|
exports: exports.sort((a, b) => a.shotCode.localeCompare(b.shotCode)),
|
|
})),
|
|
};
|
|
}
|
|
|
|
function serialiseReady(row: {
|
|
id: string;
|
|
versionString: string;
|
|
outputDir: string;
|
|
exrFileCount: number | null;
|
|
exrTotalBytes: bigint | null;
|
|
deliveryMovPath: string | null;
|
|
checksum: string | null;
|
|
frameStart: number;
|
|
frameEnd: number;
|
|
shot: { id: string; shotCode: string; episode: string | null };
|
|
qcReviews: { result: string; reviewerName: string | null; createdAt: Date }[];
|
|
}) {
|
|
return {
|
|
exportId: row.id,
|
|
shotId: row.shot.id,
|
|
shotCode: row.shot.shotCode,
|
|
episode: row.shot.episode ?? "",
|
|
versionString: row.versionString,
|
|
outputDir: row.outputDir,
|
|
frameStart: row.frameStart,
|
|
frameEnd: row.frameEnd,
|
|
fileCount: row.exrFileCount,
|
|
totalBytes: row.exrTotalBytes == null ? null : Number(row.exrTotalBytes),
|
|
deliveryMovPath: row.deliveryMovPath,
|
|
hasChecksum: row.checksum != null,
|
|
qc: row.qcReviews[0]
|
|
? { result: row.qcReviews[0].result, reviewer: row.qcReviews[0].reviewerName, at: row.qcReviews[0].createdAt }
|
|
: null,
|
|
};
|
|
}
|
|
|
|
// ── E16: create a package ────────────────────────────────────────────────────
|
|
|
|
export const createDeliverySchema = z.object({
|
|
projectId: z.string().min(1),
|
|
episode: z.string().max(50).optional().nullable(),
|
|
/** Omitted → every ready export in the episode. */
|
|
exportIds: z.array(z.string().min(1)).max(500).optional(),
|
|
options: z
|
|
.object({
|
|
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
|
name: z.string().max(120).optional(),
|
|
includeMovs: z.boolean().optional(),
|
|
copyMode: z.enum(["auto", "always-copy"]).optional(),
|
|
})
|
|
.optional(),
|
|
});
|
|
|
|
export type CreateDeliveryInput = z.infer<typeof createDeliverySchema>;
|
|
|
|
export async function createDeliveryPackage(input: CreateDeliveryInput, userId: string) {
|
|
const project = await db.project.findUnique({
|
|
where: { id: input.projectId },
|
|
select: { id: true, code: true, showId: true, deliveryConfig: true },
|
|
});
|
|
if (!project) throw new PipelineError(404, "Project not found");
|
|
|
|
const config = await resolveDeliveryConfig(project.deliveryConfig);
|
|
if (input.options?.includeMovs !== undefined) config.includeMovs = input.options.includeMovs;
|
|
if (input.options?.copyMode) config.copyMode = input.options.copyMode;
|
|
|
|
const buildDate = input.options?.date ? new Date(`${input.options.date}T00:00:00`) : new Date();
|
|
if (Number.isNaN(buildDate.getTime())) throw new PipelineError(422, "options.date is not a valid date");
|
|
|
|
const maxAttempts = await getConfigNumber("render.maxAttempts");
|
|
|
|
return db.$transaction(async (tx) => {
|
|
const exports = await tx.export.findMany({
|
|
where: {
|
|
projectId: project.id,
|
|
status: "READY_FOR_DELIVERY",
|
|
...(input.exportIds?.length ? { id: { in: input.exportIds } } : {}),
|
|
...(input.episode ? { shot: { episode: input.episode } } : {}),
|
|
},
|
|
include: { shot: { select: { id: true, shotCode: true, clientCode: true, episode: true } } },
|
|
orderBy: { shotId: "asc" },
|
|
});
|
|
|
|
if (exports.length === 0) {
|
|
throw new PipelineError(422, "No READY_FOR_DELIVERY exports match — nothing to package");
|
|
}
|
|
if (input.exportIds?.length && exports.length !== input.exportIds.length) {
|
|
const found = new Set(exports.map((e) => e.id));
|
|
throw new PipelineError(
|
|
409,
|
|
"Some exports are no longer ready for delivery (re-rendered or already packaged?)",
|
|
{ missing: input.exportIds.filter((id) => !found.has(id)) }
|
|
);
|
|
}
|
|
// One export per shot: two versions of the same shot in one package is
|
|
// always a mistake, and it is cheaper to refuse than to ship it.
|
|
const shotIds = new Set<string>();
|
|
for (const e of exports) {
|
|
if (shotIds.has(e.shotId)) {
|
|
throw new PipelineError(422, `Two versions of ${e.shot.shotCode} selected — pick one`);
|
|
}
|
|
shotIds.add(e.shotId);
|
|
}
|
|
|
|
const episode = input.episode ?? exports[0].shot.episode ?? "";
|
|
const dates = dateTokens(buildDate);
|
|
const showId = project.showId || project.code;
|
|
|
|
const lastNumber = await tx.deliveryPackage.aggregate({
|
|
where: { projectId: project.id },
|
|
_max: { packageNumber: true },
|
|
});
|
|
const packageNumber = (lastNumber._max.packageNumber ?? 0) + 1;
|
|
|
|
const tokens: TokenValues = {
|
|
showId,
|
|
episode,
|
|
...dates,
|
|
packageName: "",
|
|
packageDir: "",
|
|
deliveryRoot: config.deliveryRoot,
|
|
shotCode: "",
|
|
versionString: "",
|
|
packageNumber: String(packageNumber),
|
|
};
|
|
tokens.packageName = input.options?.name ?? applyTokens(config.packageNameTemplate, tokens);
|
|
tokens.packageDir = applyTokens(config.layout.packageDir, tokens);
|
|
|
|
const pkg = await tx.deliveryPackage.create({
|
|
data: {
|
|
projectId: project.id,
|
|
episode,
|
|
name: tokens.packageName,
|
|
packageNumber,
|
|
status: "QUEUED",
|
|
rootPath: tokens.packageDir,
|
|
// Snapshot: historic packages always show the rules they were built with.
|
|
configSnapshot: { ...config, tokens } as unknown as Prisma.InputJsonValue,
|
|
createdById: userId,
|
|
},
|
|
});
|
|
|
|
await tx.deliveryItem.createMany({
|
|
data: exports.map((e) => ({
|
|
packageId: pkg.id,
|
|
exportId: e.id,
|
|
shotId: e.shotId,
|
|
shotCode: e.shot.shotCode,
|
|
clientCode: e.shot.clientCode,
|
|
versionString: e.versionString,
|
|
})),
|
|
});
|
|
|
|
const manifest = await buildDeliveryJobManifest(tx, pkg.id);
|
|
const job = await tx.renderJob.create({
|
|
data: {
|
|
type: "DELIVERY_BUILD",
|
|
deliveryId: pkg.id,
|
|
attempt: 1,
|
|
maxAttempts,
|
|
// Deliveries are interactive: someone is waiting on the folder, and the
|
|
// work is file copying rather than hours of CPU, so it jumps renders.
|
|
priority: 10,
|
|
manifest: manifest as unknown as Prisma.InputJsonValue,
|
|
},
|
|
});
|
|
|
|
return {
|
|
package: {
|
|
id: pkg.id,
|
|
name: pkg.name,
|
|
packageNumber,
|
|
episode,
|
|
status: pkg.status,
|
|
rootPath: pkg.rootPath,
|
|
itemCount: exports.length,
|
|
},
|
|
renderJob: { id: job.id },
|
|
};
|
|
});
|
|
}
|
|
|
|
// ── The DELIVERY_BUILD manifest ──────────────────────────────────────────────
|
|
|
|
export interface DeliveryManifestItem {
|
|
itemId: string;
|
|
exportId: string;
|
|
shotCode: string;
|
|
versionString: string;
|
|
sourceDir: string;
|
|
outputPattern: string;
|
|
frameStart: number;
|
|
frameEnd: number;
|
|
fileCount: number | null;
|
|
checksum: string | null;
|
|
deliveryMovPath: string | null;
|
|
shotDir: string;
|
|
/**
|
|
* `{clientCode}_{versionString}` when the shot has a client code, else the
|
|
* studio's own `{shotCode}_cmp_TT_{versionString}` — what the worker names
|
|
* the delivered EXR sequence and MOV as, replacing the internal render
|
|
* filename it copies from.
|
|
*/
|
|
deliveredBaseName: string;
|
|
/** When validation last ran for this export; null when it never did. */
|
|
validatedAt: string | null;
|
|
}
|
|
|
|
export interface DeliveryManifest {
|
|
stage: "DELIVERY";
|
|
/**
|
|
* EXR_DELIVERY builds the per-shot folders of EXRs + MOVs; MOV_REVIEW builds
|
|
* a flat folder of preview MOVs only. One job type, one claim path, one
|
|
* verification story — the worker branches on this.
|
|
*/
|
|
kind: DeliveryKind;
|
|
packageId: string;
|
|
packageName: string;
|
|
packageNumber: number;
|
|
episode: string;
|
|
packageDir: string;
|
|
reportFile: string;
|
|
includeMovs: boolean;
|
|
copyMode: string;
|
|
verifyMode: string;
|
|
checksumAlgo: string;
|
|
reportFormats: string[];
|
|
items: DeliveryManifestItem[];
|
|
}
|
|
|
|
export async function buildDeliveryJobManifest(
|
|
tx: Prisma.TransactionClient,
|
|
packageId: string
|
|
): Promise<DeliveryManifest> {
|
|
const pkg = await tx.deliveryPackage.findUniqueOrThrow({
|
|
where: { id: packageId },
|
|
include: {
|
|
items: {
|
|
include: {
|
|
export: {
|
|
include: {
|
|
// Newest validation run: a linked package uses it to detect a
|
|
// source file changed after it passed QC.
|
|
validations: { orderBy: { createdAt: "desc" }, take: 1, select: { createdAt: true } },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
project: { select: { code: true, showId: true } },
|
|
},
|
|
});
|
|
|
|
const snapshot = pkg.configSnapshot as unknown as DeliveryConfig & { tokens: TokenValues };
|
|
const baseTokens: TokenValues = {
|
|
...snapshot.tokens,
|
|
packageName: pkg.name,
|
|
packageDir: pkg.rootPath ?? snapshot.tokens.packageDir,
|
|
episode: pkg.episode,
|
|
};
|
|
|
|
return {
|
|
stage: "DELIVERY",
|
|
kind: pkg.kind,
|
|
packageId: pkg.id,
|
|
packageName: pkg.name,
|
|
packageNumber: pkg.packageNumber,
|
|
episode: pkg.episode,
|
|
packageDir: baseTokens.packageDir,
|
|
reportFile: applyTokens(snapshot.layout.reportFile, baseTokens),
|
|
includeMovs: snapshot.includeMovs,
|
|
copyMode: snapshot.copyMode ?? "auto",
|
|
verifyMode: snapshot.verifyMode ?? "auto",
|
|
checksumAlgo: snapshot.checksumAlgo,
|
|
reportFormats: snapshot.reportFormats,
|
|
items: pkg.items.map((item) => ({
|
|
itemId: item.id,
|
|
exportId: item.exportId,
|
|
shotCode: item.shotCode,
|
|
versionString: item.versionString,
|
|
sourceDir: item.export.outputDir,
|
|
outputPattern: item.export.outputPattern,
|
|
frameStart: item.export.frameStart,
|
|
frameEnd: item.export.frameEnd,
|
|
fileCount: item.export.exrFileCount,
|
|
checksum: item.export.checksum,
|
|
deliveryMovPath: snapshot.includeMovs ? item.export.deliveryMovPath : null,
|
|
validatedAt: item.export.validations[0]?.createdAt.toISOString() ?? null,
|
|
// Client code substitutes for shotCode in what the client sees; folder
|
|
// naming follows the same rule as the delivered filenames themselves.
|
|
shotDir: applyTokens(snapshot.layout.shotDir, {
|
|
...baseTokens,
|
|
shotCode: clientShotCode(item),
|
|
versionString: item.versionString,
|
|
}),
|
|
deliveredBaseName: item.clientCode
|
|
? `${item.clientCode}_${item.versionString}`
|
|
: `${item.shotCode}_cmp_TT_${item.versionString}`,
|
|
})),
|
|
};
|
|
}
|
|
|
|
// ── Worker reporting ─────────────────────────────────────────────────────────
|
|
|
|
export const deliveryReportSchema = z.object({
|
|
machineId: z.string().min(1),
|
|
items: z
|
|
.array(
|
|
z.object({
|
|
itemId: z.string().min(1),
|
|
status: z.enum(["COPIED", "VERIFIED", "FAILED"]),
|
|
exrPath: z.string().max(1000).optional().nullable(),
|
|
movPath: z.string().max(1000).optional().nullable(),
|
|
fileCount: z.number().int().nonnegative().optional().nullable(),
|
|
totalBytes: z.number().nonnegative().optional().nullable(),
|
|
checksum: z.string().max(200).optional().nullable(),
|
|
linkMode: z.enum(["hardlink", "copy"]).optional().nullable(),
|
|
errorMessage: z.string().max(2000).optional().nullable(),
|
|
})
|
|
)
|
|
.max(500),
|
|
manifest: z.unknown().optional(),
|
|
reportKey: z.string().max(500).optional().nullable(),
|
|
rootPath: z.string().max(1000).optional().nullable(),
|
|
errorMessage: z.string().max(2000).optional().nullable(),
|
|
/** false → the package failed as a whole. */
|
|
ok: z.boolean(),
|
|
});
|
|
|
|
export type DeliveryReport = z.infer<typeof deliveryReportSchema>;
|
|
|
|
/**
|
|
* The worker finished (or gave up on) a DELIVERY_BUILD job. Exports move to
|
|
* PACKAGED only for items that actually verified — a package with a failed item
|
|
* is FAILED, and nothing about it is silently treated as delivered.
|
|
*/
|
|
export async function reportDeliveryBuild(jobId: string, body: DeliveryReport) {
|
|
return db.$transaction(async (tx) => {
|
|
const job = await tx.renderJob.findUnique({ where: { id: jobId } });
|
|
if (!job) throw new PipelineError(404, "Render job not found");
|
|
if (job.machineId !== body.machineId) {
|
|
throw new PipelineError(409, "Job is not held by this machine (lease reassigned?)");
|
|
}
|
|
if (!job.deliveryId) throw new PipelineError(422, "Job has no delivery package");
|
|
|
|
const pkg = await tx.deliveryPackage.findUnique({
|
|
where: { id: job.deliveryId },
|
|
include: { items: true },
|
|
});
|
|
if (!pkg) throw new PipelineError(404, "Delivery package not found");
|
|
if (pkg.status === "READY" || pkg.status === "DELIVERED") {
|
|
return { package: { id: pkg.id, status: pkg.status }, alreadyApplied: true };
|
|
}
|
|
|
|
await tx.renderJob.update({
|
|
where: { id: job.id },
|
|
data: {
|
|
status: body.ok ? "COMPLETED" : "FAILED",
|
|
progress: 1,
|
|
finishedAt: new Date(),
|
|
errorMessage: body.errorMessage ?? null,
|
|
},
|
|
});
|
|
|
|
for (const reported of body.items) {
|
|
await tx.deliveryItem.updateMany({
|
|
where: { id: reported.itemId, packageId: pkg.id },
|
|
data: {
|
|
status: reported.status,
|
|
exrPath: reported.exrPath ?? null,
|
|
movPath: reported.movPath ?? null,
|
|
fileCount: reported.fileCount ?? null,
|
|
totalBytes: reported.totalBytes == null ? null : BigInt(Math.round(reported.totalBytes)),
|
|
checksum: reported.checksum ?? null,
|
|
linkMode: reported.linkMode ?? null,
|
|
errorMessage: reported.errorMessage ?? null,
|
|
},
|
|
});
|
|
}
|
|
|
|
const verified = body.items.filter((i) => i.status === "VERIFIED");
|
|
const failed = body.items.filter((i) => i.status === "FAILED");
|
|
const success = body.ok && failed.length === 0 && verified.length === pkg.items.length;
|
|
|
|
// A review is not a delivery: the same version is shown in as many reviews
|
|
// as it takes, so building one must never advance the Export state machine.
|
|
if (success && pkg.kind === "EXR_DELIVERY") {
|
|
for (const item of pkg.items) {
|
|
const exportRow = await tx.export.findUnique({ where: { id: item.exportId }, select: { id: true, status: true } });
|
|
if (exportRow?.status === "READY_FOR_DELIVERY") {
|
|
await applyTransition(tx, exportRow, "PACKAGED", {
|
|
type: "WORKER",
|
|
id: body.machineId,
|
|
note: `Packaged into #${pkg.packageNumber} ${pkg.name}`,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
const updated = await tx.deliveryPackage.update({
|
|
where: { id: pkg.id },
|
|
data: {
|
|
status: success ? "READY" : "FAILED",
|
|
builtAt: new Date(),
|
|
rootPath: body.rootPath ?? pkg.rootPath,
|
|
manifest: (body.manifest ?? undefined) as Prisma.InputJsonValue | undefined,
|
|
reportKey: body.reportKey ?? pkg.reportKey,
|
|
errorMessage: success
|
|
? null
|
|
: (body.errorMessage ??
|
|
(failed.length ? `${failed.length} item(s) failed: ${failed.map((f) => f.itemId).join(", ")}` : "Build did not complete")),
|
|
},
|
|
});
|
|
|
|
return {
|
|
package: { id: updated.id, status: updated.status },
|
|
verified: verified.length,
|
|
failed: failed.length,
|
|
alreadyApplied: false,
|
|
};
|
|
});
|
|
}
|
|
|
|
// ── E17 / E19 / cancel ───────────────────────────────────────────────────────
|
|
|
|
export async function listDeliveryPackages(filter: {
|
|
projectId?: string;
|
|
episode?: string;
|
|
status?: DeliveryStatus;
|
|
kind?: DeliveryKind;
|
|
}) {
|
|
const packages = await db.deliveryPackage.findMany({
|
|
where: {
|
|
...(filter.projectId ? { projectId: filter.projectId } : {}),
|
|
...(filter.episode ? { episode: filter.episode } : {}),
|
|
...(filter.status ? { status: filter.status } : {}),
|
|
...(filter.kind ? { kind: filter.kind } : {}),
|
|
},
|
|
orderBy: { createdAt: "desc" },
|
|
include: {
|
|
items: { select: { id: true, shotCode: true, versionString: true, status: true, fileCount: true, linkMode: true, errorMessage: true } },
|
|
project: { select: { id: true, name: true, code: true } },
|
|
},
|
|
});
|
|
|
|
return {
|
|
packages: packages.map((p) => ({
|
|
id: p.id,
|
|
packageNumber: p.packageNumber,
|
|
name: p.name,
|
|
kind: p.kind,
|
|
episode: p.episode,
|
|
status: p.status,
|
|
rootPath: p.rootPath,
|
|
errorMessage: p.errorMessage,
|
|
projectName: p.project.name,
|
|
projectCode: p.project.code,
|
|
createdAt: p.createdAt,
|
|
builtAt: p.builtAt,
|
|
deliveredAt: p.deliveredAt,
|
|
itemCount: p.items.length,
|
|
verifiedCount: p.items.filter((i) => i.status === "VERIFIED").length,
|
|
items: p.items,
|
|
})),
|
|
};
|
|
}
|
|
|
|
/** E19 — the human uploaded the folder to the client and is confirming it. */
|
|
export async function markDelivered(packageId: string, userId: string) {
|
|
return db.$transaction(async (tx) => {
|
|
const pkg = await tx.deliveryPackage.findUnique({ where: { id: packageId }, include: { items: true } });
|
|
if (!pkg) throw new PipelineError(404, "Delivery package not found");
|
|
if (pkg.status === "DELIVERED") {
|
|
return { package: { id: pkg.id, status: pkg.status }, alreadyApplied: true };
|
|
}
|
|
if (pkg.status !== "READY") {
|
|
throw new PipelineError(409, `Package is ${pkg.status} — only a READY package can be marked delivered`);
|
|
}
|
|
if (pkg.kind !== "EXR_DELIVERY") {
|
|
throw new PipelineError(
|
|
409,
|
|
"A MOV review package is shared for review, not delivered — it has no delivered state"
|
|
);
|
|
}
|
|
|
|
for (const item of pkg.items) {
|
|
const exportRow = await tx.export.findUnique({ where: { id: item.exportId }, select: { id: true, status: true } });
|
|
if (exportRow?.status === "PACKAGED") {
|
|
await applyTransition(tx, exportRow, "DELIVERED", {
|
|
type: "USER",
|
|
id: userId,
|
|
note: `Delivered in #${pkg.packageNumber} ${pkg.name}`,
|
|
});
|
|
}
|
|
}
|
|
|
|
const updated = await tx.deliveryPackage.update({
|
|
where: { id: pkg.id },
|
|
data: { status: "DELIVERED", deliveredAt: new Date(), deliveredById: userId },
|
|
});
|
|
return { package: { id: updated.id, status: updated.status }, alreadyApplied: false };
|
|
});
|
|
}
|
|
|
|
/** A mistaken package is cancelled, never deleted (§11.5). */
|
|
export async function cancelDeliveryPackage(packageId: string, userId: string) {
|
|
return db.$transaction(async (tx) => {
|
|
const pkg = await tx.deliveryPackage.findUnique({ where: { id: packageId } });
|
|
if (!pkg) throw new PipelineError(404, "Delivery package not found");
|
|
if (!["DRAFT", "QUEUED", "FAILED"].includes(pkg.status)) {
|
|
throw new PipelineError(409, `Package is ${pkg.status} — only DRAFT, QUEUED or FAILED packages can be cancelled`);
|
|
}
|
|
await tx.renderJob.updateMany({
|
|
where: { deliveryId: pkg.id, status: { in: ["QUEUED", "CLAIMED", "RUNNING"] } },
|
|
data: { status: "CANCELLED", finishedAt: new Date(), errorMessage: "Delivery package cancelled" },
|
|
});
|
|
const updated = await tx.deliveryPackage.update({
|
|
where: { id: pkg.id },
|
|
data: { status: "CANCELLED", errorMessage: `Cancelled by user ${userId}` },
|
|
});
|
|
return { package: { id: updated.id, status: updated.status } };
|
|
});
|
|
}
|