b151e8cc52
Deploy / deploy (push) Successful in 7m25s
Clients sometimes ship footage under a naming convention that doesn't match our own shot codes. Adds an optional Shot.clientCode, editable on the Slate tab: when set, it replaces shotCode only in what the client actually sees — slate identity, overlay burn-in, and delivered EXR/MOV file and folder names in Netflix packages — with our own version string flatly appended (no "_cmp_TT_" studio jargon mixed in). Internal render paths, EXR filenames on the SAN, comp names, and DB lookups are untouched. Shots without a client code render identically to before. Delivery packages denormalize clientCode at build time, same as the existing shotCode/versionString snapshot, so a later shot edit can't retroactively change already-built delivery history. The worker now renames delivered EXR frames and the delivery MOV using the existing frame-number parser (SequencePattern) so only the base name changes — frame numbers and padding survive exactly. This is the one piece of the change that could plausibly ship the wrong bytes, so it's covered by new unit tests (renaming, padding, MOV rename, checksum-survives-rename, and a graceful fallback for an unparseable frame number) rather than resting on manual verification alone. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1216 lines
40 KiB
TypeScript
1216 lines
40 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 } from "@prisma/client";
|
|
import { recalcShotStatus } 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 };
|
|
}
|
|
|
|
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 };
|
|
}
|
|
|
|
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 };
|
|
}
|
|
|
|
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");
|
|
|
|
await db.shot.update({
|
|
where: { id: shotId },
|
|
data: {
|
|
shotApprovalStatus: "INTERNALLY_APPROVED",
|
|
sharedWithClient: false,
|
|
status: "READY_FOR_CLIENT",
|
|
},
|
|
});
|
|
|
|
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, status: "CLIENT_REVIEW" },
|
|
});
|
|
|
|
// 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,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
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, status: "READY_FOR_CLIENT" },
|
|
});
|
|
|
|
// 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 },
|
|
});
|
|
}
|
|
});
|
|
|
|
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.shot.update({
|
|
where: { id: shotId },
|
|
data: { shotApprovalStatus: "CLIENT_APPROVED", status: "COMPLETE" },
|
|
});
|
|
|
|
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");
|
|
}
|
|
|
|
const newStatus = shot.sharedWithClient ? "CLIENT_REVIEW" : "READY_FOR_CLIENT";
|
|
|
|
await db.shot.update({
|
|
where: { id: shotId },
|
|
data: {
|
|
shotApprovalStatus: "INTERNALLY_APPROVED",
|
|
status: newStatus,
|
|
},
|
|
});
|
|
|
|
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 };
|
|
}
|
|
|
|
/**
|
|
* Internal: handle client requesting changes on a shot.
|
|
* Resets shotApprovalStatus = PENDING, sharedWithClient = false.
|
|
* Recalculates shot status (will become REVISIONS if tasks set to CHANGES).
|
|
*/
|
|
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(async (tx) => {
|
|
// Reset shot approval
|
|
await tx.shot.update({
|
|
where: { id: shotId },
|
|
data: { shotApprovalStatus: "PENDING", sharedWithClient: false },
|
|
});
|
|
|
|
// If a specific task is called out, mark it CHANGES; otherwise mark all non-DONE tasks
|
|
if (taskId) {
|
|
await tx.task.update({
|
|
where: { id: taskId },
|
|
data: { status: "CHANGES" },
|
|
});
|
|
} else {
|
|
await tx.task.updateMany({
|
|
where: { shotId, status: { not: "DONE" } },
|
|
data: { status: "CHANGES" },
|
|
});
|
|
}
|
|
|
|
await recalcShotStatus(shotId, tx);
|
|
});
|
|
|
|
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 };
|
|
}
|