1005 lines
32 KiB
TypeScript
1005 lines
32 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(),
|
|
});
|
|
|
|
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,
|
|
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 };
|
|
}
|
|
|
|
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 };
|
|
}
|
|
|
|
/**
|
|
* 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 };
|
|
}
|