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>
1535 lines
52 KiB
TypeScript
1535 lines
52 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";
|
|
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"),
|
|
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 Assign Artist ────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Point a selection of shots at one artist, or clear the assignment when
|
|
* `artistId` is null. Mirrors the single-shot artist field in updateShot —
|
|
* the assignment is a plain column write with no notification, so reassigning
|
|
* a batch during planning stays quiet.
|
|
*/
|
|
export async function bulkAssignArtist(shotIds: string[], artistId: 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 ids = z.array(z.string().cuid()).parse(shotIds);
|
|
if (!ids.length) return { success: true, updated: 0, artistName: null as string | null };
|
|
|
|
let artistName: string | null = null;
|
|
if (artistId) {
|
|
const artist = await db.user.findUnique({
|
|
where: { id: z.string().cuid().parse(artistId) },
|
|
select: { id: true, name: true, email: true, role: true, isActive: true },
|
|
});
|
|
// A deactivated user or a client account must never end up owning a shot,
|
|
// so this is checked here rather than trusting whatever the picker sent.
|
|
if (!artist || !artist.isActive || artist.role === "CLIENT") {
|
|
throw new Error("That user cannot be assigned shots");
|
|
}
|
|
artistName = artist.name ?? artist.email;
|
|
}
|
|
|
|
const shots = await db.shot.findMany({
|
|
where: { id: { in: ids } },
|
|
select: { id: true, projectId: true },
|
|
});
|
|
if (shots.length !== ids.length) throw new Error("Some shots not found");
|
|
|
|
await db.shot.updateMany({
|
|
where: { id: { in: ids } },
|
|
data: { artistId },
|
|
});
|
|
|
|
for (const pid of [...new Set(shots.map((s) => s.projectId))]) {
|
|
revalidatePath(`/projects/${pid}`);
|
|
}
|
|
revalidatePath(`/shot-status`);
|
|
|
|
return { success: true, updated: shots.length, artistName };
|
|
}
|
|
|
|
// ── Bulk Manual Delivery ──────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Record that a selection of shots was delivered outside the render pipeline
|
|
* (by hand, before packages existed), or clear that record when
|
|
* `deliveredOn` is null. The delivery countdown counts these alongside
|
|
* DELIVERED EXR packages (lib/dashboard/ops.ts).
|
|
*
|
|
* `deliveredOn` is the day the frames went out ("yyyy-MM-dd"), stored at
|
|
* noon UTC so the calendar day survives any timezone it is shown in.
|
|
*/
|
|
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");
|
|
if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role)) {
|
|
throw new Error("Insufficient permissions");
|
|
}
|
|
|
|
const ids = z.array(z.string().cuid()).parse(shotIds);
|
|
if (!ids.length) return { success: true as const, updated: 0 };
|
|
|
|
let deliveredAt: Date | null = null;
|
|
if (deliveredOn) {
|
|
const day = z.string().regex(/^\d{4}-\d{2}-\d{2}$/).parse(deliveredOn);
|
|
deliveredAt = new Date(`${day}T12:00:00.000Z`);
|
|
if (Number.isNaN(deliveredAt.getTime())) throw new Error("Invalid delivery date");
|
|
if (deliveredAt.getTime() > Date.now() + 24 * 60 * 60 * 1000) {
|
|
throw new Error("A delivery date can't be in the future");
|
|
}
|
|
}
|
|
|
|
const shots = await db.shot.findMany({
|
|
where: { id: { in: ids } },
|
|
select: { id: true, projectId: true },
|
|
});
|
|
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
|
|
? {
|
|
manualDeliveredAt: deliveredAt,
|
|
manualDeliveredById: session.user.id,
|
|
manualDeliveryNote: trimmedNote,
|
|
manualDeliveryUrl: uploadUrl,
|
|
manualDeliverySharedWith: sharedWith,
|
|
}
|
|
: {
|
|
manualDeliveredAt: null,
|
|
manualDeliveredById: null,
|
|
manualDeliveryNote: null,
|
|
manualDeliveryUrl: null,
|
|
manualDeliverySharedWith: null,
|
|
},
|
|
});
|
|
|
|
for (const pid of [...new Set(shots.map((s) => s.projectId))]) {
|
|
revalidatePath(`/projects/${pid}`);
|
|
}
|
|
revalidatePath(`/shot-status`);
|
|
revalidatePath(`/dashboard`);
|
|
|
|
return { success: true as const, 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 };
|
|
}
|