Files
vfxreview/lib/render-pipeline/delivery-request-state.ts
twotalesanimation f30acef101
Deploy / deploy (push) Successful in 5m17s
feat(deliveries): delivery requests and upload/share evidence
A built package used to become DELIVERED on one click with nothing behind
it, and nothing recorded what a client had asked for.

- Marking delivered is now two recorded steps: Record upload (the link on
  the client's portal, contents confirmed) then Share link (who it was sent
  to). The second is refused until the first is on record.
- Delivery requests log a client's ask by episode, pasted shot codes or a
  Shot Status selection. A line closes only when its shot goes out with
  evidence after the request was logged; a request can otherwise only be
  cancelled with a reason.
- Delivered Outside Pipeline takes an upload link and recipient, required
  for shots on an open request.
- The dashboard gains an Awaiting delivery section listing open requests
  and built packages the client does not have yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 14:21:24 +02:00

164 lines
5.5 KiB
TypeScript

import { z } from "zod";
/**
* Delivery request rules with no database behind them, shared by the server
* and the pages that show requests.
*/
export const DELIVERABLES = ["EXR", "MOV", "EXR_AND_MOV"] as const;
export type Deliverable = (typeof DELIVERABLES)[number];
export const DELIVERABLE_LABEL: Record<Deliverable, string> = {
EXR: "EXRs",
MOV: "MOVs",
EXR_AND_MOV: "EXRs + MOVs",
};
/** Where one requested shot stands, from furthest along to not started. */
export type RequestLineStage =
| "DELIVERED"
| "UPLOADED"
| "BUILT"
| "BUILDING"
| "READY"
| "IN_QC"
| "NOT_READY";
export const LINE_STAGE_LABEL: Record<RequestLineStage, string> = {
DELIVERED: "delivered",
UPLOADED: "uploaded, link not shared",
BUILT: "built, not uploaded",
BUILDING: "package building",
READY: "ready to package",
IN_QC: "in QC",
NOT_READY: "not through QC",
};
export const createRequestSchema = z
.object({
projectId: z.string().min(1),
/** Exactly one of these three says which shots were asked for. */
episode: z.string().trim().min(1).max(50).optional().nullable(),
shotIds: z.array(z.string().min(1)).min(1).max(1000).optional().nullable(),
shotCodes: z.string().trim().min(1).max(20000).optional().nullable(),
deliverable: z.enum(DELIVERABLES),
requestedBy: z.string().trim().min(1, "Say who asked for it").max(200),
dueDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().nullable(),
note: z.string().trim().max(2000).optional().nullable(),
})
.refine((v) => [v.episode, v.shotIds, v.shotCodes].filter(Boolean).length === 1, {
message: "Choose an episode, a shot selection or a list of shot codes",
});
export type CreateRequestInput = z.infer<typeof createRequestSchema>;
export const cancelRequestSchema = z.object({
reason: z.string().trim().min(1, "Say why the request is being cancelled").max(500),
});
/**
* Shot codes out of a pasted message: split on anything that is not part of a
* code, so "116_010, 116_020\n116_030" and a column copied from a sheet both
* work. Order is kept and repeats dropped.
*/
export function parseShotCodes(text: string): string[] {
const seen = new Set<string>();
const codes: string[] = [];
for (const raw of text.split(/[^A-Za-z0-9_\-.]+/)) {
const code = raw.trim();
const key = code.toLowerCase();
if (!code || seen.has(key)) continue;
seen.add(key);
codes.push(code);
}
return codes;
}
/**
* Pasted codes against a project's shots. The client writes their own codes
* and we write ours, so either matches; anything that matches neither is
* returned rather than dropped, because a silently skipped shot is exactly the
* kind of miss a request exists to prevent.
*/
export function matchShotCodes<T extends { shotCode: string; clientCode?: string | null }>(
codes: string[],
shots: T[]
): { matched: T[]; unmatched: string[] } {
const byCode = new Map<string, T>();
for (const shot of shots) {
byCode.set(shot.shotCode.toLowerCase(), shot);
const alias = shot.clientCode?.trim().toLowerCase();
if (alias && !byCode.has(alias)) byCode.set(alias, shot);
}
const matched = new Set<T>();
const unmatched: string[] = [];
for (const code of codes) {
const shot = byCode.get(code.toLowerCase());
if (shot) matched.add(shot);
else unmatched.push(code);
}
return { matched: [...matched], unmatched };
}
/**
* Where a requested shot stands. Only a delivery made after the request was
* logged closes the line — a client asking again for something already sent
* is asking for it to be sent again.
*/
export function resolveLineStage(input: {
requestCreatedAt: Date;
/** EXR delivery packages containing the shot. */
packages: { status: string; uploadedAt: Date | null; deliveredAt: Date | null }[];
manual: { deliveredAt: Date | null; url: string | null; sharedWith: string | null };
/** Statuses of the shot's exports. */
exportStatuses: string[];
}): RequestLineStage {
const { requestCreatedAt, packages, manual, exportStatuses } = input;
if (
packages.some((p) => p.status === "DELIVERED" && p.deliveredAt && p.deliveredAt >= requestCreatedAt)
) {
return "DELIVERED";
}
if (
manual.deliveredAt &&
manual.url &&
manual.sharedWith &&
manualDeliveryCounts(manual.deliveredAt, requestCreatedAt)
) {
return "DELIVERED";
}
const ready = packages.filter((p) => p.status === "READY");
if (ready.some((p) => p.uploadedAt)) return "UPLOADED";
if (ready.length) return "BUILT";
if (packages.some((p) => p.status === "QUEUED" || p.status === "BUILDING")) return "BUILDING";
if (exportStatuses.includes("READY_FOR_DELIVERY")) return "READY";
if (exportStatuses.includes("READY_FOR_QC")) return "IN_QC";
return "NOT_READY";
}
const DAY_MS = 24 * 3600_000;
/** Past its due date or two days old is critical; a day old is a warning. */
export function waitingSeverity(
since: Date,
dueDate: Date | null,
now = Date.now()
): "normal" | "warning" | "critical" {
if (dueDate && dueDate.getTime() < now) return "critical";
const age = now - since.getTime();
return age >= 2 * DAY_MS ? "critical" : age >= DAY_MS ? "warning" : "normal";
}
/**
* A manual delivery is recorded as a calendar day (noon UTC), so it counts for
* a request logged any time that same day.
*/
export function manualDeliveryCounts(manualDeliveredAt: Date, requestCreatedAt: Date): boolean {
const dayStart = Date.UTC(
requestCreatedAt.getUTCFullYear(),
requestCreatedAt.getUTCMonth(),
requestCreatedAt.getUTCDate()
);
return manualDeliveredAt.getTime() >= dayStart;
}