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 { 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(); 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(); 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 { if (!shotIds.length) return 0; const open = await listDeliveryRequests({ openOnly: true }); const wanted = new Set(shotIds); const owed = new Set(); 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; }