"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(), }); 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, }, }); 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, // 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(), 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, ...rest } = parsed; const shot = await db.shot.update({ where: { id: shotId }, data: { ...rest, 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" }], }); }