f30acef101
Deploy / deploy (push) Successful in 5m17s
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>
164 lines
5.5 KiB
TypeScript
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;
|
|
}
|