Files
vfxreview/actions/shots.ts
T
twotalesanimation 94792279c5 fix(status): reset review state on a new version, and derive shot status everywhere
Requesting changes on a shot and then re-rendering it through the render queue
left every layer of the app still showing the old verdict. Three independent
causes, all on the pipeline path.

finalizePreview inherited the superseded version's approvalStatus
unconditionally. The carry-over exists so a re-export of a signed-off shot does
not reappear as awaiting review, but it applied equally to NEEDS_CHANGES and
REJECTED, so the render queued to address a note arrived already wearing that
note and nothing registered that the work had been redone. carryApproval now
carries only APPROVED.

The pipeline never moved the task. One left on CHANGES by a review or a QC fail
stayed there through every re-render, with no way out but a manual kanban drag.
A genuinely new version now moves it to INTERNAL_REVIEW, as actions/versions.ts
and batch-upload already did. A replaced version is excluded: regeneratePreview
re-delivers the same version after a slate fix and is not a new submission.

The pipeline never called recalcShotStatus at all, so shot boards went stale the
moment a render changed a task. finalizePreview and the QC fail path both
recalculate now; the latter already documented the REVISIONS derivation it was
relying on and never triggering.

Shot.status is derived from tasks and approval flags, but six call sites wrote
it directly with the status they expected: internallyApproveShot,
shareWithClient, unshareFromClient, clientApproveShot, unapproveShot and the
client approve route. Rule 1 of the derivation is that any CHANGES task outranks
an approval, so a shot could be pushed past open rework and then snap back to
REVISIONS on the next unrelated recalc, which is close to unreportable as a bug.
They now set the approval inputs and let recalcShotStatus derive.
unshareFromClient additionally hardcoded READY_FOR_CLIENT even for a
client-approved shot. updateShotStatus keeps its direct write as the deliberate
manual override, now documented as transient for the same reason.

markShotForRevisions replaces the duplicated change-request logic. Both copies
marked all non-DONE tasks CHANGES, which matches nothing on the shot this is
most often called for: one out for client review precisely because all its tasks
are finished. The request then derived to INTERNAL_REVIEW and had no visible
effect anywhere. When nothing is picked up it now reopens the task behind the
shot's latest version, which is the deliverable the reviewer was looking at.

A client's shot-level change request also marks the versions and writes Approval
rows now. The dashboard counters, the version badges and the portal's own
filters all read Version.approvalStatus, so a shot-level verdict was invisible
everywhere those look.

clearStaleShotApproval covers the reverse case: uploading a new version onto an
approved shot moved the task to INTERNAL_REVIEW and changed nothing visible,
because the derivation short-circuits on shotApprovalStatus. The shot kept
reading COMPLETE while carrying work nobody had looked at. This is the one
user-visible behaviour change here, so watch the boards on a live show: an
approved shot now drops back to INTERNAL_REVIEW on a new upload. sharedWithClient
is deliberately left alone, since revoking client access stays a separate act.

submitApproval and unapproveVersion are transactional now. The verdict, the
version, the task and the derived shot status are one decision, and a failure
partway through left the three layers disagreeing with no way to tell which one
was right.

KanbanCard compared approvalStatus against "CHANGES_REQUESTED", which is not a
member of the enum, so the changes-requested icon never rendered at all.

Thirteen tests cover carryApproval, markShotForRevisions including the all-DONE
regression, and clearStaleShotApproval. What is not covered is the pipeline
transaction itself, which needs a database: queue a render against a shot in
REVISIONS and confirm the task and shot both move before trusting this on a
show.

Version.reviewStatus is still written nowhere while being published through
ext/shots/lookup and EXT_API_REFERENCE.md as a permanent "PENDING". Left alone
deliberately: removing a field from that contract could break the connector.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 18:17:04 +02:00

1390 lines
47 KiB
TypeScript

"use server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { ShotStatus, ShotPriority, ShotApprovalStatus, Prisma } from "@prisma/client";
import { recalcShotStatus, markShotForRevisions } from "@/lib/shot-status";
const createShotSchema = z.object({
scene: z.string().min(1).max(50).regex(/^[A-Z0-9_]+$/i, "Alphanumeric and underscore only"),
episode: z.string().max(50).optional(),
description: z.string().optional(),
projectId: z.string().cuid(),
artistId: z.string().cuid().optional().or(z.literal("")),
priority: z.nativeEnum(ShotPriority).default("NORMAL"),
fps: z.number().default(24),
frameStart: z.number().int().optional(),
frameEnd: z.number().int().optional(),
dueDate: z.string().optional(),
thumbnailUrl: z.string().optional(),
shotGroupName: z.string().max(100).optional(),
isKeyShot: z.boolean().default(false),
});
export async function createShot(data: z.infer<typeof createShotSchema>) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role)) {
throw new Error("Insufficient permissions");
}
const parsed = createShotSchema.parse(data);
const scene = parsed.scene.toUpperCase();
const episode = parsed.episode?.toUpperCase() ?? null;
// Fetch project for showId and projectType
const project = await db.project.findUnique({
where: { id: parsed.projectId },
select: { showId: true, projectType: true },
});
if (!project) throw new Error("Project not found");
if (!project.showId) {
throw new Error("Project has no Show ID set. Please edit the project to add one.");
}
// For episodic projects, episode is required
if (project.projectType === "EPISODIC" && !episode) {
throw new Error("Episode is required for episodic projects.");
}
// Determine shot number scope: projectId + scene (+ episode for episodic)
const scopeWhere = {
projectId: parsed.projectId,
scene,
...(project.projectType === "EPISODIC" ? { episode } : {}),
};
const maxShot = await db.shot.findFirst({
where: scopeWhere,
orderBy: { shotNumber: "desc" },
select: { shotNumber: true },
});
const shotNumber = (maxShot?.shotNumber ?? 0) + 10;
const paddedNumber = shotNumber.toString().padStart(4, "0");
// Build shot code per naming convention
const shotCode =
project.projectType === "EPISODIC" && episode
? `${project.showId}_${episode}_${scene}_${paddedNumber}`
: `${project.showId}_${scene}_${paddedNumber}`;
const shot = await db.shot.create({
data: {
shotCode,
scene,
episode,
shotNumber,
description: parsed.description,
projectId: parsed.projectId,
artistId: parsed.artistId || undefined,
priority: parsed.priority,
fps: parsed.fps,
frameStart: parsed.frameStart,
frameEnd: parsed.frameEnd,
dueDate: parsed.dueDate ? new Date(parsed.dueDate) : undefined,
thumbnailUrl: parsed.thumbnailUrl,
isKeyShot: parsed.isKeyShot ?? false,
shotGroupId: parsed.shotGroupName?.trim()
? (await db.shotGroup.upsert({
where: { projectId_name: { projectId: parsed.projectId, name: parsed.shotGroupName.trim() } },
create: { projectId: parsed.projectId, name: parsed.shotGroupName.trim() },
update: {},
})).id
: undefined,
},
});
revalidatePath(`/projects/${parsed.projectId}`);
return { success: true, shot };
}
// ── Duplicate Shot ────────────────────────────────────────────────────────────
export async function duplicateShot(sourceShotId: string) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role)) {
throw new Error("Insufficient permissions");
}
// Load source shot with tasks
const source = await db.shot.findUnique({
where: { id: sourceShotId },
include: {
tasks: {
select: {
title: true,
description: true,
type: true,
priority: true,
estimatedHours: true,
sortOrder: true,
dueDate: true,
assignedArtistId: true,
},
},
},
});
if (!source) throw new Error("Shot not found");
const project = await db.project.findUnique({
where: { id: source.projectId },
select: { showId: true, projectType: true },
});
if (!project?.showId) throw new Error("Project has no Show ID");
// Determine next shot number in same scene/episode scope
const scopeWhere = {
projectId: source.projectId,
scene: source.scene,
...(project.projectType === "EPISODIC" ? { episode: source.episode } : {}),
};
const maxShot = await db.shot.findFirst({
where: scopeWhere,
orderBy: { shotNumber: "desc" },
select: { shotNumber: true },
});
const shotNumber = (maxShot?.shotNumber ?? 0) + 10;
const paddedNumber = shotNumber.toString().padStart(4, "0");
const shotCode =
project.projectType === "EPISODIC" && source.episode
? `${project.showId}_${source.episode}_${source.scene}_${paddedNumber}`
: `${project.showId}_${source.scene}_${paddedNumber}`;
const newShot = await db.shot.create({
data: {
shotCode,
scene: source.scene,
episode: source.episode,
shotNumber,
sequence: source.sequence,
description: source.description,
projectId: source.projectId,
artistId: source.artistId,
priority: source.priority,
fps: source.fps,
frameStart: source.frameStart,
frameEnd: source.frameEnd,
dueDate: source.dueDate,
thumbnailUrl: source.thumbnailUrl,
shotGroupId: source.shotGroupId ?? undefined,
// Duplicate tasks — reset status and schedule fields
tasks: {
create: source.tasks.map((t) => ({
title: t.title,
description: t.description,
type: t.type,
priority: t.priority,
estimatedHours: t.estimatedHours,
sortOrder: t.sortOrder,
dueDate: t.dueDate,
assignedArtistId: t.assignedArtistId,
projectId: source.projectId,
status: "TODO" as const,
})),
},
},
});
revalidatePath(`/projects/${source.projectId}`);
return { success: true, shot: newShot };
}
// ── Update Shot ───────────────────────────────────────────────────────────────
const updateShotSchema = z.object({
shotId: z.string().cuid(),
shotCode: z.string().min(1).max(120).regex(/^[A-Z0-9_]+$/i, "Alphanumeric and underscores only").optional(),
description: z.string().optional(),
status: z.nativeEnum(ShotStatus).optional(),
priority: z.nativeEnum(ShotPriority).optional(),
shotApprovalStatus: z.nativeEnum(ShotApprovalStatus).optional(),
sharedWithClient: z.boolean().optional(),
fps: z.number().optional(),
frameStart: z.number().int().optional().nullable(),
frameEnd: z.number().int().optional().nullable(),
dueDate: z.string().optional().nullable(),
artistId: z.string().cuid().optional().nullable().or(z.literal("")),
thumbnailUrl: z.string().optional().nullable(),
originalFootageUrl: z.string().optional().nullable(),
originalFootageKey: z.string().optional().nullable(),
});
export async function updateShot(data: z.infer<typeof updateShotSchema>) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role)) {
throw new Error("Insufficient permissions");
}
const parsed = updateShotSchema.parse(data);
const { shotId, dueDate, artistId, shotCode, ...rest } = parsed;
// Check uniqueness if shotCode is being changed
if (shotCode) {
const existing = await db.shot.findFirst({
where: { projectId: (await db.shot.findUnique({ where: { id: shotId }, select: { projectId: true } }))!.projectId, shotCode, id: { not: shotId } },
select: { id: true },
});
if (existing) throw new Error(`Shot code "${shotCode}" is already used in this project.`);
}
const shot = await db.shot.update({
where: { id: shotId },
data: {
...rest,
...(shotCode ? { shotCode: shotCode.toUpperCase() } : {}),
dueDate: dueDate ? new Date(dueDate) : dueDate === null ? null : undefined,
artistId: artistId === "" ? null : artistId,
},
});
revalidatePath(`/projects/${shot.projectId}`);
revalidatePath(`/projects/${shot.projectId}/shots/${shotId}`);
return { success: true, shot };
}
// ── CSV Import ────────────────────────────────────────────────────────────────
export async function importShotsFromCsv(
projectId: string,
rows: Array<{
scene: string;
episode?: string;
description?: string;
priority?: string;
fps?: number;
frameStart?: number;
frameEnd?: number;
}>
) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role)) {
throw new Error("Insufficient permissions");
}
const project = await db.project.findUnique({
where: { id: projectId },
select: { showId: true, projectType: true },
});
if (!project) throw new Error("Project not found");
if (!project.showId) throw new Error("Project has no Show ID. Add one in Project Settings first.");
const VALID_PRIORITIES = ["LOW", "NORMAL", "HIGH", "CRITICAL"];
const created: string[] = [];
const errors: string[] = [];
for (const row of rows) {
try {
const scene = row.scene.trim().toUpperCase();
if (!scene) { errors.push("Empty scene name — skipped"); continue; }
const episode = row.episode?.trim().toUpperCase() || null;
if (project.projectType === "EPISODIC" && !episode) {
errors.push(`${scene}: episode required for episodic project — skipped`);
continue;
}
const rawPriority = row.priority?.trim().toUpperCase();
const priority = VALID_PRIORITIES.includes(rawPriority ?? "")
? (rawPriority as ShotPriority)
: ShotPriority.NORMAL;
const scopeWhere = {
projectId,
scene,
...(project.projectType === "EPISODIC" ? { episode } : {}),
};
const maxShot = await db.shot.findFirst({
where: scopeWhere,
orderBy: { shotNumber: "desc" },
select: { shotNumber: true },
});
const shotNumber = (maxShot?.shotNumber ?? 0) + 10;
const paddedNumber = shotNumber.toString().padStart(4, "0");
const shotCode =
project.projectType === "EPISODIC" && episode
? `${project.showId}_${episode}_${scene}_${paddedNumber}`
: `${project.showId}_${scene}_${paddedNumber}`;
await db.shot.create({
data: {
shotCode,
scene,
episode,
shotNumber,
description: row.description?.trim() || undefined,
projectId,
priority,
fps: row.fps ?? 24,
frameStart: row.frameStart ?? undefined,
frameEnd: row.frameEnd ?? undefined,
},
});
created.push(shotCode);
} catch (e: unknown) {
errors.push(`${row.scene}: ${e instanceof Error ? e.message : "Unknown error"}`);
}
}
revalidatePath(`/projects/${projectId}`);
return { success: true, created, errors };
}
/**
* Manual override of the shot's status.
*
* Deliberately the one place that writes Shot.status without deriving it — but
* note the override is transient: Shot.status is a derived field, so the next
* recalc (any task move, approval, upload or render) recomputes it from the
* tasks and approval flags and discards whatever was set here. Change the
* underlying task states if the new status needs to stick.
*/
export async function updateShotStatus(
shotId: string,
status: ShotStatus
) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
const shot = await db.shot.update({
where: { id: shotId },
data: { status },
include: { project: true },
});
revalidatePath(`/projects/${shot.projectId}`);
revalidatePath(`/projects/${shot.projectId}/shots/${shotId}`);
return { success: true };
}
export async function getShotById(shotId: string) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
return db.shot.findUnique({
where: { id: shotId },
include: {
project: { include: { client: true } },
artist: { select: { id: true, name: true, email: true, image: true } },
versions: {
include: {
artist: { select: { id: true, name: true, image: true } },
_count: { select: { comments: true } },
},
orderBy: { versionNumber: "desc" },
},
},
});
}
export async function getShotsByProject(projectId: string) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
return db.shot.findMany({
where: { projectId },
include: {
artist: { select: { id: true, name: true, image: true } },
versions: {
where: { isLatest: true },
take: 1,
include: { _count: { select: { comments: true } } },
},
_count: { select: { versions: true } },
},
orderBy: [{ sequence: "asc" }, { shotCode: "asc" }],
});
}
// ─── Footage Plates ────────────────────────────────────────────────────────────
const addFootagePlateSchema = z.object({
shotId: z.string().min(1),
fileUrl: z.string().min(1),
fileKey: z.string().default(""),
fileName: z.string().default(""),
fileSize: z.number().int().positive().optional(),
label: z.string().max(100).default(""),
});
export async function addFootagePlate(raw: unknown) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
const data = addFootagePlateSchema.parse(raw);
// Determine next sortOrder
const last = await db.footagePlate.findFirst({
where: { shotId: data.shotId },
orderBy: { sortOrder: "desc" },
select: { sortOrder: true },
});
const plate = await db.footagePlate.create({
data: {
shotId: data.shotId,
fileUrl: data.fileUrl,
fileKey: data.fileKey,
fileName: data.fileName,
fileSize: data.fileSize != null ? BigInt(data.fileSize) : null,
label: data.label,
sortOrder: (last?.sortOrder ?? -1) + 1,
},
});
const shot = await db.shot.findUnique({ where: { id: data.shotId }, select: { projectId: true } });
if (shot) {
revalidatePath(`/projects/${shot.projectId}`);
revalidatePath(`/projects/${shot.projectId}/shots/${data.shotId}`);
}
return { success: true, plate: { ...plate, fileSize: plate.fileSize?.toString() ?? null } };
}
export async function removeFootagePlate(plateId: string) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
const role = session.user.role as string;
if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(role)) throw new Error("Insufficient permissions");
const plate = await db.footagePlate.findUnique({
where: { id: plateId },
include: { shot: { select: { projectId: true } } },
});
if (!plate) throw new Error("Plate not found");
await db.footagePlate.delete({ where: { id: plateId } });
revalidatePath(`/projects/${plate.shot.projectId}`);
revalidatePath(`/projects/${plate.shot.projectId}/shots/${plate.shotId}`);
return { success: true };
}
// ── Update Shot Notes ─────────────────────────────────────────────────────────
export async function updateShotNotes(shotId: string, notes: string) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role)) {
throw new Error("Insufficient permissions");
}
const shot = await db.shot.findUnique({
where: { id: shotId },
select: { projectId: true },
});
if (!shot) throw new Error("Shot not found");
await db.shot.update({
where: { id: shotId },
data: { notes: notes.trim() || null },
});
revalidatePath(`/projects/${shot.projectId}`);
revalidatePath(`/shot-status`);
return { success: true };
}
// ── Update Shot Slate Fields ──────────────────────────────────────────────────
export async function updateShotSlate(
shotId: string,
data: {
slateType?: string | null;
slateDescription?: string | null;
slateVfxScope?: string | null;
slateSubmissionNote?: string | null;
clientCode?: string | null;
}
) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role)) {
throw new Error("Insufficient permissions");
}
const shot = await db.shot.findUnique({ where: { id: shotId }, select: { projectId: true } });
if (!shot) throw new Error("Shot not found");
await db.shot.update({
where: { id: shotId },
data: {
slateType: data.slateType !== undefined ? (data.slateType?.trim() || null) : undefined,
slateDescription: data.slateDescription !== undefined ? (data.slateDescription?.trim() || null) : undefined,
slateVfxScope: data.slateVfxScope !== undefined ? (data.slateVfxScope?.trim() || null) : undefined,
slateSubmissionNote: data.slateSubmissionNote !== undefined ? (data.slateSubmissionNote?.trim() || null) : undefined,
clientCode: data.clientCode !== undefined ? (data.clientCode?.trim() || null) : undefined,
},
});
revalidatePath(`/projects/${shot.projectId}`);
return { success: true };
}
// ── Toggle Key Shot ───────────────────────────────────────────────────────────
export async function toggleKeyShot(shotId: string, isKeyShot: boolean) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role)) {
throw new Error("Insufficient permissions");
}
const shot = await db.shot.findUnique({
where: { id: shotId },
select: { projectId: true },
});
if (!shot) throw new Error("Shot not found");
await db.shot.update({
where: { id: shotId },
data: { isKeyShot },
});
revalidatePath(`/projects/${shot.projectId}`);
revalidatePath(`/shot-status`);
return { success: true };
}
/**
* Scaled in the edit: the plate is not used at its native size, which changes
* how the shot is comped and reviewed. Same shape as toggleKeyShot — a standing
* property of the shot, flipped from the shot-status board.
*/
export async function toggleScaledShot(shotId: string, isScaled: boolean) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role)) {
throw new Error("Insufficient permissions");
}
const shot = await db.shot.findUnique({
where: { id: shotId },
select: { projectId: true },
});
if (!shot) throw new Error("Shot not found");
await db.shot.update({
where: { id: shotId },
data: { isScaled },
});
revalidatePath(`/projects/${shot.projectId}`);
revalidatePath(`/shot-status`);
return { success: true };
}
export async function deleteShot(shotId: string) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
const role = session.user.role as string;
if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(role)) throw new Error("Insufficient permissions");
const shot = await db.shot.findUnique({
where: { id: shotId },
select: { projectId: true },
});
if (!shot) throw new Error("Shot not found");
await db.shot.delete({ where: { id: shotId } });
revalidatePath(`/projects/${shot.projectId}`);
return { success: true, projectId: shot.projectId };
}
// ── Bulk Update Due Dates ─────────────────────────────────────────────────────
export async function bulkUpdateDueDates(
shotIds: string[],
dueDate: string,
includeTasks: boolean,
) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role)) {
throw new Error("Insufficient permissions");
}
if (!shotIds.length) return { success: true, updated: 0 };
const date = new Date(dueDate);
const shots = await db.shot.findMany({
where: { id: { in: shotIds } },
select: { id: true, projectId: true },
});
if (shots.length !== shotIds.length) throw new Error("Some shots not found");
const projectIds = [...new Set(shots.map((s) => s.projectId))];
await db.shot.updateMany({
where: { id: { in: shotIds } },
data: { dueDate: date },
});
if (includeTasks) {
await db.task.updateMany({
where: { shotId: { in: shotIds } },
data: { dueDate: date },
});
}
for (const pid of projectIds) {
revalidatePath(`/projects/${pid}`);
}
revalidatePath(`/shot-status`);
return { success: true, updated: shots.length };
}
// ── Bulk Delete Shots ─────────────────────────────────────────────────────────
export async function bulkDeleteShots(shotIds: string[]) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role)) {
throw new Error("Insufficient permissions");
}
if (!shotIds.length) return { success: true, deleted: 0, failed: [] as { shotId: string; shotCode: string; reason: string }[] };
const shots = await db.shot.findMany({
where: { id: { in: shotIds } },
select: { id: true, shotCode: true, projectId: true },
});
const projectIds = [...new Set(shots.map((s) => s.projectId))];
const failed: { shotId: string; shotCode: string; reason: string }[] = [];
let deleted = 0;
// One at a time, not deleteMany: a shot with renders or deliveries is
// protected at the database level (exports_shotId_fkey is ON DELETE
// RESTRICT, deliberately — delivery history must never be destroyable by
// deleting the shot underneath it). A single bulk statement would abort
// entirely on the first shot it hits; deleting individually means every
// shot that *can* go still does, and the ones that can't are reported by
// name instead of failing the whole selection.
for (const shot of shots) {
try {
await db.shot.delete({ where: { id: shot.id } });
deleted += 1;
} catch (e) {
failed.push({
shotId: shot.id,
shotCode: shot.shotCode,
reason:
e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2003"
? "has renders or deliveries — delete those first"
: e instanceof Error
? e.message
: "unknown error",
});
}
}
for (const pid of projectIds) {
revalidatePath(`/projects/${pid}`);
}
revalidatePath(`/shot-status`);
return { success: true, deleted, failed };
}
export async function renameFootagePlate(plateId: string, label: string) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
const role = session.user.role as string;
if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(role)) throw new Error("Insufficient permissions");
const trimmed = label.trim().slice(0, 100);
const plate = await db.footagePlate.update({
where: { id: plateId },
data: { label: trimmed },
include: { shot: { select: { projectId: true } } },
});
revalidatePath(`/projects/${plate.shot.projectId}`);
revalidatePath(`/projects/${plate.shot.projectId}/shots/${plate.shotId}`);
return { success: true };
}
// ── Internal Approval ─────────────────────────────────────────────────────────
/**
* Supervisor/Producer/Admin: mark a shot as internally approved.
* Sets shotApprovalStatus = INTERNALLY_APPROVED, sharedWithClient = false.
* Shot status becomes READY_FOR_CLIENT.
*/
export async function internallyApproveShot(shotId: string) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role)) {
throw new Error("Insufficient permissions");
}
const shot = await db.shot.findUnique({
where: { id: shotId },
select: { projectId: true },
});
if (!shot) throw new Error("Shot not found");
// Set the approval inputs and let the derivation pick the status. Writing
// READY_FOR_CLIENT directly skipped the rule that an outstanding CHANGES task
// outranks any approval, so a shot could be pushed past open rework and then
// snap back to REVISIONS on the next unrelated recalc.
await db.$transaction(async (tx) => {
await tx.shot.update({
where: { id: shotId },
data: {
shotApprovalStatus: "INTERNALLY_APPROVED",
sharedWithClient: false,
},
});
await recalcShotStatus(shotId, tx);
});
revalidatePath(`/projects/${shot.projectId}`);
revalidatePath(`/projects/${shot.projectId}/shots/${shotId}`);
revalidatePath(`/shot-status`);
return { success: true };
}
// ── Share / Unshare With Client ───────────────────────────────────────────────
/**
* Producer/Supervisor/Admin: share an internally-approved shot with the client.
* Sets sharedWithClient = true → status becomes CLIENT_REVIEW.
* Also marks the latest version of every task on this shot as isClientVisible = true
* so the client portal can display them.
*/
export async function shareWithClient(shotId: string) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role)) {
throw new Error("Insufficient permissions");
}
const shot = await db.shot.findUnique({
where: { id: shotId },
select: {
projectId: true,
shotApprovalStatus: true,
tasks: { select: { id: true } },
},
});
if (!shot) throw new Error("Shot not found");
if (!(["INTERNALLY_APPROVED", "CLIENT_APPROVED"] as string[]).includes(shot.shotApprovalStatus)) {
throw new Error("Shot must be internally approved before sharing with client");
}
const now = new Date();
await db.$transaction(async (tx) => {
// Mark shot as shared
await tx.shot.update({
where: { id: shotId },
data: { sharedWithClient: true },
});
// For each task, make the latest version client-visible
for (const task of shot.tasks) {
const latest = await tx.version.findFirst({
where: { taskId: task.id, isLatest: true },
select: { id: true, isClientVisible: true },
});
if (latest && !latest.isClientVisible) {
await tx.version.update({
where: { id: latest.id },
data: {
isClientVisible: true,
sharedAt: now,
sharedById: session.user.id,
},
});
}
}
await recalcShotStatus(shotId, tx);
});
revalidatePath(`/projects/${shot.projectId}`);
revalidatePath(`/projects/${shot.projectId}/shots/${shotId}`);
revalidatePath(`/shot-status`);
return { success: true };
}
/**
* Producer/Supervisor/Admin: remove a shot from client review.
* Sets sharedWithClient = false → status becomes READY_FOR_CLIENT.
* Also hides all task versions that were made visible by shareWithClient.
*/
export async function unshareFromClient(shotId: string) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role)) {
throw new Error("Insufficient permissions");
}
const shot = await db.shot.findUnique({
where: { id: shotId },
select: {
projectId: true,
tasks: { select: { id: true } },
},
});
if (!shot) throw new Error("Shot not found");
await db.$transaction(async (tx) => {
await tx.shot.update({
where: { id: shotId },
data: { sharedWithClient: false },
});
// Hide all client-visible versions on this shot's tasks
const taskIds = shot.tasks.map((t) => t.id);
if (taskIds.length > 0) {
await tx.version.updateMany({
where: { taskId: { in: taskIds }, isClientVisible: true },
data: { isClientVisible: false },
});
}
await recalcShotStatus(shotId, tx);
});
revalidatePath(`/projects/${shot.projectId}`);
revalidatePath(`/projects/${shot.projectId}/shots/${shotId}`);
revalidatePath(`/shot-status`);
return { success: true };
}
// ── Shot-Level Client Approval ────────────────────────────────────────────────
/**
* Internal: handle client approving the shot.
* Sets shotApprovalStatus = CLIENT_APPROVED → status becomes COMPLETE.
*/
export async function clientApproveShot(shotId: string) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
if (!["ADMIN", "PRODUCER", "SUPERVISOR", "CLIENT"].includes(session.user.role)) {
throw new Error("Insufficient permissions");
}
const shot = await db.shot.findUnique({
where: { id: shotId },
select: { projectId: true, sharedWithClient: true },
});
if (!shot) throw new Error("Shot not found");
if (!shot.sharedWithClient) throw new Error("Shot is not shared with client");
await db.$transaction(async (tx) => {
await tx.shot.update({
where: { id: shotId },
data: { shotApprovalStatus: "CLIENT_APPROVED" },
});
await recalcShotStatus(shotId, tx);
});
revalidatePath(`/projects/${shot.projectId}`);
revalidatePath(`/projects/${shot.projectId}/shots/${shotId}`);
revalidatePath(`/shot-status`);
return { success: true };
}
/**
* Admin/Producer/Supervisor: undo a client approval on a shot.
* Resets shotApprovalStatus from CLIENT_APPROVED → INTERNALLY_APPROVED.
* Shot status reverts to CLIENT_REVIEW (if still shared) or READY_FOR_CLIENT.
*/
export async function unapproveShot(shotId: string) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role)) {
throw new Error("Insufficient permissions");
}
const shot = await db.shot.findUnique({
where: { id: shotId },
select: { projectId: true, shotApprovalStatus: true, sharedWithClient: true },
});
if (!shot) throw new Error("Shot not found");
if (shot.shotApprovalStatus !== "CLIENT_APPROVED") {
throw new Error("Shot is not client-approved");
}
// The derivation already encodes "CLIENT_REVIEW if still shared, else
// READY_FOR_CLIENT" for an INTERNALLY_APPROVED shot, and additionally honours
// any open CHANGES task.
await db.$transaction(async (tx) => {
await tx.shot.update({
where: { id: shotId },
data: { shotApprovalStatus: "INTERNALLY_APPROVED" },
});
await recalcShotStatus(shotId, tx);
});
revalidatePath(`/projects/${shot.projectId}`);
revalidatePath(`/projects/${shot.projectId}/shots/${shotId}`);
revalidatePath(`/shot-status`);
return { success: true };
}
// ── EDL / Pull CSV Import ─────────────────────────────────────────────────────
import type { EdlImportRow } from "@/lib/edl-utils";
export type { EdlImportRow };
export async function importShotsFromEdl(
projectId: string,
rows: EdlImportRow[]
): Promise<{ created: string[]; updated: string[]; skipped: string[]; errors: string[] }> {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role)) {
throw new Error("Insufficient permissions");
}
const created: string[] = [];
const updated: string[] = [];
const skipped: string[] = [];
const errors: string[] = [];
for (const row of rows) {
if (row.action === "skip") { skipped.push(row.shotCode); continue; }
try {
if (row.action === "update" && row.existingShotId) {
await db.shot.update({
where: { id: row.existingShotId },
data: {
sourceClip: row.sourceClip || null,
timecodeStart: row.timecodeStart || null,
timecodeEnd: row.timecodeEnd || null,
clipDuration: row.clipDuration || null,
exrOutput: row.exrOutput || null,
},
});
updated.push(row.shotCode);
} else {
const existing = await db.shot.findFirst({
where: { projectId, shotCode: row.shotCode },
select: { id: true },
});
if (existing) {
await db.shot.update({
where: { id: existing.id },
data: {
sourceClip: row.sourceClip || null,
timecodeStart: row.timecodeStart || null,
timecodeEnd: row.timecodeEnd || null,
clipDuration: row.clipDuration || null,
exrOutput: row.exrOutput || null,
},
});
updated.push(row.shotCode);
} else {
const parts = row.shotCode.split("_");
const scene = parts[2] ?? parts[1] ?? "000";
const episode = parts[1] ?? null;
const maxNum = await db.shot.findFirst({
where: { projectId, scene, episode },
orderBy: { shotNumber: "desc" },
select: { shotNumber: true },
});
const shotNumber = (maxNum?.shotNumber ?? 0) + 10;
await db.shot.create({
data: {
projectId,
shotCode: row.shotCode,
scene,
episode,
shotNumber,
sourceClip: row.sourceClip || null,
timecodeStart: row.timecodeStart || null,
timecodeEnd: row.timecodeEnd || null,
clipDuration: row.clipDuration || null,
exrOutput: row.exrOutput || null,
},
});
created.push(row.shotCode);
}
}
} catch (e) {
errors.push(`${row.shotCode}: ${e instanceof Error ? e.message : "Unknown error"}`);
}
}
revalidatePath(`/projects/${projectId}`);
return { created, updated, skipped, errors };
}
// ── Picture Tracker / Sequence Timecode Update ────────────────────────────────
export interface SeqTimecodeRow {
shotCode: string;
seqTimecodeStart: string;
seqTimecodeEnd: string;
}
export async function updateShotsSeqTimecodes(
projectId: string,
rows: SeqTimecodeRow[]
): Promise<{ updated: string[]; skipped: string[]; errors: string[] }> {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role)) {
throw new Error("Insufficient permissions");
}
const updated: string[] = [];
const skipped: string[] = [];
const errors: string[] = [];
for (const row of rows) {
try {
const existing = await db.shot.findFirst({
where: { projectId, shotCode: row.shotCode },
select: { id: true },
});
if (!existing) {
skipped.push(row.shotCode);
continue;
}
await db.shot.update({
where: { id: existing.id },
data: {
seqTimecodeStart: row.seqTimecodeStart || null,
seqTimecodeEnd: row.seqTimecodeEnd || null,
},
});
updated.push(row.shotCode);
} catch (e) {
errors.push(`${row.shotCode}: ${e instanceof Error ? e.message : "Unknown error"}`);
}
}
revalidatePath(`/projects/${projectId}`);
return { updated, skipped, errors };
}
// ── Simple CSV shot import ────────────────────────────────────────────────────
export interface SimpleCsvRow {
shotCode: string;
seqTimecodeStart: string;
seqTimecodeEnd: string;
description: string;
group: string;
action: "create" | "update" | "skip";
}
export async function importShotsFromSimpleCsv(
projectId: string,
rows: SimpleCsvRow[]
): Promise<{ created: string[]; updated: string[]; skipped: string[]; errors: string[] }> {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role)) {
throw new Error("Insufficient permissions");
}
const created: string[] = [];
const updated: string[] = [];
const skipped: string[] = [];
const errors: string[] = [];
for (const row of rows) {
if (row.action === "skip") { skipped.push(row.shotCode); continue; }
try {
const existing = await db.shot.findFirst({
where: { projectId, shotCode: row.shotCode },
select: { id: true },
});
if (existing) {
const shotGroupId = row.group?.trim()
? (await db.shotGroup.upsert({
where: { projectId_name: { projectId, name: row.group.trim() } },
create: { projectId, name: row.group.trim() },
update: {},
})).id
: undefined;
await db.shot.update({
where: { id: existing.id },
data: {
description: row.description || null,
seqTimecodeStart: row.seqTimecodeStart || null,
seqTimecodeEnd: row.seqTimecodeEnd || null,
...(shotGroupId ? { shotGroupId } : {}),
},
});
updated.push(row.shotCode);
} else if (row.action === "create") {
const parts = row.shotCode.split("_");
const scene = parts.length >= 3 ? parts[2] : (parts[1] ?? "000");
const episode = parts.length >= 4 ? parts[1] : null;
const maxNum = await db.shot.findFirst({
where: { projectId, scene, episode },
orderBy: { shotNumber: "desc" },
select: { shotNumber: true },
});
const shotNumber = (maxNum?.shotNumber ?? 0) + 10;
const shotGroupId = row.group?.trim()
? (await db.shotGroup.upsert({
where: { projectId_name: { projectId, name: row.group.trim() } },
create: { projectId, name: row.group.trim() },
update: {},
})).id
: undefined;
await db.shot.create({
data: {
projectId,
shotCode: row.shotCode,
scene,
episode,
shotNumber,
description: row.description || null,
seqTimecodeStart: row.seqTimecodeStart || null,
seqTimecodeEnd: row.seqTimecodeEnd || null,
shotGroupId,
},
});
created.push(row.shotCode);
} else {
// action === "update" but shot not found
skipped.push(row.shotCode);
}
} catch (e) {
errors.push(`${row.shotCode}: ${e instanceof Error ? e.message : "Unknown error"}`);
}
}
revalidatePath(`/projects/${projectId}`);
return { created, updated, skipped, errors };
}
// ── Group Assignment ──────────────────────────────────────────────────────────────────
export interface GroupAssignRow {
shotCode: string;
groupName: string;
exists: boolean;
}
export async function assignShotGroups(
projectId: string,
rows: GroupAssignRow[]
): Promise<{ assigned: string[]; skipped: string[]; errors: string[] }> {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role)) {
throw new Error("Insufficient permissions");
}
const assigned: string[] = [];
const skipped: string[] = [];
const errors: string[] = [];
for (const row of rows) {
if (!row.exists) { skipped.push(row.shotCode); continue; }
try {
const shot = await db.shot.findFirst({
where: { projectId, shotCode: row.shotCode },
select: { id: true },
});
if (!shot) { skipped.push(row.shotCode); continue; }
const group = await db.shotGroup.upsert({
where: { projectId_name: { projectId, name: row.groupName.trim() } },
create: { projectId, name: row.groupName.trim() },
update: {},
});
await db.shot.update({
where: { id: shot.id },
data: { shotGroupId: group.id },
});
assigned.push(row.shotCode);
} catch (e) {
errors.push(`${row.shotCode}: ${e instanceof Error ? e.message : "Unknown error"}`);
}
}
revalidatePath(`/projects/${projectId}`);
return { assigned, skipped, errors };
}
/**
* Session-authenticated wrapper for requesting changes on a shot — a supervisor
* or producer relaying a client note from inside the app.
*
* The client portal cannot use this: it runs on a review token, not a session,
* so it calls markShotForRevisions directly from its route. Both go through that
* one helper so the two entry points cannot drift apart again.
*/
export async function clientRequestShotChanges(shotId: string, taskId?: string) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
if (!["ADMIN", "PRODUCER", "SUPERVISOR", "CLIENT"].includes(session.user.role)) {
throw new Error("Insufficient permissions");
}
const shot = await db.shot.findUnique({
where: { id: shotId },
select: { projectId: true },
});
if (!shot) throw new Error("Shot not found");
await db.$transaction((tx) => markShotForRevisions(tx, shotId, taskId));
revalidatePath(`/projects/${shot.projectId}`);
revalidatePath(`/projects/${shot.projectId}/shots/${shotId}`);
revalidatePath(`/shot-status`);
return { success: true };
}
// ── Shot Version ──────────────────────────────────────────────────────────────
const VERSION_RE = /^v\d{3}$/;
/**
* Set or manually override the shot's version string (format: v###).
*/
export async function updateShotVersion(shotId: string, version: string) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role)) {
throw new Error("Insufficient permissions");
}
if (!VERSION_RE.test(version)) {
throw new Error("Version must be in format v### (e.g. v001, v012)");
}
const shot = await db.shot.findUnique({
where: { id: shotId },
select: { projectId: true },
});
if (!shot) throw new Error("Shot not found");
await db.shot.update({
where: { id: shotId },
data: { shotVersion: version },
});
revalidatePath(`/projects/${shot.projectId}/shots/${shotId}`);
return { success: true, shotVersion: version };
}
// ── Generated (render-derived) shot thumbnails ───────────────────────────────
//
// A shot can carry two stills: `thumbnailUrl`, the reference frame that came
// with the brief, and `generatedThumbnailUrl`, a frame promoted from a render
// export. The reference always wins on display (lib/shot-thumbnail.ts), so
// these actions can only ever fill a gap — a promotion never overwrites the
// brief, and clearing one always falls back to it.
//
// Shows with `pipelineConfig.autoShotThumbnail` promote on every finished
// export automatically (lib/render-pipeline/preview.ts). These actions are the
// manual equivalent, and work regardless of that flag: a supervisor can pull a
// frame onto one shot on a project that is otherwise hands-off.
/**
* Promote a render export's thumbnail onto its shot.
*
* With no `exportId`, uses the newest export of that shot which actually has a
* thumbnail — "use the latest render frame".
*/
export async function promoteExportThumbnail(shotId: string, exportId?: string) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role)) {
throw new Error("Insufficient permissions");
}
const shot = await db.shot.findUnique({
where: { id: shotId },
select: { id: true, projectId: true },
});
if (!shot) throw new Error("Shot not found");
const exportRow = await db.export.findFirst({
where: {
shotId,
thumbnailKey: { not: null },
...(exportId ? { id: exportId } : {}),
},
orderBy: { versionNumber: "desc" },
select: { id: true, thumbnailKey: true, versionString: true },
});
if (!exportRow?.thumbnailKey) {
throw new Error(
exportId
? "That export has no thumbnail — its preview may have been skipped, or the worker had no ffmpeg configured"
: "No render of this shot has produced a thumbnail yet"
);
}
const generatedThumbnailUrl = `/api/files/${exportRow.thumbnailKey}`;
await db.shot.update({
where: { id: shotId },
data: { generatedThumbnailUrl, generatedThumbnailFrom: exportRow.versionString },
});
revalidatePath(`/projects/${shot.projectId}`);
revalidatePath(`/projects/${shot.projectId}/shots/${shotId}`);
return {
success: true,
generatedThumbnailUrl,
generatedThumbnailFrom: exportRow.versionString,
};
}
/** Drop the render-derived still. The reference frame, if any, comes back. */
export async function clearGeneratedThumbnail(shotId: string) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role)) {
throw new Error("Insufficient permissions");
}
const shot = await db.shot.findUnique({
where: { id: shotId },
select: { id: true, projectId: true },
});
if (!shot) throw new Error("Shot not found");
await db.shot.update({
where: { id: shotId },
data: { generatedThumbnailUrl: null, generatedThumbnailFrom: null },
});
revalidatePath(`/projects/${shot.projectId}`);
revalidatePath(`/projects/${shot.projectId}/shots/${shotId}`);
return { success: true };
}