feat(deliveries): delivery requests and upload/share evidence
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>
This commit is contained in:
twotalesanimation
2026-09-30 14:21:24 +02:00
parent 326d8cbefa
commit f30acef101
25 changed files with 2241 additions and 52 deletions
+66 -9
View File
@@ -20,9 +20,14 @@ This is the operational guide. The design rationale and normative rules live in
18 shots"*, click **Build delivery**.
3. A render worker builds the folder on the delivery volume, verifies every
file, and writes a manifest and CSV report.
4. A human uploads that folder to the client, then clicks **Mark delivered**.
4. A human uploads that folder to the client and clicks **Record upload**,
pasting the link to where it now lives.
5. They send the client that link and click **Share link**, recording who it
went to. Only then is the package `DELIVERED`.
Nothing is ever sent to the client automatically. Step 4 is deliberately manual.
Nothing is ever sent to the client automatically. Steps 4 and 5 are deliberately
manual — but until both are recorded the package sits in **Awaiting delivery**
at the top of the dashboard.
---
@@ -69,8 +74,8 @@ Watch the package status on the same page:
|---|---|
| `QUEUED` | Waiting for a worker to pick it up |
| `BUILDING` | A worker is creating folders and linking files |
| `READY` | Built and verified — upload it |
| `DELIVERED` | You confirmed the client has it |
| `READY` | Built and verified. Shown as *Built — not uploaded*, then *Uploaded — link not shared* |
| `DELIVERED` | The upload location and who the link was sent to are both on record |
| `FAILED` | Something did not verify — see the error and the per-shot list |
| `CANCELLED` | Abandoned before delivery |
@@ -166,11 +171,59 @@ so it is logged and the package continues without it.
## Marking delivered
Once you have uploaded the folder, **Mark delivered** moves every packaged
export to `DELIVERED`. Requires admin, producer or supervisor.
A built package is not a delivered one, so delivery is two recorded steps. Both
require admin, producer or supervisor.
This is the end of the pipeline, and it is a human statement of fact — the app
has no way to know the client received anything.
1. **Record upload** — paste the link to the folder on the client's portal and
confirm it matches the shot, frame and MOV counts shown. The link can be
corrected until it is shared.
2. **Share link** — copy the generated message (link, shots, versions), send it,
and record who it went to. This moves every packaged export to `DELIVERED`.
The second step is refused until the first is on record. Meanwhile the package
is listed under **Awaiting delivery** on the dashboard, amber after a day and
red after two.
This is still a human statement of fact — the client's portal has no API, so
the app cannot see the upload itself. What it guarantees is that nothing reads
as delivered without a link, a recipient, a name and a time behind it. Packages
delivered before this existed show as delivered with no evidence recorded.
---
## Delivery requests
When a client asks for something ("EXRs for 116"), log it so it cannot be
forgotten. Requires admin, producer or supervisor.
- **Dashboard → Awaiting delivery → Log request**, or the same button on
**Pipeline → Deliveries**: pick the project, then a whole episode or a pasted
list of shot codes (ours or the client's; a code that matches nothing is
refused rather than skipped).
- **Shot Status → Bulk Actions → Log Delivery Request** for a ticked selection.
The shot list is a snapshot taken when the request is logged. The request then
sits in **Awaiting delivery** showing *"3 of 9 delivered"*, and on the
deliveries page every shot shows where it stands: not through QC, in QC, ready
to package, building, built, uploaded, delivered. **Build N ready** packages
the shots that have passed QC.
A request has no "done" button. A shot's line closes only when:
- a package containing it is shared (step 2 of marking delivered) after the
request was logged, or
- it is marked **Delivered Outside Pipeline** with the upload link and who the
link was sent to, dated on or after the day the request was logged. For a
shot on an open request those two fields are required.
The request closes when every line has. A delivery made *before* the request
was logged does not count — a client asking again is asking for it again. The
only other way out is **Cancel request**, which needs a reason and stays in the
history.
The *Wanted* field (EXRs, MOVs, both) records what was asked for; it does not
change what closes a line. A MOV-only ask is closed through Delivered Outside
Pipeline, since a MOV review package has no delivered state.
---
@@ -233,8 +286,12 @@ a different spec is a config change rather than a code change.
| E16 | `POST` | `/api/deliveries` | Create a package and queue the build |
| E17 | `GET` | `/api/deliveries` | List packages / history |
| E18 | `GET` | `/api/deliveries/ready` | Ready exports grouped by episode |
| E19 | `POST` | `/api/deliveries/{id}/delivered` | Mark delivered |
| — | `POST` | `/api/deliveries/{id}/uploaded` | Record the upload: `{ uploadUrl, contentsConfirmed: true }` |
| E19 | `POST` | `/api/deliveries/{id}/delivered` | Record the share and mark delivered: `{ sharedWith, note? }` |
| — | `POST` | `/api/deliveries/{id}/cancel` | Cancel a package |
| — | `GET` | `/api/delivery-requests` | List requests with per-shot stages (`?projectId=`, `?open=1`) |
| — | `POST` | `/api/delivery-requests` | Log a request: `{ projectId, episode \| shotIds \| shotCodes, deliverable, requestedBy, dueDate?, note? }` |
| — | `POST` | `/api/delivery-requests/{id}/cancel` | Cancel a request: `{ reason }` |
| — | `POST` | `/api/ext/render/jobs/{jobId}/delivery` | Worker reports a build (API key) |
`POST /api/deliveries` takes `{ projectId, episode?, exportIds?, options? }`.
+35 -3
View File
@@ -6,6 +6,7 @@ import { revalidatePath } from "next/cache";
import { z } from "zod";
import { ShotStatus, ShotPriority, ShotApprovalStatus, Prisma } from "@prisma/client";
import { recalcShotStatus, markShotForRevisions } from "@/lib/shot-status";
import { countShotsOnOpenRequests } from "@/lib/render-pipeline/delivery-requests";
const createShotSchema = z.object({
scene: z.string().min(1).max(50).regex(/^[A-Z0-9_]+$/i, "Alphanumeric and underscore only"),
@@ -718,6 +719,8 @@ export async function bulkSetManualDelivery(
shotIds: string[],
deliveredOn: string | null,
note?: string,
/** Where it was uploaded and who was sent the link. */
evidence?: { uploadUrl?: string; sharedWith?: string },
) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
@@ -726,7 +729,7 @@ export async function bulkSetManualDelivery(
}
const ids = z.array(z.string().cuid()).parse(shotIds);
if (!ids.length) return { success: true, updated: 0 };
if (!ids.length) return { success: true as const, updated: 0 };
let deliveredAt: Date | null = null;
if (deliveredOn) {
@@ -745,6 +748,27 @@ export async function bulkSetManualDelivery(
if (shots.length !== ids.length) throw new Error("Some shots not found");
const trimmedNote = note?.trim() || null;
const uploadUrl = evidence?.uploadUrl?.trim() || null;
const sharedWith = evidence?.sharedWith?.trim() || null;
if (uploadUrl && !/^https?:\/\/\S+$/i.test(uploadUrl)) {
return { success: false as const, error: "The upload link must be a full http(s) link" };
}
// A backfill of old deliveries may have no link to give. A shot a client is
// waiting on is different: without the link and recipient the request it
// answers would stay open, so say so now rather than leave it unexplained.
if (deliveredAt && (!uploadUrl || !sharedWith)) {
const owed = await countShotsOnOpenRequests(ids);
if (owed > 0) {
// Returned, not thrown: a production build replaces a thrown action
// error with a generic message, and this one has to reach the person.
return {
success: false as const,
error: `${owed} of these shots ${owed === 1 ? "is" : "are"} on an open delivery request — add the upload link and who it was sent to`,
};
}
}
await db.shot.updateMany({
where: { id: { in: ids } },
data: deliveredAt
@@ -752,8 +776,16 @@ export async function bulkSetManualDelivery(
manualDeliveredAt: deliveredAt,
manualDeliveredById: session.user.id,
manualDeliveryNote: trimmedNote,
manualDeliveryUrl: uploadUrl,
manualDeliverySharedWith: sharedWith,
}
: { manualDeliveredAt: null, manualDeliveredById: null, manualDeliveryNote: null },
: {
manualDeliveredAt: null,
manualDeliveredById: null,
manualDeliveryNote: null,
manualDeliveryUrl: null,
manualDeliverySharedWith: null,
},
});
for (const pid of [...new Set(shots.map((s) => s.projectId))]) {
@@ -762,7 +794,7 @@ export async function bulkSetManualDelivery(
revalidatePath(`/shot-status`);
revalidatePath(`/dashboard`);
return { success: true, updated: shots.length };
return { success: true as const, updated: shots.length };
}
// ── Bulk Delete Shots ─────────────────────────────────────────────────────────
+31 -1
View File
@@ -5,12 +5,15 @@ import { BlockedQueue } from "@/components/dashboard/BlockedQueue";
import { FarmStrip } from "@/components/dashboard/FarmStrip";
import { DeliveryCountdown } from "@/components/dashboard/DeliveryCountdown";
import { QcQueue } from "@/components/dashboard/QcQueue";
import { AwaitingDelivery } from "@/components/dashboard/AwaitingDelivery";
import { LogRequestButton } from "@/components/dashboard/LogRequestButton";
import { RecentDeliveries } from "@/components/dashboard/RecentDeliveries";
import { WhoseCourt } from "@/components/dashboard/WhoseCourt";
import { ShotQueue } from "@/components/dashboard/ShotQueue";
import { TaskWidgets } from "@/components/dashboard/TaskWidgets";
import { ScheduleWidgets } from "@/components/dashboard/ScheduleWidgets";
import {
getAwaitingDelivery,
getBlockedExports,
getDeliveryCountdown,
getFarmStatus,
@@ -20,6 +23,7 @@ import {
getRecentDeliveries,
getWhoseCourt,
} from "@/lib/dashboard/ops";
import { listDeliveryRequests } from "@/lib/render-pipeline/delivery-requests";
import type { DashboardStats } from "@/types";
export const metadata = { title: "Dashboard" };
@@ -37,7 +41,7 @@ async function getDashboardData(userId: string, role: string) {
const isClient = role === "CLIENT";
// Producers see the floor; an artist sees the failures that are theirs.
const [counts, blocked, farm, episodes, qcQueue, qcStaleHours, deliveries, court] =
const [counts, blocked, farm, episodes, qcQueue, qcStaleHours, deliveries, awaitingDelivery, openRequests, court] =
await Promise.all([
getOpsCounts(isArtist ? userId : undefined),
getBlockedExports(isArtist ? { artistId: userId } : {}),
@@ -46,6 +50,8 @@ async function getDashboardData(userId: string, role: string) {
isArtist || isClient ? [] : getQcQueue(),
getQcStaleHours(),
isArtist || isClient ? [] : getRecentDeliveries(),
isArtist || isClient ? [] : getAwaitingDelivery(),
isArtist || isClient ? [] : listDeliveryRequests({ openOnly: true }),
isArtist || isClient ? null : getWhoseCourt(),
]);
@@ -128,6 +134,8 @@ async function getDashboardData(userId: string, role: string) {
qcQueue,
qcStaleHours,
deliveries,
awaitingDelivery,
openRequests,
court,
shots,
myTasks,
@@ -152,6 +160,8 @@ export default async function DashboardPage() {
qcQueue,
qcStaleHours,
deliveries,
awaitingDelivery,
openRequests,
court,
shots,
myTasks,
@@ -171,6 +181,26 @@ export default async function DashboardPage() {
{!isClient && <StatsCards stats={stats} />}
{/* Rows only clear by being dealt with; the heading stays so there is
always somewhere to log what a client just asked for. */}
{!isClient && !isArtistRole && (
<div className="space-y-3">
<div className="flex items-center justify-between gap-3">
<h2
className={
openRequests.length + awaitingDelivery.length > 0
? "text-sm font-semibold text-amber-400 uppercase tracking-wide"
: "text-sm font-semibold text-muted-foreground uppercase tracking-wide"
}
>
Awaiting delivery
</h2>
{canSeeSchedule && <LogRequestButton />}
</div>
<AwaitingDelivery requests={openRequests} packages={awaitingDelivery} />
</div>
)}
{!isClient && (
<div className="space-y-3">
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
@@ -3,11 +3,14 @@
import { useState } from "react";
import Link from "next/link";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { ArrowLeft, Package, CheckCircle2, XCircle, RefreshCw } from "lucide-react";
import { ArrowLeft, Package, CheckCircle2, XCircle, RefreshCw, Upload } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useToast } from "@/components/ui/use-toast";
import { cn } from "@/lib/utils";
import { handoffStage } from "@/lib/render-pipeline/delivery-handoff";
import { DELIVERY_STATUS_STYLES, statusLabel } from "../status-colors";
import { RecordShareDialog, RecordUploadDialog } from "./HandoffDialogs";
import { RequestsPanel } from "./RequestsPanel";
interface ReadyExport {
exportId: string;
@@ -35,14 +38,25 @@ interface PackageRow {
rootPath: string | null;
errorMessage: string | null;
projectName: string;
projectCode: string;
createdAt: string;
builtAt: string | null;
uploadUrl: string | null;
uploadedAt: string | null;
uploadedByName: string | null;
sharedWith: string | null;
sharedNote: string | null;
deliveredAt: string | null;
deliveredByName: string | null;
itemCount: number;
verifiedCount: number;
exrFileCount: number;
movCount: number;
totalBytes: number;
items: {
id: string;
shotCode: string;
clientCode: string | null;
versionString: string;
status: string;
fileCount: number | null;
@@ -69,6 +83,7 @@ export function DeliveriesClient({ canBuild }: { canBuild: boolean }) {
const [projectId, setProjectId] = useState<string>("");
const [episode, setEpisode] = useState<string>("");
const [building, setBuilding] = useState(false);
const [handoff, setHandoff] = useState<{ step: "upload" | "share"; packageId: string } | null>(null);
const { data: projects } = useQuery({
queryKey: ["pipeline-projects"],
@@ -126,16 +141,23 @@ export function DeliveriesClient({ canBuild }: { canBuild: boolean }) {
}
}
async function packageAction(id: string, verb: "delivered" | "cancel") {
const res = await fetch(`/api/deliveries/${id}/${verb}`, { method: "POST" });
const body = await res.json().catch(() => ({}));
if (!res.ok) {
toast({ title: `${verb} failed`, description: body.error ?? res.statusText, variant: "destructive" });
}
function refreshPackages() {
queryClient.invalidateQueries({ queryKey: ["delivery-packages"] });
queryClient.invalidateQueries({ queryKey: ["delivery-ready"] });
}
async function cancelPackage(id: string) {
const res = await fetch(`/api/deliveries/${id}/cancel`, { method: "POST" });
const body = await res.json().catch(() => ({}));
if (!res.ok) {
toast({ title: "Cancel failed", description: body.error ?? res.statusText, variant: "destructive" });
}
refreshPackages();
}
// Looked up from the live list so the dialog never acts on a stale row.
const handoffPackage = handoff ? packages?.packages.find((p) => p.id === handoff.packageId) : undefined;
const episodes = ready?.episodes ?? [];
const shown = episode ? episodes.filter((e) => e.episode === episode) : episodes;
@@ -183,6 +205,8 @@ export function DeliveriesClient({ canBuild }: { canBuild: boolean }) {
)}
</div>
<RequestsPanel projectId={projectId} canBuild={canBuild} />
{/* Ready to build */}
{projectId && (
<div className="rounded-lg border border-zinc-800 bg-zinc-900/40">
@@ -238,7 +262,9 @@ export function DeliveriesClient({ canBuild }: { canBuild: boolean }) {
<div className="rounded-lg border border-zinc-800 bg-zinc-900/40">
<div className="px-4 py-2.5 border-b border-zinc-800 text-sm font-medium text-white">Packages</div>
<div className="divide-y divide-zinc-800/60">
{(packages?.packages ?? []).map((p) => (
{(packages?.packages ?? []).map((p) => {
const stage = handoffStage(p);
return (
<details key={p.id} className="group">
<summary className="flex items-center gap-3 px-4 py-3 cursor-pointer hover:bg-zinc-900/60 flex-wrap">
<span className="text-sm text-white font-medium">#{p.packageNumber}</span>
@@ -251,10 +277,16 @@ export function DeliveriesClient({ canBuild }: { canBuild: boolean }) {
<span
className={cn(
"rounded-full border px-2 py-0.5 text-xs",
DELIVERY_STATUS_STYLES[p.status] ?? ""
// Built is not delivered: a package still owed to the
// client must not read as a green "READY".
stage ? "bg-amber-500/15 text-amber-400 border-amber-500/30" : (DELIVERY_STATUS_STYLES[p.status] ?? "")
)}
>
{statusLabel(p.status)}
{stage === "AWAITING_UPLOAD"
? "BUILT — NOT UPLOADED"
: stage === "AWAITING_SHARE"
? "UPLOADED — LINK NOT SHARED"
: statusLabel(p.status)}
</span>
<span className="text-xs text-zinc-500">
{p.episode ? `episode ${p.episode} · ` : ""}
@@ -263,17 +295,30 @@ export function DeliveriesClient({ canBuild }: { canBuild: boolean }) {
<span className="text-xs text-zinc-600">{new Date(p.createdAt).toLocaleDateString()}</span>
{p.status === "BUILDING" && <RefreshCw className="h-3.5 w-3.5 text-blue-400 animate-spin" />}
<span className="ml-auto flex items-center gap-2">
{canBuild && p.status === "READY" && p.kind === "EXR_DELIVERY" && (
{canBuild && stage === "AWAITING_UPLOAD" && (
<Button
size="sm"
variant="outline"
onClick={(e) => {
e.preventDefault();
packageAction(p.id, "delivered");
setHandoff({ step: "upload", packageId: p.id });
}}
>
<Upload className="h-4 w-4 mr-2 text-amber-400" />
Record upload
</Button>
)}
{canBuild && stage === "AWAITING_SHARE" && (
<Button
size="sm"
variant="outline"
onClick={(e) => {
e.preventDefault();
setHandoff({ step: "share", packageId: p.id });
}}
>
<CheckCircle2 className="h-4 w-4 mr-2 text-emerald-400" />
Mark delivered
Share link
</Button>
)}
{canBuild && ["QUEUED", "FAILED", "DRAFT"].includes(p.status) && (
@@ -282,7 +327,7 @@ export function DeliveriesClient({ canBuild }: { canBuild: boolean }) {
variant="outline"
onClick={(e) => {
e.preventDefault();
packageAction(p.id, "cancel");
cancelPackage(p.id);
}}
>
<XCircle className="h-4 w-4 mr-2 text-red-400" />
@@ -294,9 +339,31 @@ export function DeliveriesClient({ canBuild }: { canBuild: boolean }) {
<div className="px-4 pb-4 space-y-2">
{p.rootPath && <div className="text-xs text-zinc-500 break-all">{p.rootPath}</div>}
{p.errorMessage && <div className="text-xs text-red-400">{p.errorMessage}</div>}
{p.uploadedAt && p.uploadUrl && (
<div className="text-xs text-zinc-400 break-all">
Uploaded {new Date(p.uploadedAt).toLocaleString()}
{p.uploadedByName ? ` by ${p.uploadedByName}` : ""} —{" "}
<a href={p.uploadUrl} target="_blank" rel="noreferrer" className="text-sky-400 hover:underline">
{p.uploadUrl}
</a>
{canBuild && stage === "AWAITING_SHARE" && (
<button
type="button"
className="ml-2 text-zinc-500 hover:text-white underline"
onClick={() => setHandoff({ step: "upload", packageId: p.id })}
>
change
</button>
)}
</div>
)}
{p.deliveredAt && (
<div className="text-xs text-emerald-400">
Delivered {new Date(p.deliveredAt).toLocaleString()}
{p.sharedWith
? `Link shared with ${p.sharedWith} ${new Date(p.deliveredAt).toLocaleString()}`
: `Delivered ${new Date(p.deliveredAt).toLocaleString()} — before uploads and shares were recorded`}
{p.deliveredByName ? ` by ${p.deliveredByName}` : ""}
{p.sharedNote ? ` — ${p.sharedNote}` : ""}
</div>
)}
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-x-6 gap-y-1">
@@ -325,12 +392,24 @@ export function DeliveriesClient({ canBuild }: { canBuild: boolean }) {
</div>
</div>
</details>
))}
);
})}
{(packages?.packages ?? []).length === 0 && (
<div className="p-4 text-sm text-zinc-500">No packages built yet</div>
)}
</div>
</div>
{handoff?.step === "upload" && handoffPackage && (
<RecordUploadDialog pkg={handoffPackage} onClose={() => setHandoff(null)} onSaved={refreshPackages} />
)}
{handoff?.step === "share" && handoffPackage?.uploadUrl && (
<RecordShareDialog
pkg={{ ...handoffPackage, uploadUrl: handoffPackage.uploadUrl }}
onClose={() => setHandoff(null)}
onSaved={refreshPackages}
/>
)}
</div>
);
}
@@ -0,0 +1,218 @@
"use client";
import { useState } from "react";
import { Copy } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { useToast } from "@/components/ui/use-toast";
import { buildShareMessage } from "@/lib/render-pipeline/delivery-handoff";
export interface HandoffPackage {
id: string;
packageNumber: number;
name: string;
projectCode: string;
episode: string;
rootPath: string | null;
uploadUrl: string | null;
itemCount: number;
exrFileCount: number;
movCount: number;
totalBytes: number;
items: { shotCode: string; clientCode: string | null; versionString: string }[];
}
function formatSize(bytes: number): string {
if (!bytes) return "size unknown";
const gb = bytes / 1_073_741_824;
return gb >= 1 ? `${gb.toFixed(1)} GB` : `${(bytes / 1_048_576).toFixed(0)} MB`;
}
async function post(path: string, body: unknown): Promise<string | null> {
const res = await fetch(path, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (res.ok) return null;
const data = await res.json().catch(() => ({}));
return data.error ?? res.statusText;
}
/** Step one: where the folder now lives on the client's side. */
export function RecordUploadDialog({
pkg,
onClose,
onSaved,
}: {
pkg: HandoffPackage;
onClose: () => void;
onSaved: () => void;
}) {
const { toast } = useToast();
const [uploadUrl, setUploadUrl] = useState(pkg.uploadUrl ?? "");
const [confirmed, setConfirmed] = useState(false);
const [saving, setSaving] = useState(false);
async function save() {
setSaving(true);
const error = await post(`/api/deliveries/${pkg.id}/uploaded`, { uploadUrl, contentsConfirmed: confirmed });
setSaving(false);
if (error) {
toast({ title: "Upload not recorded", description: error, variant: "destructive" });
return;
}
onSaved();
onClose();
}
return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle>
Record upload — #{pkg.packageNumber} {pkg.name}
</DialogTitle>
<DialogDescription>
Paste the link to the folder on the client&apos;s portal. The package stays on the dashboard as
awaiting delivery until the link has also been shared.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="rounded-md border border-zinc-800 bg-zinc-900/60 p-3 text-xs text-zinc-400 space-y-1">
<div className="text-zinc-200">
The upload should contain {pkg.itemCount} shot{pkg.itemCount === 1 ? "" : "s"} · {pkg.exrFileCount} EXR
frames · {pkg.movCount} MOV{pkg.movCount === 1 ? "" : "s"} · {formatSize(pkg.totalBytes)}
</div>
{pkg.rootPath && <div className="break-all">Source: {pkg.rootPath}</div>}
</div>
<div className="space-y-1.5">
<Label htmlFor="upload-url">Upload link</Label>
<Input
id="upload-url"
type="url"
placeholder="https://…"
value={uploadUrl}
onChange={(e) => setUploadUrl(e.target.value)}
autoFocus
/>
</div>
<label className="flex items-start gap-2 text-sm text-zinc-300">
<input
type="checkbox"
className="mt-1"
checked={confirmed}
onChange={(e) => setConfirmed(e.target.checked)}
/>
<span>I opened this link and the upload has finished and matches the counts above.</span>
</label>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button onClick={save} disabled={saving || !uploadUrl.trim() || !confirmed}>
Record upload
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
/** Step two: the link went to a person. This is what makes it delivered. */
export function RecordShareDialog({
pkg,
onClose,
onSaved,
}: {
pkg: HandoffPackage & { uploadUrl: string };
onClose: () => void;
onSaved: () => void;
}) {
const { toast } = useToast();
const [sharedWith, setSharedWith] = useState("");
const [note, setNote] = useState("");
const [saving, setSaving] = useState(false);
const message = buildShareMessage(pkg);
async function copy() {
try {
await navigator.clipboard.writeText(message);
toast({ title: "Message copied" });
} catch {
toast({ title: "Could not copy", description: "Select the text and copy it by hand.", variant: "destructive" });
}
}
async function save() {
setSaving(true);
const error = await post(`/api/deliveries/${pkg.id}/delivered`, { sharedWith, note: note || null });
setSaving(false);
if (error) {
toast({ title: "Not marked delivered", description: error, variant: "destructive" });
return;
}
onSaved();
onClose();
}
return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle>
Share link — #{pkg.packageNumber} {pkg.name}
</DialogTitle>
<DialogDescription>
Send this message to the client, then record who it went to. That marks the package delivered.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<Label htmlFor="share-message">Message</Label>
<Button size="sm" variant="outline" onClick={copy}>
<Copy className="h-3.5 w-3.5 mr-2" />
Copy message
</Button>
</div>
<Textarea id="share-message" readOnly value={message} rows={8} className="font-mono text-xs" />
</div>
<div className="space-y-1.5">
<Label htmlFor="shared-with">Sent to</Label>
<Input
id="shared-with"
placeholder="Name, and where — e.g. Sam, production WhatsApp group"
value={sharedWith}
onChange={(e) => setSharedWith(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="share-note">Note (optional)</Label>
<Input id="share-note" value={note} onChange={(e) => setNote(e.target.value)} />
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button onClick={save} disabled={saving || !sharedWith.trim()}>
Link sent — mark delivered
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,273 @@
"use client";
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Package, Plus, XCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { useToast } from "@/components/ui/use-toast";
import { cn } from "@/lib/utils";
import { LogRequestDialog } from "@/components/delivery/LogRequestDialog";
import {
DELIVERABLE_LABEL,
LINE_STAGE_LABEL,
type Deliverable,
type RequestLineStage,
} from "@/lib/render-pipeline/delivery-request-state";
interface RequestRow {
id: string;
requestNumber: number;
projectId: string;
projectCode: string;
episode: string;
deliverable: string;
requestedBy: string;
note: string | null;
dueDate: string | null;
createdAt: string;
cancelReason: string | null;
state: "OPEN" | "CLOSED" | "CANCELLED";
lineCount: number;
deliveredCount: number;
severity: "normal" | "warning" | "critical";
lines: {
id: string;
shotCode: string;
clientCode: string | null;
stage: RequestLineStage;
readyExportId: string | null;
}[];
}
const SEVERITY_STYLES: Record<RequestRow["severity"], string> = {
normal: "bg-zinc-500/15 text-zinc-300 border-zinc-500/30",
warning: "bg-amber-500/15 text-amber-400 border-amber-500/30",
critical: "bg-red-500/15 text-red-400 border-red-500/30",
};
const STAGE_STYLES: Record<RequestLineStage, string> = {
DELIVERED: "text-emerald-400",
UPLOADED: "text-amber-400",
BUILT: "text-amber-400",
BUILDING: "text-blue-400",
READY: "text-green-400",
IN_QC: "text-zinc-400",
NOT_READY: "text-zinc-500",
};
function requestTitle(r: RequestRow): string {
const what = DELIVERABLE_LABEL[r.deliverable as Deliverable] ?? r.deliverable;
return r.episode ? `${what} for ${r.episode}` : `${what} · ${r.lineCount} shot${r.lineCount === 1 ? "" : "s"}`;
}
/**
* What clients have asked for. An open request lists every shot with where it
* stands, so "did 116 go out?" is answered by the page rather than by memory.
*/
export function RequestsPanel({ projectId, canBuild }: { projectId: string; canBuild: boolean }) {
const { toast } = useToast();
const queryClient = useQueryClient();
const [logging, setLogging] = useState(false);
const [cancelling, setCancelling] = useState<RequestRow | null>(null);
const [reason, setReason] = useState("");
const [busy, setBusy] = useState(false);
const { data } = useQuery({
queryKey: ["delivery-requests", projectId],
queryFn: async () => {
const res = await fetch(`/api/delivery-requests${projectId ? `?projectId=${encodeURIComponent(projectId)}` : ""}`);
if (!res.ok) throw new Error("Failed to load delivery requests");
return res.json() as Promise<{ requests: RequestRow[] }>;
},
// Lines move as packages build and get shared on this same page.
refetchInterval: 5000,
});
const refresh = () => {
queryClient.invalidateQueries({ queryKey: ["delivery-requests"] });
queryClient.invalidateQueries({ queryKey: ["delivery-packages"] });
queryClient.invalidateQueries({ queryKey: ["delivery-ready"] });
};
async function build(request: RequestRow) {
const exportIds = request.lines.flatMap((l) => (l.readyExportId ? [l.readyExportId] : []));
if (!exportIds.length) return;
setBusy(true);
const res = await fetch("/api/deliveries", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ projectId: request.projectId, episode: request.episode || null, exportIds }),
});
const body = await res.json().catch(() => ({}));
setBusy(false);
if (!res.ok) {
toast({ title: "Build failed", description: body.error ?? res.statusText, variant: "destructive" });
} else {
toast({
title: `Package #${body.package.packageNumber} queued`,
description: `${body.package.itemCount} shots for request #${request.requestNumber}`,
});
}
refresh();
}
async function cancel() {
if (!cancelling) return;
setBusy(true);
const res = await fetch(`/api/delivery-requests/${cancelling.id}/cancel`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ reason }),
});
const body = await res.json().catch(() => ({}));
setBusy(false);
if (!res.ok) {
toast({ title: "Not cancelled", description: body.error ?? res.statusText, variant: "destructive" });
return;
}
setCancelling(null);
refresh();
}
const requests = data?.requests ?? [];
const open = requests.filter((r) => r.state === "OPEN");
const past = requests.filter((r) => r.state !== "OPEN");
return (
<div className="rounded-lg border border-zinc-800 bg-zinc-900/40">
<div className="flex items-center justify-between gap-3 px-4 py-2.5 border-b border-zinc-800">
<span className="text-sm font-medium text-white">Delivery requests</span>
{canBuild && (
<Button size="sm" variant="outline" onClick={() => setLogging(true)}>
<Plus className="h-3.5 w-3.5 mr-1.5" />
Log request
</Button>
)}
</div>
{open.length === 0 && <div className="p-4 text-sm text-zinc-500">No open requests.</div>}
<div className="divide-y divide-zinc-800/60">
{open.map((r) => {
const readyCount = r.lines.filter((l) => l.readyExportId).length;
return (
<details key={r.id} open className="group">
<summary className="flex items-center gap-3 px-4 py-3 cursor-pointer hover:bg-zinc-900/60 flex-wrap">
<span className="text-sm text-white font-medium">Request #{r.requestNumber}</span>
<span className="text-sm text-zinc-300">{requestTitle(r)}</span>
<span className={cn("rounded-full border px-2 py-0.5 text-xs", SEVERITY_STYLES[r.severity])}>
{r.deliveredCount} of {r.lineCount} delivered
</span>
<span className="text-xs text-zinc-500">
{r.projectCode} · asked by {r.requestedBy} · {new Date(r.createdAt).toLocaleDateString()}
{r.dueDate ? ` · due ${new Date(r.dueDate).toLocaleDateString()}` : ""}
</span>
<span className="ml-auto flex items-center gap-2">
{canBuild && readyCount > 0 && (
<Button
size="sm"
disabled={busy}
onClick={(e) => {
e.preventDefault();
build(r);
}}
>
<Package className="h-4 w-4 mr-2" />
Build {readyCount} ready
</Button>
)}
{canBuild && (
<Button
size="sm"
variant="outline"
onClick={(e) => {
e.preventDefault();
setReason("");
setCancelling(r);
}}
>
<XCircle className="h-4 w-4 mr-2 text-red-400" />
Cancel request
</Button>
)}
</span>
</summary>
<div className="px-4 pb-4 space-y-2">
{r.note && <div className="text-xs text-zinc-400">{r.note}</div>}
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-x-6 gap-y-1">
{r.lines.map((line) => (
<div key={line.id} className="flex items-center gap-2 text-xs">
<span className="text-zinc-300">{line.shotCode}</span>
{line.clientCode && <span className="text-zinc-600">{line.clientCode}</span>}
<span className={STAGE_STYLES[line.stage]}>{LINE_STAGE_LABEL[line.stage]}</span>
</div>
))}
</div>
</div>
</details>
);
})}
</div>
{past.length > 0 && (
<details className="border-t border-zinc-800">
<summary className="px-4 py-2.5 cursor-pointer text-xs text-zinc-500 hover:text-zinc-300">
Closed and cancelled ({past.length})
</summary>
<div className="px-4 pb-3 space-y-1">
{past.map((r) => (
<div key={r.id} className="text-xs text-zinc-500">
<span className="text-zinc-300">Request #{r.requestNumber}</span> {requestTitle(r)} · {r.projectCode} ·
asked by {r.requestedBy} ·{" "}
{r.state === "CANCELLED" ? (
<span className="text-zinc-400">cancelled — {r.cancelReason}</span>
) : (
<span className="text-emerald-400">all {r.lineCount} delivered</span>
)}
</div>
))}
</div>
</details>
)}
<LogRequestDialog
open={logging}
onClose={() => setLogging(false)}
onLogged={refresh}
defaultProjectId={projectId || undefined}
/>
{cancelling && (
<Dialog open onOpenChange={(o) => !o && !busy && setCancelling(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Cancel request #{cancelling.requestNumber}</DialogTitle>
<DialogDescription>
{cancelling.lineCount - cancelling.deliveredCount} of {cancelling.lineCount} shots have not been
delivered. Cancelling removes the request from Awaiting delivery; it stays in the history with the
reason.
</DialogDescription>
</DialogHeader>
<Input
aria-label="Reason"
placeholder="Why — e.g. client no longer needs 116"
value={reason}
onChange={(e) => setReason(e.target.value)}
autoFocus
/>
<DialogFooter>
<Button variant="outline" onClick={() => setCancelling(null)} disabled={busy}>
Keep request
</Button>
<Button variant="destructive" onClick={cancel} disabled={busy || !reason.trim()}>
Cancel request
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
</div>
);
}
@@ -35,6 +35,7 @@ import { cn, getInitials } from "@/lib/utils";
import { updateShotNotes, bulkUpdateDueDates, bulkAssignArtist, bulkDeleteShots, bulkSetManualDelivery, toggleKeyShot, toggleScaledShot } from "@/actions/shots";
import { previewMovReview, buildMovReview } from "@/actions/deliveries";
import { useToast } from "@/components/ui/use-toast";
import { LogRequestDialog } from "@/components/delivery/LogRequestDialog";
import {
Film,
Clock,
@@ -55,6 +56,7 @@ import {
Scaling,
UserPlus,
PackageCheck,
ClipboardList,
} from "lucide-react";
import { format } from "date-fns";
import { shotThumbnail } from "@/lib/shot-thumbnail";
@@ -373,11 +375,17 @@ function ManualDeliveryDialog({
}: {
open: boolean;
onClose: () => void;
onApply: (deliveredOn: string | null, note: string) => Promise<void>;
onApply: (
deliveredOn: string | null,
note: string,
evidence: { uploadUrl: string; sharedWith: string },
) => Promise<void>;
count: number;
}) {
const [date, setDate] = useState("");
const [note, setNote] = useState("");
const [uploadUrl, setUploadUrl] = useState("");
const [sharedWith, setSharedWith] = useState("");
const [clearing, setClearing] = useState(false);
const [isPending, startTransition] = useTransition();
@@ -385,6 +393,8 @@ function ManualDeliveryDialog({
if (open) {
setDate("");
setNote("");
setUploadUrl("");
setSharedWith("");
setClearing(false);
}
}, [open]);
@@ -392,7 +402,7 @@ function ManualDeliveryDialog({
const handleApply = () => {
if (!clearing && !date) return;
startTransition(async () => {
await onApply(clearing ? null : date, note);
await onApply(clearing ? null : date, note, { uploadUrl, sharedWith });
});
};
@@ -444,6 +454,30 @@ function ManualDeliveryDialog({
className="w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 [color-scheme:dark] focus:border-zinc-500 focus:outline-none focus:ring-1 focus:ring-zinc-500"
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-zinc-400">Upload link</label>
<input
type="url"
value={uploadUrl}
onChange={(e) => setUploadUrl(e.target.value)}
placeholder="https://…"
className="w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 placeholder:text-zinc-600 focus:border-zinc-500 focus:outline-none focus:ring-1 focus:ring-zinc-500"
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-zinc-400">Link sent to</label>
<input
type="text"
value={sharedWith}
onChange={(e) => setSharedWith(e.target.value)}
placeholder="Name, and where it was sent"
className="w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 placeholder:text-zinc-600 focus:border-zinc-500 focus:outline-none focus:ring-1 focus:ring-zinc-500"
/>
<p className="text-xs text-zinc-500">
Both are needed for shots on an open delivery request; a backfill of old deliveries can leave
them blank.
</p>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-zinc-400">Note (optional)</label>
<Textarea
@@ -1086,6 +1120,7 @@ export function ShotStatusClient({
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [movReviewDialogOpen, setMovReviewDialogOpen] = useState(false);
const [manualDeliveryDialogOpen, setManualDeliveryDialogOpen] = useState(false);
const [requestDialogOpen, setRequestDialogOpen] = useState(false);
const toggleEpisode = (ep: string) =>
setExpandedEpisodes((prev) => {
@@ -1185,10 +1220,19 @@ export function ShotStatusClient({
}
};
const handleManualDelivery = async (deliveredOn: string | null, note: string) => {
const handleManualDelivery = async (
deliveredOn: string | null,
note: string,
evidence: { uploadUrl: string; sharedWith: string },
) => {
try {
const result = await bulkSetManualDelivery([...selectedIds], deliveredOn, note);
const n = `${result.updated} shot${result.updated !== 1 ? "s" : ""}`;
const result = await bulkSetManualDelivery([...selectedIds], deliveredOn, note, evidence);
if (!result.success) {
// The dialog stays open so the missing link and recipient can be added.
toast({ title: "Not marked delivered", description: result.error, variant: "destructive" });
return;
}
const n =`${result.updated} shot${result.updated !== 1 ? "s" : ""}`;
toast({ title: deliveredOn ? `${n} marked delivered` : `Manual delivery cleared on ${n}` });
setManualDeliveryDialogOpen(false);
clearSelection();
@@ -1384,6 +1428,10 @@ export function ShotStatusClient({
<Clapperboard className="h-3.5 w-3.5 mr-2" />
Build MOV Review Package
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setRequestDialogOpen(true)}>
<ClipboardList className="h-3.5 w-3.5 mr-2" />
Log Delivery Request
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setManualDeliveryDialogOpen(true)}>
<PackageCheck className="h-3.5 w-3.5 mr-2" />
Delivered Outside Pipeline
@@ -1610,6 +1658,16 @@ export function ShotStatusClient({
count={selectionCount}
/>
{/* A client asked for the selected shots */}
{selectedProjectId && (
<LogRequestDialog
open={requestDialogOpen}
onClose={() => setRequestDialogOpen(false)}
onLogged={clearSelection}
preset={{ projectId: selectedProjectId, shotIds: [...selectedIds] }}
/>
)}
{/* Bulk delete dialog */}
<BulkDeleteDialog
open={deleteDialogOpen}
@@ -2,20 +2,30 @@ import { NextRequest, NextResponse } from "next/server";
import { toErrorResponse } from "@/lib/render-pipeline/errors";
import { requirePipelineAdmin } from "@/lib/render-pipeline/session-auth";
import { markDelivered } from "@/lib/render-pipeline/deliveries";
import { recordShareSchema } from "@/lib/render-pipeline/delivery-handoff";
// ── POST /api/deliveries/{id}/delivered (E19) ────────────────────────────────
//
// The folder has been uploaded to the client. Moves every packaged export to
// DELIVERED — the end of the pipeline.
// The upload link has been sent to the client. Takes who it was sent to, and
// moves every packaged export to DELIVERED — the end of the pipeline.
export async function POST(
_req: NextRequest,
req: NextRequest,
{ params }: { params: Promise<{ packageId: string }> }
) {
try {
const user = await requirePipelineAdmin();
const { packageId } = await params;
const result = await markDelivered(packageId, user.id);
const parsed = recordShareSchema.safeParse(await req.json().catch(() => null));
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.issues[0]?.message ?? "Invalid request", details: parsed.error.issues },
{ status: 422 }
);
}
const result = await markDelivered(packageId, parsed.data, user.id);
return NextResponse.json(result);
} catch (err) {
const { body, status } = toErrorResponse(err);
@@ -0,0 +1,34 @@
import { NextRequest, NextResponse } from "next/server";
import { toErrorResponse } from "@/lib/render-pipeline/errors";
import { requirePipelineAdmin } from "@/lib/render-pipeline/session-auth";
import { markUploaded } from "@/lib/render-pipeline/deliveries";
import { recordUploadSchema } from "@/lib/render-pipeline/delivery-handoff";
// ── POST /api/deliveries/{id}/uploaded ───────────────────────────────────────
//
// The folder is on the client's portal. Records where, so the package shows as
// waiting to be shared rather than waiting to be uploaded.
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ packageId: string }> }
) {
try {
const user = await requirePipelineAdmin();
const { packageId } = await params;
const parsed = recordUploadSchema.safeParse(await req.json().catch(() => null));
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.issues[0]?.message ?? "Invalid request", details: parsed.error.issues },
{ status: 422 }
);
}
const result = await markUploaded(packageId, parsed.data, user.id);
return NextResponse.json(result);
} catch (err) {
const { body, status } = toErrorResponse(err);
return NextResponse.json(body, { status });
}
}
@@ -0,0 +1,34 @@
import { NextRequest, NextResponse } from "next/server";
import { toErrorResponse } from "@/lib/render-pipeline/errors";
import { requirePipelineAdmin } from "@/lib/render-pipeline/session-auth";
import { cancelDeliveryRequest } from "@/lib/render-pipeline/delivery-requests";
import { cancelRequestSchema } from "@/lib/render-pipeline/delivery-request-state";
// ── POST /api/delivery-requests/{id}/cancel ──────────────────────────────────
//
// The only way a request leaves the awaiting list without being delivered, and
// it has to say why.
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ requestId: string }> }
) {
try {
const user = await requirePipelineAdmin();
const { requestId } = await params;
const parsed = cancelRequestSchema.safeParse(await req.json().catch(() => null));
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.issues[0]?.message ?? "Invalid request", details: parsed.error.issues },
{ status: 422 }
);
}
const result = await cancelDeliveryRequest(requestId, parsed.data.reason, user.id);
return NextResponse.json(result);
} catch (err) {
const { body, status } = toErrorResponse(err);
return NextResponse.json(body, { status });
}
}
@@ -0,0 +1,22 @@
import { NextRequest, NextResponse } from "next/server";
import { toErrorResponse } from "@/lib/render-pipeline/errors";
import { requirePipelineUser } from "@/lib/render-pipeline/session-auth";
import { getRequestOptions } from "@/lib/render-pipeline/delivery-requests";
// ── GET /api/delivery-requests/options?projectId= ────────────────────────────
//
// The episodes a request can be logged against.
export async function GET(req: NextRequest) {
try {
await requirePipelineUser();
const projectId = new URL(req.url).searchParams.get("projectId");
if (!projectId) {
return NextResponse.json({ error: "projectId is required" }, { status: 400 });
}
return NextResponse.json(await getRequestOptions(projectId));
} catch (err) {
const { body, status } = toErrorResponse(err);
return NextResponse.json(body, { status });
}
}
+44
View File
@@ -0,0 +1,44 @@
import { NextRequest, NextResponse } from "next/server";
import { toErrorResponse } from "@/lib/render-pipeline/errors";
import { requirePipelineAdmin, requirePipelineUser } from "@/lib/render-pipeline/session-auth";
import { createDeliveryRequest, listDeliveryRequests } from "@/lib/render-pipeline/delivery-requests";
import { createRequestSchema } from "@/lib/render-pipeline/delivery-request-state";
// ── GET /api/delivery-requests · POST /api/delivery-requests ─────────────────
//
// What clients have asked for, and how much of each ask has gone out.
export async function GET(req: NextRequest) {
try {
await requirePipelineUser();
const { searchParams } = new URL(req.url);
const requests = await listDeliveryRequests({
projectId: searchParams.get("projectId") ?? undefined,
openOnly: searchParams.get("open") === "1",
});
return NextResponse.json({ requests });
} catch (err) {
const { body, status } = toErrorResponse(err);
return NextResponse.json(body, { status });
}
}
export async function POST(req: NextRequest) {
try {
const user = await requirePipelineAdmin();
const parsed = createRequestSchema.safeParse(await req.json().catch(() => null));
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.issues[0]?.message ?? "Invalid request", details: parsed.error.issues },
{ status: 422 }
);
}
const result = await createDeliveryRequest(parsed.data, user.id);
return NextResponse.json(result, { status: 201 });
} catch (err) {
const { body, status } = toErrorResponse(err);
return NextResponse.json(body, { status });
}
}
+88
View File
@@ -0,0 +1,88 @@
import Link from "next/link";
import { format, formatDistanceToNowStrict } from "date-fns";
import { cn } from "@/lib/utils";
import type { AwaitingDelivery as AwaitingDeliveryRow } from "@/lib/dashboard/ops";
import type { DeliveryRequestView } from "@/lib/render-pipeline/delivery-requests";
import { DELIVERABLE_LABEL, type Deliverable } from "@/lib/render-pipeline/delivery-request-state";
const STAGE_LABEL: Record<AwaitingDeliveryRow["stage"], string> = {
AWAITING_UPLOAD: "Not uploaded",
AWAITING_SHARE: "Uploaded — link not shared",
};
const SEVERITY_STYLES: Record<AwaitingDeliveryRow["severity"], string> = {
normal: "bg-zinc-500/15 text-zinc-300 border-zinc-500/30",
warning: "bg-amber-500/15 text-amber-400 border-amber-500/30",
critical: "bg-red-500/15 text-red-400 border-red-500/30",
};
const ROW_CLASS =
"flex items-center gap-3 border-b border-zinc-800/60 px-4 py-2.5 last:border-b-0 hover:bg-zinc-900/40";
const PILL_CLASS = "shrink-0 rounded-full border px-2 py-0.5 text-xs whitespace-nowrap";
/**
* Everything a client is owed: what they asked for that has not fully gone
* out, and built packages they do not have yet. A row only leaves this list
* when the upload location and who the link went to are on record (or a
* request is cancelled with a reason) — never because time passed or someone
* remembers doing it.
*/
export function AwaitingDelivery({
requests,
packages,
}: {
requests: DeliveryRequestView[];
packages: AwaitingDeliveryRow[];
}) {
if (requests.length === 0 && packages.length === 0) {
return (
<div className="rounded-lg border border-zinc-800 bg-card px-4 py-4 text-sm text-zinc-500">
Nothing is owed to a client.
</div>
);
}
return (
<div className="rounded-lg border border-amber-500/30 bg-card overflow-hidden">
{requests.map((r) => (
<Link key={r.id} href="/pipeline/deliveries" className={ROW_CLASS}>
<div className="min-w-0 flex-1">
<div className="truncate text-[13px] text-white">
<span className="font-medium">Request #{r.requestNumber}</span>{" "}
<span className="text-zinc-300">
{DELIVERABLE_LABEL[r.deliverable as Deliverable] ?? r.deliverable}
{r.episode ? ` for ${r.episode}` : ""}
</span>{" "}
<span className="text-zinc-500">{r.projectCode}</span>
</div>
<div className="truncate text-xs text-zinc-500">
asked by {r.requestedBy} {formatDistanceToNowStrict(r.createdAt)} ago
{r.dueDate ? ` · due ${format(r.dueDate, "d MMM")}` : ""}
</div>
</div>
<span className={cn(PILL_CLASS, SEVERITY_STYLES[r.severity])}>
{r.deliveredCount} of {r.lineCount} delivered
</span>
</Link>
))}
{packages.map((p) => (
<Link key={p.id} href="/pipeline/deliveries" className={ROW_CLASS}>
<div className="min-w-0 flex-1">
<div className="truncate text-[13px] text-white">
<span className="font-mono font-medium">#{p.packageNumber}</span>{" "}
<span className="text-zinc-300">{p.name}</span>{" "}
<span className="text-zinc-500">
{p.projectCode}
{p.episode ? ` · ${p.episode}` : ""}
</span>
</div>
<div className="truncate text-xs text-zinc-500">
{p.itemCount} shot{p.itemCount === 1 ? "" : "s"} · built {formatDistanceToNowStrict(p.builtAt)} ago
</div>
</div>
<span className={cn(PILL_CLASS, SEVERITY_STYLES[p.severity])}>{STAGE_LABEL[p.stage]}</span>
</Link>
))}
</div>
);
}
+23
View File
@@ -0,0 +1,23 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Plus } from "lucide-react";
import { Button } from "@/components/ui/button";
import { LogRequestDialog } from "@/components/delivery/LogRequestDialog";
/** The dashboard's way in to logging what a client just asked for. */
export function LogRequestButton() {
const router = useRouter();
const [open, setOpen] = useState(false);
return (
<>
<Button size="sm" variant="outline" onClick={() => setOpen(true)}>
<Plus className="h-3.5 w-3.5 mr-1.5" />
Log request
</Button>
<LogRequestDialog open={open} onClose={() => setOpen(false)} onLogged={() => router.refresh()} />
</>
);
}
+262
View File
@@ -0,0 +1,262 @@
"use client";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { useToast } from "@/components/ui/use-toast";
import { DELIVERABLES, DELIVERABLE_LABEL, parseShotCodes, type Deliverable } from "@/lib/render-pipeline/delivery-request-state";
const SELECT_CLASS = "w-full rounded-md border border-zinc-800 bg-zinc-900 px-3 py-2 text-sm text-zinc-200";
interface ProjectOption {
id: string;
name: string;
code: string;
}
/**
* Log what a client asked for. The ask arrives over chat, so this has to be
* quicker than deciding to do it later: a project, the shots, who asked.
*
* With `preset` the shots are already chosen (the shot-status selection);
* otherwise they come from an episode or a pasted list of codes.
*/
export function LogRequestDialog({
open,
onClose,
onLogged,
preset,
defaultProjectId,
}: {
open: boolean;
onClose: () => void;
onLogged: () => void;
preset?: { projectId: string; shotIds: string[] };
defaultProjectId?: string;
}) {
const { toast } = useToast();
const [projects, setProjects] = useState<ProjectOption[]>([]);
const [projectId, setProjectId] = useState("");
const [episodes, setEpisodes] = useState<{ episode: string; shotCount: number }[]>([]);
const [mode, setMode] = useState<"episode" | "codes">("episode");
const [episode, setEpisode] = useState("");
const [codes, setCodes] = useState("");
const [deliverable, setDeliverable] = useState<Deliverable>("EXR");
const [requestedBy, setRequestedBy] = useState("");
const [dueDate, setDueDate] = useState("");
const [note, setNote] = useState("");
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
useEffect(() => {
if (!open) return;
setProjectId(preset?.projectId ?? defaultProjectId ?? "");
setMode("episode");
setEpisode("");
setCodes("");
setDeliverable("EXR");
setRequestedBy("");
setDueDate("");
setNote("");
setError(null);
if (preset) return;
fetch("/api/projects")
.then((res) => (res.ok ? res.json() : { projects: [] }))
.then((body) => setProjects((body.projects ?? body) as ProjectOption[]))
.catch(() => setProjects([]));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
useEffect(() => {
setEpisodes([]);
setEpisode("");
if (!open || preset || !projectId) return;
let stale = false;
fetch(`/api/delivery-requests/options?projectId=${encodeURIComponent(projectId)}`)
.then((res) => (res.ok ? res.json() : { episodes: [] }))
.then((body) => {
if (!stale) setEpisodes(body.episodes ?? []);
})
.catch(() => {});
return () => {
stale = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, projectId]);
const codeCount = parseShotCodes(codes).length;
const hasShots = preset ? preset.shotIds.length > 0 : mode === "episode" ? Boolean(episode) : codeCount > 0;
const canSave = Boolean(projectId) && hasShots && requestedBy.trim().length > 0 && !saving;
async function save() {
setSaving(true);
setError(null);
const res = await fetch("/api/delivery-requests", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
projectId,
...(preset ? { shotIds: preset.shotIds } : mode === "episode" ? { episode } : { shotCodes: codes }),
deliverable,
requestedBy,
dueDate: dueDate || null,
note: note || null,
}),
});
const body = await res.json().catch(() => ({}));
setSaving(false);
if (!res.ok) {
// Shown in place rather than as a toast: an unmatched code has to be
// fixed in the text right above it.
setError(body.error ?? res.statusText);
return;
}
toast({
title: `Request #${body.request.requestNumber} logged`,
description: `${body.request.lineCount} shot${body.request.lineCount === 1 ? "" : "s"} awaiting delivery`,
});
onLogged();
onClose();
}
return (
<Dialog open={open} onOpenChange={(o) => !o && !saving && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle>Log delivery request</DialogTitle>
<DialogDescription>
What the client asked for. It stays under Awaiting delivery until every shot has been uploaded and the
link shared.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{preset ? (
<p className="text-sm text-zinc-300">
{preset.shotIds.length} selected shot{preset.shotIds.length === 1 ? "" : "s"}
</p>
) : (
<>
<div className="space-y-1.5">
<Label htmlFor="request-project">Project</Label>
<select
id="request-project"
value={projectId}
onChange={(e) => setProjectId(e.target.value)}
className={SELECT_CLASS}
>
<option value="">Select a project…</option>
{projects.map((p) => (
<option key={p.id} value={p.id}>
{p.name} ({p.code})
</option>
))}
</select>
</div>
<div className="space-y-2">
<div className="flex items-center gap-4 text-sm text-zinc-200">
<label className="flex items-center gap-2 cursor-pointer">
<input type="radio" checked={mode === "episode"} onChange={() => setMode("episode")} />
Whole episode
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input type="radio" checked={mode === "codes"} onChange={() => setMode("codes")} />
Paste shot codes
</label>
</div>
{mode === "episode" ? (
<select
aria-label="Episode"
value={episode}
onChange={(e) => setEpisode(e.target.value)}
disabled={!projectId}
className={SELECT_CLASS}
>
<option value="">{projectId ? "Select an episode…" : "Pick a project first"}</option>
{episodes.map((e) => (
<option key={e.episode} value={e.episode}>
{e.episode} — {e.shotCount} shots
</option>
))}
</select>
) : (
<div className="space-y-1">
<Textarea
aria-label="Shot codes"
value={codes}
onChange={(e) => setCodes(e.target.value)}
rows={4}
placeholder="Paste from the client's message — our codes or theirs, any separator"
className="font-mono text-xs"
/>
<p className="text-xs text-zinc-500">
{codeCount} code{codeCount === 1 ? "" : "s"} found
</p>
</div>
)}
</div>
</>
)}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="request-deliverable">Wanted</Label>
<select
id="request-deliverable"
value={deliverable}
onChange={(e) => setDeliverable(e.target.value as Deliverable)}
className={SELECT_CLASS}
>
{DELIVERABLES.map((d) => (
<option key={d} value={d}>
{DELIVERABLE_LABEL[d]}
</option>
))}
</select>
</div>
<div className="space-y-1.5">
<Label htmlFor="request-due">Due (optional)</Label>
<Input
id="request-due"
type="date"
value={dueDate}
onChange={(e) => setDueDate(e.target.value)}
className="[color-scheme:dark]"
/>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="request-by">Asked by</Label>
<Input
id="request-by"
placeholder="Who at the client asked"
value={requestedBy}
onChange={(e) => setRequestedBy(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="request-note">Note (optional)</Label>
<Input id="request-note" value={note} onChange={(e) => setNote(e.target.value)} />
</div>
{error && <p className="text-sm text-red-400 break-words">{error}</p>}
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={saving}>
Cancel
</Button>
<Button onClick={save} disabled={!canSave}>
Log request
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+67
View File
@@ -1,6 +1,7 @@
import { db } from "@/lib/db";
import { getConfigNumber } from "@/lib/render-pipeline/config";
import { listMachines } from "@/lib/render-pipeline/machines";
import { handoffStage, type HandoffStage } from "@/lib/render-pipeline/delivery-handoff";
import type { ExportStatus, Prisma } from "@prisma/client";
/**
@@ -596,6 +597,72 @@ export async function getRecentDeliveries(limit = 5): Promise<DeliverySummary[]>
});
}
export interface AwaitingDelivery {
id: string;
packageNumber: number;
name: string;
projectCode: string;
episode: string;
itemCount: number;
stage: HandoffStage;
/** When the folder was ready to go — the clock the client is waiting on. */
builtAt: Date;
severity: "normal" | "warning" | "critical";
}
/**
* Built packages the client does not have yet.
*
* A READY package is the easiest thing in the pipeline to believe is done: the
* folder exists and every file verified, but nothing has left the building.
* It stays listed until the upload location and the person it was shared with
* are both on record, and gets louder the longer that takes.
*/
export async function getAwaitingDelivery(): Promise<AwaitingDelivery[]> {
const rows = await db.deliveryPackage.findMany({
where: {
kind: "EXR_DELIVERY",
status: "READY",
project: { status: { in: ["ACTIVE", "ON_HOLD"] } },
},
orderBy: { builtAt: "asc" },
select: {
id: true,
packageNumber: true,
name: true,
kind: true,
status: true,
episode: true,
createdAt: true,
builtAt: true,
uploadedAt: true,
project: { select: { code: true } },
_count: { select: { items: true } },
},
});
const day = 24 * 3600_000;
return rows.flatMap((row) => {
const stage = handoffStage(row);
if (!stage) return [];
const builtAt = row.builtAt ?? row.createdAt;
const age = Date.now() - builtAt.getTime();
return [
{
id: row.id,
packageNumber: row.packageNumber,
name: row.name,
projectCode: row.project.code,
episode: row.episode,
itemCount: row._count.items,
stage,
builtAt,
severity: age >= 2 * day ? "critical" : age >= day ? "warning" : "normal",
},
];
});
}
export interface CourtSide {
count: number;
items: { id: string; shotCode: string; label: string; href: string }[];
+83 -13
View File
@@ -5,6 +5,7 @@ import { PipelineError } from "./errors";
import { applyTransition } from "./transitions";
import { getConfigNumber, getConfigValue } from "./config";
import { clientShotCode } from "@/lib/shot-code";
import type { RecordShareInput, RecordUploadInput } from "./delivery-handoff";
/**
* Delivery builder (RenderPipeline2 §11). The web app decides *what* goes in a
@@ -640,11 +641,33 @@ export async function listDeliveryPackages(filter: {
},
orderBy: { createdAt: "desc" },
include: {
items: { select: { id: true, shotCode: true, versionString: true, status: true, fileCount: true, linkMode: true, errorMessage: true } },
items: {
select: {
id: true,
shotCode: true,
clientCode: true,
versionString: true,
status: true,
fileCount: true,
totalBytes: true,
movPath: true,
linkMode: true,
errorMessage: true,
},
},
project: { select: { id: true, name: true, code: true } },
},
});
// Who recorded each handoff step — the ids are plain columns, not relations.
const userIds = [
...new Set(packages.flatMap((p) => [p.uploadedById, p.deliveredById]).filter((id): id is string => !!id)),
];
const users = userIds.length
? await db.user.findMany({ where: { id: { in: userIds } }, select: { id: true, name: true, email: true } })
: [];
const userName = new Map(users.map((u) => [u.id, u.name ?? u.email]));
return {
packages: packages.map((p) => ({
id: p.id,
@@ -659,30 +682,71 @@ export async function listDeliveryPackages(filter: {
projectCode: p.project.code,
createdAt: p.createdAt,
builtAt: p.builtAt,
uploadUrl: p.uploadUrl,
uploadedAt: p.uploadedAt,
uploadedByName: p.uploadedById ? (userName.get(p.uploadedById) ?? null) : null,
sharedWith: p.sharedWith,
sharedNote: p.sharedNote,
deliveredAt: p.deliveredAt,
deliveredByName: p.deliveredById ? (userName.get(p.deliveredById) ?? null) : null,
itemCount: p.items.length,
verifiedCount: p.items.filter((i) => i.status === "VERIFIED").length,
items: p.items,
// What the upload should contain, for the person comparing it against
// the client's portal.
exrFileCount: p.items.reduce((sum, i) => sum + (i.fileCount ?? 0), 0),
movCount: p.items.filter((i) => i.movPath).length,
totalBytes: p.items.reduce((sum, i) => sum + Number(i.totalBytes ?? 0), 0),
items: p.items.map(({ totalBytes: _bytes, movPath: _mov, ...item }) => item),
})),
};
}
/** E19 — the human uploaded the folder to the client and is confirming it. */
export async function markDelivered(packageId: string, userId: string) {
function assertHandoffAllowed(pkg: { kind: DeliveryKind; status: DeliveryStatus }, verb: string) {
if (pkg.kind !== "EXR_DELIVERY") {
throw new PipelineError(
409,
"A MOV review package is shared for review, not delivered — it has no delivered state"
);
}
if (pkg.status !== "READY") {
throw new PipelineError(409, `Package is ${pkg.status} — only a READY package can be ${verb}`);
}
}
/**
* Step one of the handoff: the folder is on the client's portal, and this is
* where. Repeatable while the package is READY, so a wrong link can be
* corrected before it is shared.
*/
export async function markUploaded(packageId: string, input: RecordUploadInput, userId: string) {
return db.$transaction(async (tx) => {
const pkg = await tx.deliveryPackage.findUnique({ where: { id: packageId } });
if (!pkg) throw new PipelineError(404, "Delivery package not found");
assertHandoffAllowed(pkg, "recorded as uploaded");
const updated = await tx.deliveryPackage.update({
where: { id: pkg.id },
data: { uploadUrl: input.uploadUrl, uploadedAt: new Date(), uploadedById: userId },
});
return { package: { id: updated.id, status: updated.status, uploadUrl: updated.uploadUrl } };
});
}
/**
* E19 — step two: the link was sent to a named person, which is what makes the
* package DELIVERED. Refused until the upload location is on record, so
* "delivered" always has somewhere to point.
*/
export async function markDelivered(packageId: string, input: RecordShareInput, userId: string) {
return db.$transaction(async (tx) => {
const pkg = await tx.deliveryPackage.findUnique({ where: { id: packageId }, include: { items: true } });
if (!pkg) throw new PipelineError(404, "Delivery package not found");
if (pkg.status === "DELIVERED") {
return { package: { id: pkg.id, status: pkg.status }, alreadyApplied: true };
}
if (pkg.status !== "READY") {
throw new PipelineError(409, `Package is ${pkg.status} — only a READY package can be marked delivered`);
}
if (pkg.kind !== "EXR_DELIVERY") {
throw new PipelineError(
409,
"A MOV review package is shared for review, not delivered — it has no delivered state"
);
assertHandoffAllowed(pkg, "marked delivered");
if (!pkg.uploadedAt || !pkg.uploadUrl) {
throw new PipelineError(409, "Record where the package was uploaded before marking it delivered");
}
for (const item of pkg.items) {
@@ -698,7 +762,13 @@ export async function markDelivered(packageId: string, userId: string) {
const updated = await tx.deliveryPackage.update({
where: { id: pkg.id },
data: { status: "DELIVERED", deliveredAt: new Date(), deliveredById: userId },
data: {
status: "DELIVERED",
sharedWith: input.sharedWith,
sharedNote: input.note || null,
deliveredAt: new Date(),
deliveredById: userId,
},
});
return { package: { id: updated.id, status: updated.status }, alreadyApplied: false };
});
@@ -0,0 +1,72 @@
import { describe, expect, it } from "vitest";
import { buildShareMessage, handoffStage, recordShareSchema, recordUploadSchema } from "./delivery-handoff";
describe("handoffStage", () => {
const ready = { kind: "EXR_DELIVERY", status: "READY", uploadedAt: null };
it("waits on the upload first, then on the share", () => {
expect(handoffStage(ready)).toBe("AWAITING_UPLOAD");
expect(handoffStage({ ...ready, uploadedAt: new Date() })).toBe("AWAITING_SHARE");
});
it("waits on nothing before the build or after delivery", () => {
expect(handoffStage({ ...ready, status: "BUILDING" })).toBeNull();
expect(handoffStage({ ...ready, status: "FAILED" })).toBeNull();
expect(handoffStage({ ...ready, status: "DELIVERED", uploadedAt: new Date() })).toBeNull();
});
it("never asks for a review package to be delivered", () => {
expect(handoffStage({ ...ready, kind: "MOV_REVIEW" })).toBeNull();
});
});
describe("recordUploadSchema", () => {
const url = "https://contenthub.example.com/folders/abc";
it("accepts a link with the contents confirmed", () => {
expect(recordUploadSchema.safeParse({ uploadUrl: url, contentsConfirmed: true }).success).toBe(true);
});
it("refuses an unconfirmed upload", () => {
expect(recordUploadSchema.safeParse({ uploadUrl: url, contentsConfirmed: false }).success).toBe(false);
expect(recordUploadSchema.safeParse({ uploadUrl: url }).success).toBe(false);
});
it("refuses anything that is not a web link", () => {
for (const uploadUrl of ["", "uploaded", "//SAN/DELIVERY/ACME/101", "javascript:alert(1)"]) {
expect(recordUploadSchema.safeParse({ uploadUrl, contentsConfirmed: true }).success).toBe(false);
}
});
});
describe("recordShareSchema", () => {
it("needs a recipient", () => {
expect(recordShareSchema.safeParse({ sharedWith: " " }).success).toBe(false);
expect(recordShareSchema.safeParse({ sharedWith: "Sam (post super)" }).success).toBe(true);
});
});
describe("buildShareMessage", () => {
it("lists the packaged versions under the client's own shot codes", () => {
const message = buildShareMessage({
projectCode: "UNG",
episode: "116",
name: "20260930_Delivery",
uploadUrl: "https://contenthub.example.com/folders/abc",
items: [
{ shotCode: "UNG_116_020", clientCode: null, versionString: "v002" },
{ shotCode: "UNG_116_010", clientCode: "116_A_0010", versionString: "v004" },
],
});
expect(message).toBe(
[
"Delivery: UNG · episode 116 · 20260930_Delivery",
"2 shots",
"https://contenthub.example.com/folders/abc",
"",
"116_A_0010 v004",
"UNG_116_020 v002",
].join("\n")
);
});
});
+73
View File
@@ -0,0 +1,73 @@
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");
}
@@ -0,0 +1,113 @@
import { describe, expect, it } from "vitest";
import {
createRequestSchema,
matchShotCodes,
parseShotCodes,
resolveLineStage,
waitingSeverity,
} from "./delivery-request-state";
describe("parseShotCodes", () => {
it("takes codes out of a pasted message whatever separates them", () => {
expect(parseShotCodes("UNG_116_010, UNG_116_020\nUNG_116_030; 116-A-0040")).toEqual([
"UNG_116_010",
"UNG_116_020",
"UNG_116_030",
"116-A-0040",
]);
});
it("drops repeats regardless of case", () => {
expect(parseShotCodes("ung_116_010 UNG_116_010")).toEqual(["ung_116_010"]);
});
});
describe("matchShotCodes", () => {
const shots = [
{ id: "a", shotCode: "UNG_116_010", clientCode: "116_A_0010" },
{ id: "b", shotCode: "UNG_116_020", clientCode: null },
];
it("matches our code or the client's, in any case", () => {
const { matched, unmatched } = matchShotCodes(["116_a_0010", "ung_116_020"], shots);
expect(matched.map((s) => s.id)).toEqual(["a", "b"]);
expect(unmatched).toEqual([]);
});
it("reports what it could not match instead of dropping it", () => {
expect(matchShotCodes(["UNG_116_010", "UNG_116_999"], shots).unmatched).toEqual(["UNG_116_999"]);
});
it("counts a shot once when both of its codes are given", () => {
expect(matchShotCodes(["UNG_116_010", "116_A_0010"], shots).matched).toHaveLength(1);
});
});
describe("resolveLineStage", () => {
const logged = new Date("2026-09-30T14:00:00Z");
const before = new Date("2026-09-20T10:00:00Z");
const after = new Date("2026-10-01T10:00:00Z");
const base = {
requestCreatedAt: logged,
packages: [],
manual: { deliveredAt: null, url: null, sharedWith: null },
exportStatuses: [],
};
it("walks a shot from not ready through to delivered", () => {
expect(resolveLineStage(base)).toBe("NOT_READY");
expect(resolveLineStage({ ...base, exportStatuses: ["READY_FOR_QC"] })).toBe("IN_QC");
expect(resolveLineStage({ ...base, exportStatuses: ["READY_FOR_QC", "READY_FOR_DELIVERY"] })).toBe("READY");
const pkg = { status: "BUILDING", uploadedAt: null, deliveredAt: null };
expect(resolveLineStage({ ...base, packages: [pkg] })).toBe("BUILDING");
expect(resolveLineStage({ ...base, packages: [{ ...pkg, status: "READY" }] })).toBe("BUILT");
expect(resolveLineStage({ ...base, packages: [{ ...pkg, status: "READY", uploadedAt: after }] })).toBe("UPLOADED");
expect(
resolveLineStage({ ...base, packages: [{ status: "DELIVERED", uploadedAt: after, deliveredAt: after }] })
).toBe("DELIVERED");
});
it("does not let a delivery from before the request close it", () => {
expect(
resolveLineStage({ ...base, packages: [{ status: "DELIVERED", uploadedAt: before, deliveredAt: before }] })
).toBe("NOT_READY");
});
it("closes on a manual delivery only with a link and a recipient", () => {
const sameDay = new Date("2026-09-30T12:00:00Z");
const manual = { deliveredAt: sameDay, url: "https://x.example/f", sharedWith: "Sam" };
expect(resolveLineStage({ ...base, manual })).toBe("DELIVERED");
expect(resolveLineStage({ ...base, manual: { ...manual, url: null } })).toBe("NOT_READY");
expect(resolveLineStage({ ...base, manual: { ...manual, sharedWith: null } })).toBe("NOT_READY");
expect(resolveLineStage({ ...base, manual: { ...manual, deliveredAt: before } })).toBe("NOT_READY");
});
});
describe("waitingSeverity", () => {
const now = new Date("2026-09-30T12:00:00Z").getTime();
const hoursAgo = (h: number) => new Date(now - h * 3600_000);
it("escalates with age", () => {
expect(waitingSeverity(hoursAgo(2), null, now)).toBe("normal");
expect(waitingSeverity(hoursAgo(30), null, now)).toBe("warning");
expect(waitingSeverity(hoursAgo(50), null, now)).toBe("critical");
});
it("is critical once past the due date, however new", () => {
expect(waitingSeverity(hoursAgo(2), hoursAgo(1), now)).toBe("critical");
});
});
describe("createRequestSchema", () => {
const base = { projectId: "p", deliverable: "EXR", requestedBy: "Sam" };
it("needs exactly one way of naming the shots", () => {
expect(createRequestSchema.safeParse(base).success).toBe(false);
expect(createRequestSchema.safeParse({ ...base, episode: "116" }).success).toBe(true);
expect(createRequestSchema.safeParse({ ...base, episode: "116", shotCodes: "UNG_116_010" }).success).toBe(false);
});
it("needs to know who asked", () => {
expect(createRequestSchema.safeParse({ ...base, episode: "116", requestedBy: " " }).success).toBe(false);
});
});
@@ -0,0 +1,163 @@
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;
}
+256
View File
@@ -0,0 +1,256 @@
import { db } from "@/lib/db";
import { PipelineError } from "./errors";
import {
matchShotCodes,
parseShotCodes,
resolveLineStage,
waitingSeverity,
type CreateRequestInput,
type RequestLineStage,
} from "./delivery-request-state";
/**
* Delivery requests: what a client asked for, held open until it has gone out.
*
* A request stores only the ask and its shot list. Whether each shot has been
* delivered is read from the packages and manual deliveries themselves, so a
* request cannot say "done" when the delivery record does not.
*/
export async function createDeliveryRequest(input: CreateRequestInput, userId: string) {
const project = await db.project.findUnique({ where: { id: input.projectId }, select: { id: true } });
if (!project) throw new PipelineError(404, "Project not found");
const select = { id: true, shotCode: true, clientCode: true } as const;
let shots: { id: string; shotCode: string; clientCode: string | null }[];
if (input.episode) {
// Snapshot: the request is for the shots in the episode today. A shot
// added to the episode later is a new ask, not a silent change to this one.
shots = await db.shot.findMany({ where: { projectId: project.id, episode: input.episode }, select });
if (!shots.length) throw new PipelineError(422, `No shots in episode ${input.episode}`);
} else if (input.shotIds) {
const ids = [...new Set(input.shotIds)];
shots = await db.shot.findMany({ where: { projectId: project.id, id: { in: ids } }, select });
if (shots.length !== ids.length) {
throw new PipelineError(422, "Some of the selected shots are not in this project");
}
} else {
const codes = parseShotCodes(input.shotCodes ?? "");
const all = await db.shot.findMany({ where: { projectId: project.id }, select });
const { matched, unmatched } = matchShotCodes(codes, all);
if (unmatched.length) {
throw new PipelineError(
422,
`${unmatched.length} code${unmatched.length === 1 ? "" : "s"} did not match a shot: ${unmatched.slice(0, 10).join(", ")}${unmatched.length > 10 ? "…" : ""}`,
{ unmatched }
);
}
shots = matched;
if (!shots.length) throw new PipelineError(422, "No shot codes found in the text");
}
return db.$transaction(async (tx) => {
const last = await tx.deliveryRequest.aggregate({
where: { projectId: project.id },
_max: { requestNumber: true },
});
const request = await tx.deliveryRequest.create({
data: {
projectId: project.id,
requestNumber: (last._max.requestNumber ?? 0) + 1,
episode: input.episode ?? "",
deliverable: input.deliverable,
requestedBy: input.requestedBy,
note: input.note || null,
// Noon UTC so the calendar day survives any timezone it is shown in.
dueDate: input.dueDate ? new Date(`${input.dueDate}T12:00:00.000Z`) : null,
createdById: userId,
lines: {
create: shots.map((s) => ({ shotId: s.id, shotCode: s.shotCode, clientCode: s.clientCode })),
},
},
});
return { request: { id: request.id, requestNumber: request.requestNumber, lineCount: shots.length } };
});
}
export async function cancelDeliveryRequest(requestId: string, reason: string, userId: string) {
const request = await db.deliveryRequest.findUnique({ where: { id: requestId } });
if (!request) throw new PipelineError(404, "Delivery request not found");
if (request.cancelledAt) return { request: { id: request.id }, alreadyApplied: true };
await db.deliveryRequest.update({
where: { id: request.id },
data: { cancelledAt: new Date(), cancelledById: userId, cancelReason: reason },
});
return { request: { id: request.id }, alreadyApplied: false };
}
export interface DeliveryRequestLineView {
id: string;
shotId: string;
shotCode: string;
clientCode: string | null;
stage: RequestLineStage;
/** The export to package when the stage is READY. */
readyExportId: string | null;
}
export interface DeliveryRequestView {
id: string;
requestNumber: number;
projectId: string;
projectCode: string;
episode: string;
deliverable: string;
requestedBy: string;
note: string | null;
dueDate: Date | null;
createdAt: Date;
cancelledAt: Date | null;
cancelReason: string | null;
state: "OPEN" | "CLOSED" | "CANCELLED";
lineCount: number;
deliveredCount: number;
severity: "normal" | "warning" | "critical";
lines: DeliveryRequestLineView[];
}
/**
* Requests with each line's stage worked out from the delivery record.
* `openOnly` is what the dashboard wants; the deliveries page takes the recent
* history too.
*/
export async function listDeliveryRequests(
filter: { projectId?: string; openOnly?: boolean } = {}
): Promise<DeliveryRequestView[]> {
const requests = await db.deliveryRequest.findMany({
where: {
...(filter.projectId ? { projectId: filter.projectId } : {}),
...(filter.openOnly ? { cancelledAt: null } : {}),
project: { status: { in: ["ACTIVE", "ON_HOLD"] } },
},
orderBy: { createdAt: "desc" },
take: 200,
include: {
project: { select: { code: true } },
lines: { orderBy: { shotCode: "asc" } },
},
});
if (!requests.length) return [];
const shotIds = [...new Set(requests.flatMap((r) => r.lines.map((l) => l.shotId)))];
const [items, shots, exports] = await Promise.all([
db.deliveryItem.findMany({
where: {
shotId: { in: shotIds },
package: { kind: "EXR_DELIVERY", status: { in: ["QUEUED", "BUILDING", "READY", "DELIVERED"] } },
},
select: { shotId: true, package: { select: { status: true, uploadedAt: true, deliveredAt: true } } },
}),
db.shot.findMany({
where: { id: { in: shotIds } },
select: { id: true, manualDeliveredAt: true, manualDeliveryUrl: true, manualDeliverySharedWith: true },
}),
db.export.findMany({
where: { shotId: { in: shotIds }, status: { in: ["READY_FOR_DELIVERY", "READY_FOR_QC"] } },
orderBy: { versionNumber: "desc" },
select: { id: true, shotId: true, status: true },
}),
]);
const packagesByShot = new Map<string, (typeof items)[number]["package"][]>();
for (const item of items) {
if (!packagesByShot.has(item.shotId)) packagesByShot.set(item.shotId, []);
packagesByShot.get(item.shotId)!.push(item.package);
}
const shotById = new Map(shots.map((s) => [s.id, s]));
const exportsByShot = new Map<string, typeof exports>();
for (const row of exports) {
if (!exportsByShot.has(row.shotId)) exportsByShot.set(row.shotId, []);
exportsByShot.get(row.shotId)!.push(row);
}
const views = requests.map((request): DeliveryRequestView => {
const lines = request.lines.map((line): DeliveryRequestLineView => {
const shot = shotById.get(line.shotId);
const shotExports = exportsByShot.get(line.shotId) ?? [];
const stage = resolveLineStage({
requestCreatedAt: request.createdAt,
packages: packagesByShot.get(line.shotId) ?? [],
manual: {
deliveredAt: shot?.manualDeliveredAt ?? null,
url: shot?.manualDeliveryUrl ?? null,
sharedWith: shot?.manualDeliverySharedWith ?? null,
},
exportStatuses: shotExports.map((e) => e.status),
});
return {
id: line.id,
shotId: line.shotId,
shotCode: line.shotCode,
clientCode: line.clientCode,
stage,
// Newest first, so this is the version a build would take.
readyExportId:
stage === "READY" ? (shotExports.find((e) => e.status === "READY_FOR_DELIVERY")?.id ?? null) : null,
};
});
const deliveredCount = lines.filter((l) => l.stage === "DELIVERED").length;
const state = request.cancelledAt ? "CANCELLED" : deliveredCount === lines.length ? "CLOSED" : "OPEN";
return {
id: request.id,
requestNumber: request.requestNumber,
projectId: request.projectId,
projectCode: request.project.code,
episode: request.episode,
deliverable: request.deliverable,
requestedBy: request.requestedBy,
note: request.note,
dueDate: request.dueDate,
createdAt: request.createdAt,
cancelledAt: request.cancelledAt,
cancelReason: request.cancelReason,
state,
lineCount: lines.length,
deliveredCount,
severity: state === "OPEN" ? waitingSeverity(request.createdAt, request.dueDate) : "normal",
lines,
};
});
return filter.openOnly ? views.filter((v) => v.state === "OPEN") : views;
}
/** Episodes a request can be logged against, with how many shots each holds. */
export async function getRequestOptions(projectId: string) {
const groups = await db.shot.groupBy({
by: ["episode"],
where: { projectId, episode: { not: null } },
_count: { _all: true },
});
return {
episodes: groups
.filter((g) => g.episode)
.map((g) => ({ episode: g.episode as string, shotCount: g._count._all }))
.sort((a, b) => a.episode.localeCompare(b.episode)),
};
}
/**
* How many of these shots are still owed on a request. A manual delivery of
* such a shot has to carry the upload link and recipient, or the request it
* answers would stay open with nothing to say why.
*/
export async function countShotsOnOpenRequests(shotIds: string[]): Promise<number> {
if (!shotIds.length) return 0;
const open = await listDeliveryRequests({ openOnly: true });
const wanted = new Set(shotIds);
const owed = new Set<string>();
for (const request of open) {
for (const line of request.lines) {
if (line.stage !== "DELIVERED" && wanted.has(line.shotId)) owed.add(line.shotId);
}
}
return owed.size;
}
@@ -0,0 +1,8 @@
-- "Delivered" used to be one click with nothing behind it. Record the upload
-- location and who was sent the link, so a package only becomes DELIVERED once
-- both have actually happened. Packages delivered before this keep NULLs here.
ALTER TABLE "delivery_packages" ADD COLUMN "uploadUrl" TEXT;
ALTER TABLE "delivery_packages" ADD COLUMN "uploadedAt" TIMESTAMP(3);
ALTER TABLE "delivery_packages" ADD COLUMN "uploadedById" TEXT;
ALTER TABLE "delivery_packages" ADD COLUMN "sharedWith" TEXT;
ALTER TABLE "delivery_packages" ADD COLUMN "sharedNote" TEXT;
@@ -0,0 +1,44 @@
-- Delivery requests: what a client asked for, tracked until it has gone out
-- with evidence. Closure is derived from packages and manual deliveries, so
-- there is no status column to fall out of step.
CREATE TABLE "delivery_requests" (
"id" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"requestNumber" INTEGER NOT NULL,
"episode" TEXT NOT NULL DEFAULT '',
"deliverable" TEXT NOT NULL DEFAULT 'EXR',
"requestedBy" TEXT NOT NULL,
"note" TEXT,
"dueDate" TIMESTAMP(3),
"createdById" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"cancelledAt" TIMESTAMP(3),
"cancelledById" TEXT,
"cancelReason" TEXT,
CONSTRAINT "delivery_requests_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "delivery_request_lines" (
"id" TEXT NOT NULL,
"requestId" TEXT NOT NULL,
"shotId" TEXT NOT NULL,
"shotCode" TEXT NOT NULL,
"clientCode" TEXT,
CONSTRAINT "delivery_request_lines_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "delivery_requests_projectId_requestNumber_key" ON "delivery_requests"("projectId", "requestNumber");
CREATE INDEX "delivery_requests_projectId_cancelledAt_idx" ON "delivery_requests"("projectId", "cancelledAt");
CREATE UNIQUE INDEX "delivery_request_lines_requestId_shotId_key" ON "delivery_request_lines"("requestId", "shotId");
CREATE INDEX "delivery_request_lines_shotId_idx" ON "delivery_request_lines"("shotId");
ALTER TABLE "delivery_requests" ADD CONSTRAINT "delivery_requests_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "projects"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "delivery_request_lines" ADD CONSTRAINT "delivery_request_lines_requestId_fkey" FOREIGN KEY ("requestId") REFERENCES "delivery_requests"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "delivery_request_lines" ADD CONSTRAINT "delivery_request_lines_shotId_fkey" FOREIGN KEY ("shotId") REFERENCES "shots"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- A manual delivery closes a request line only with the same evidence a
-- package carries: where it was uploaded and who was sent the link.
ALTER TABLE "shots" ADD COLUMN "manualDeliveryUrl" TEXT;
ALTER TABLE "shots" ADD COLUMN "manualDeliverySharedWith" TEXT;
+60 -1
View File
@@ -380,6 +380,7 @@ model Project {
/// is why it lives here rather than in per-machine panel settings.
pipelineConfig Json?
deliveryPackages DeliveryPackage[]
deliveryRequests DeliveryRequest[]
@@map("projects")
}
@@ -463,6 +464,11 @@ model Shot {
manualDeliveredAt DateTime?
manualDeliveredById String?
manualDeliveryNote String? @db.Text
/// Where a manual delivery was uploaded and who was sent the link. Optional
/// for a backfill, but a manual delivery only closes a DeliveryRequest line
/// when both are present — the same evidence a package needs.
manualDeliveryUrl String?
manualDeliverySharedWith String?
// Netflix slate fields — managed via the Slate tab, used by the preview pipeline
slateType String?
slateDescription String? @db.Text
@@ -480,6 +486,7 @@ model Shot {
references ShotReference[]
loggedTakes Take[] @relation("TakeToShot")
exports Export[]
deliveryRequestLines DeliveryRequestLine[]
@@unique([projectId, shotCode])
@@map("shots")
@@ -1155,7 +1162,16 @@ model DeliveryPackage {
createdById String
createdAt DateTime @default(now())
builtAt DateTime?
deliveredAt DateTime?
// Handoff evidence. Built is not delivered: the folder still has to be
// uploaded to the client's portal and the link sent to a person, and each of
// those is recorded separately so a package cannot be "delivered" on a hunch.
uploadUrl String? // where it lives on the client's side (Content Hub folder)
uploadedAt DateTime?
uploadedById String?
sharedWith String? // who was sent the link
sharedNote String?
deliveredAt DateTime? // when the link was shared — the package is DELIVERED from here
deliveredById String?
items DeliveryItem[]
@@ -1196,6 +1212,49 @@ model DeliveryItem {
@@map("delivery_items")
}
/// Something a client asked for ("EXRs for 116"), logged by hand because the
/// ask arrives over chat. It has no status of its own: a line is closed when
/// its shot has gone out with evidence after the request was logged, and the
/// request is closed when every line is — so it cannot be ticked off by hand.
model DeliveryRequest {
id String @id @default(cuid())
projectId String
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
requestNumber Int // sequential per project → "Request #4"
episode String @default("") // set when a whole episode was asked for
deliverable String @default("EXR") // EXR | MOV | EXR_AND_MOV — what the client asked for
requestedBy String // who at the client asked
note String? @db.Text
dueDate DateTime?
createdById String
createdAt DateTime @default(now())
cancelledAt DateTime?
cancelledById String?
cancelReason String?
lines DeliveryRequestLine[]
@@unique([projectId, requestNumber])
@@index([projectId, cancelledAt])
@@map("delivery_requests")
}
/// One requested shot. The shot list is a snapshot taken when the request was
/// logged; codes are denormalised for the same reason DeliveryItem's are.
model DeliveryRequestLine {
id String @id @default(cuid())
requestId String
request DeliveryRequest @relation(fields: [requestId], references: [id], onDelete: Cascade)
shotId String
shot Shot @relation(fields: [shotId], references: [id], onDelete: Cascade)
shotCode String
clientCode String?
@@unique([requestId, shotId])
@@index([shotId])
@@map("delivery_request_lines")
}
model SketchTemplate {
id String @id @default(cuid())
name String