f30acef101
Deploy / deploy (push) Successful in 5m17s
A built package used to become DELIVERED on one click with nothing behind it, and nothing recorded what a client had asked for. - Marking delivered is now two recorded steps: Record upload (the link on the client's portal, contents confirmed) then Share link (who it was sent to). The second is refused until the first is on record. - Delivery requests log a client's ask by episode, pasted shot codes or a Shot Status selection. A line closes only when its shot goes out with evidence after the request was logged; a request can otherwise only be cancelled with a reason. - Delivered Outside Pipeline takes an upload link and recipient, required for shots on an open request. - The dashboard gains an Awaiting delivery section listing open requests and built packages the client does not have yet. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
257 lines
9.5 KiB
TypeScript
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;
|
|
}
|