Files
vfxreview/actions/shots.ts
T
twotalesanimation 5f3c89119a
Deploy / deploy (push) Successful in 2m42s
Further CSV Import method added
2026-08-06 08:36:26 +02:00

1112 lines
36 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 };
}
// ── 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;
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) {
await db.shot.update({
where: { id: existing.id },
data: {
description: row.description || null,
seqTimecodeStart: row.seqTimecodeStart || null,
seqTimecodeEnd: row.seqTimecodeEnd || null,
},
});
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;
await db.shot.create({
data: {
projectId,
shotCode: row.shotCode,
scene,
episode,
shotNumber,
description: row.description || null,
seqTimecodeStart: row.seqTimecodeStart || null,
seqTimecodeEnd: row.seqTimecodeEnd || null,
},
});
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 };
}
/**
* 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 };
}