"use server"; import { auth } from "@/auth"; import { db } from "@/lib/db"; import { revalidatePath } from "next/cache"; import { z } from "zod"; import { ShotStatus, ShotPriority } from "@prisma/client"; 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) { 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(), 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) { 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 }; } 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 }; }