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>
74 lines
2.4 KiB
TypeScript
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");
|
|
}
|