diff --git a/DELIVERY_WORKFLOW.md b/DELIVERY_WORKFLOW.md
index 8f180df..66b94f6 100644
--- a/DELIVERY_WORKFLOW.md
+++ b/DELIVERY_WORKFLOW.md
@@ -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? }`.
diff --git a/actions/shots.ts b/actions/shots.ts
index 053f6f9..94da6f9 100644
--- a/actions/shots.ts
+++ b/actions/shots.ts
@@ -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 ─────────────────────────────────────────────────────────
diff --git a/app/(dashboard)/dashboard/page.tsx b/app/(dashboard)/dashboard/page.tsx
index 59e8cec..5a56f99 100644
--- a/app/(dashboard)/dashboard/page.tsx
+++ b/app/(dashboard)/dashboard/page.tsx
@@ -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 && }
+ {/* 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 && (
+