Files
vfxreview/lib/render-pipeline/delivery-requests.ts
T
twotalesanimation f30acef101
Deploy / deploy (push) Successful in 5m17s
feat(deliveries): delivery requests and upload/share evidence
A built package used to become DELIVERED on one click with nothing behind
it, and nothing recorded what a client had asked for.

- Marking delivered is now two recorded steps: Record upload (the link on
  the client's portal, contents confirmed) then Share link (who it was sent
  to). The second is refused until the first is on record.
- Delivery requests log a client's ask by episode, pasted shot codes or a
  Shot Status selection. A line closes only when its shot goes out with
  evidence after the request was logged; a request can otherwise only be
  cancelled with a reason.
- Delivered Outside Pipeline takes an upload link and recipient, required
  for shots on an open request.
- The dashboard gains an Awaiting delivery section listing open requests
  and built packages the client does not have yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 14:21:24 +02:00

257 lines
9.5 KiB
TypeScript

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;
}