Files
vfxreview/lib/render-pipeline/delivery-handoff.ts
T
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

74 lines
2.4 KiB
TypeScript

import { z } from "zod";
import { clientShotCode } from "@/lib/shot-code";
/**
* The handoff after a package is built: upload it, then share the link.
*
* Kept free of database imports so the deliveries page can use the same stage
* logic and share message the server does.
*/
export type HandoffStage = "AWAITING_UPLOAD" | "AWAITING_SHARE";
/**
* What a built EXR package is still waiting on, or null when it is waiting on
* nothing — not built yet, already delivered, or a review (which has no
* delivered state at all).
*/
export function handoffStage(pkg: {
kind: string;
status: string;
uploadedAt: Date | string | null;
}): HandoffStage | null {
if (pkg.kind !== "EXR_DELIVERY" || pkg.status !== "READY") return null;
return pkg.uploadedAt ? "AWAITING_SHARE" : "AWAITING_UPLOAD";
}
export const recordUploadSchema = z.object({
uploadUrl: z
.string()
.trim()
.max(2000)
.url("Paste the full link to the upload folder")
.refine((value) => /^https?:\/\//i.test(value), "The upload link must start with http:// or https://"),
/** The person looked at the upload and its contents match the package. */
contentsConfirmed: z.literal(true, {
errorMap: () => ({ message: "Confirm the uploaded files match the package" }),
}),
});
export type RecordUploadInput = z.infer<typeof recordUploadSchema>;
export const recordShareSchema = z.object({
sharedWith: z.string().trim().min(1, "Say who the link was sent to").max(200),
note: z.string().trim().max(1000).optional().nullable(),
});
export type RecordShareInput = z.infer<typeof recordShareSchema>;
/**
* The message sent to the client with the link. Generated rather than typed so
* the shots and versions the client is told about are the ones in the package.
*/
export function buildShareMessage(pkg: {
projectCode: string;
episode: string;
name: string;
uploadUrl: string;
items: { shotCode: string; clientCode?: string | null; versionString: string }[];
}): string {
const shots = pkg.items
.map((item) => `${clientShotCode(item)} ${item.versionString}`)
.sort((a, b) => a.localeCompare(b));
const heading = [pkg.projectCode, pkg.episode ? `episode ${pkg.episode}` : "", pkg.name]
.filter(Boolean)
.join(" · ");
return [
`Delivery: ${heading}`,
`${shots.length} shot${shots.length === 1 ? "" : "s"}`,
pkg.uploadUrl,
"",
...shots,
].join("\n");
}