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 = { 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 = { 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; 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(); 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( codes: string[], shots: T[] ): { matched: T[]; unmatched: string[] } { const byCode = new Map(); 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(); 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; }