"use server"; import { auth } from "@/auth"; import { db } from "@/lib/db"; import { revalidatePath } from "next/cache"; import { z } from "zod"; import { TakeQuality, AttachmentFileType, AttachmentCategory } from "@prisma/client"; // ── Permission guard ───────────────────────────────────────────────────────── async function requireShootAccess() { const session = await auth(); if (!session?.user) throw new Error("Unauthorized"); if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role)) { throw new Error("Insufficient permissions"); } return session.user; } // ── Shoot Day ──────────────────────────────────────────────────────────────── const createShootDaySchema = z.object({ projectId: z.string().cuid(), date: z.string().min(1), unit: z.string().max(20).default("A"), label: z.string().max(120).optional(), notes: z.string().optional(), }); export async function createShootDay(data: z.infer) { const user = await requireShootAccess(); const parsed = createShootDaySchema.parse(data); const shootDay = await db.shootDay.create({ data: { projectId: parsed.projectId, date: new Date(parsed.date), unit: parsed.unit, label: parsed.label, notes: parsed.notes, createdById: user.id, }, include: { setups: { include: { takes: { include: { attachments: true } } } } }, }); revalidatePath("/shoot-log"); return shootDay; } export async function updateShootDay( id: string, data: Partial<{ label: string; notes: string; unit: string }> ) { await requireShootAccess(); const day = await db.shootDay.update({ where: { id }, data, }); revalidatePath("/shoot-log"); return day; } // ── Setup ──────────────────────────────────────────────────────────────────── const createSetupSchema = z.object({ shootDayId: z.string().cuid(), name: z.string().min(1).max(100), description: z.string().optional(), }); export async function createSetup(data: z.infer) { await requireShootAccess(); const parsed = createSetupSchema.parse(data); const lastSetup = await db.setup.findFirst({ where: { shootDayId: parsed.shootDayId }, orderBy: { sortOrder: "desc" }, }); const setup = await db.setup.create({ data: { shootDayId: parsed.shootDayId, name: parsed.name, description: parsed.description, sortOrder: (lastSetup?.sortOrder ?? -1) + 1, }, include: { takes: { include: { attachments: true } } }, }); revalidatePath("/shoot-log"); return setup; } export async function updateSetup(id: string, data: Partial<{ name: string; description: string }>) { await requireShootAccess(); const setup = await db.setup.update({ where: { id }, data }); revalidatePath("/shoot-log"); return setup; } export async function deleteSetup(id: string) { await requireShootAccess(); await db.setup.delete({ where: { id } }); revalidatePath("/shoot-log"); } // ── Take ───────────────────────────────────────────────────────────────────── const takeFieldsSchema = z.object({ scene: z.string().max(100).optional(), shotLabel: z.string().max(100).optional(), unitLabel: z.string().max(50).optional(), cameraLetter: z.string().max(10).optional(), clipName: z.string().max(120).optional(), roll: z.string().max(50).optional(), cameraModel: z.string().max(100).optional(), resolution: z.string().max(50).optional(), codec: z.string().max(50).optional(), fps: z.number().positive().optional(), shutter: z.string().max(30).optional(), iso: z.number().int().positive().optional(), whiteBalance: z.number().int().positive().optional(), colourSpace: z.string().max(80).optional(), lensSet: z.string().max(100).optional(), lens: z.string().max(100).optional(), tStop: z.string().max(20).optional(), filters: z.string().max(200).optional(), isAnamorphic: z.boolean().optional(), hasHdri: z.boolean().optional(), hasChromeBall: z.boolean().optional(), hasGreyBall: z.boolean().optional(), hasMacbeth: z.boolean().optional(), hasCleanPlate: z.boolean().optional(), hasSurvey: z.boolean().optional(), hasLidar: z.boolean().optional(), hasWitnessCamera: z.boolean().optional(), hasLensGrid: z.boolean().optional(), hasTexturePhotos: z.boolean().optional(), hasPhotogrammetry: z.boolean().optional(), weather: z.string().max(100).optional(), sunDirection: z.string().max(100).optional(), artificialLights: z.string().optional(), supervisorNotes: z.string().optional(), continuityNotes: z.string().optional(), vfxRequirements: z.string().optional(), quality: z.nativeEnum(TakeQuality).optional(), pipelineShotId: z.string().cuid().optional().or(z.literal("")), }); export async function createTake( setupId: string, initialData?: Partial> ) { const user = await requireShootAccess(); const lastTake = await db.take.findFirst({ where: { setupId }, orderBy: { takeNumber: "desc" }, }); const takeNumber = (lastTake?.takeNumber ?? 0) + 1; const take = await db.take.create({ data: { setupId, takeNumber, createdById: user.id, ...sanitizeTakeData(initialData ?? {}), }, include: { attachments: true }, }); revalidatePath("/shoot-log"); return take; } export async function updateTake( id: string, data: Partial> ) { await requireShootAccess(); const parsed = takeFieldsSchema.partial().parse(data); const take = await db.take.update({ where: { id }, data: sanitizeTakeData(parsed), include: { attachments: true }, }); revalidatePath("/shoot-log"); return take; } export async function duplicateTake(sourceId: string) { const user = await requireShootAccess(); const source = await db.take.findUniqueOrThrow({ where: { id: sourceId }, include: { setup: true }, }); const lastTake = await db.take.findFirst({ where: { setupId: source.setupId }, orderBy: { takeNumber: "desc" }, }); const takeNumber = (lastTake?.takeNumber ?? 0) + 1; // Increment clip name suffix if pattern matches e.g. A001_C002 -> A001_C003 const nextClipName = incrementClipName(source.clipName); const newTake = await db.take.create({ data: { setupId: source.setupId, takeNumber, createdById: user.id, scene: source.scene, shotLabel: source.shotLabel, unitLabel: source.unitLabel, cameraLetter: source.cameraLetter, clipName: nextClipName, roll: source.roll, cameraModel: source.cameraModel, resolution: source.resolution, codec: source.codec, fps: source.fps, shutter: source.shutter, iso: source.iso, whiteBalance: source.whiteBalance, colourSpace: source.colourSpace, lensSet: source.lensSet, lens: source.lens, tStop: source.tStop, filters: source.filters, isAnamorphic: source.isAnamorphic, hasHdri: source.hasHdri, hasChromeBall: source.hasChromeBall, hasGreyBall: source.hasGreyBall, hasMacbeth: source.hasMacbeth, hasCleanPlate: source.hasCleanPlate, hasSurvey: source.hasSurvey, hasLidar: source.hasLidar, hasWitnessCamera: source.hasWitnessCamera, hasLensGrid: source.hasLensGrid, hasTexturePhotos: source.hasTexturePhotos, hasPhotogrammetry: source.hasPhotogrammetry, weather: source.weather, sunDirection: source.sunDirection, artificialLights: source.artificialLights, quality: source.quality, // Notes reset for new take supervisorNotes: null, continuityNotes: null, vfxRequirements: null, }, include: { attachments: true }, }); revalidatePath("/shoot-log"); return newTake; } export async function deleteTake(id: string) { await requireShootAccess(); await db.take.delete({ where: { id } }); revalidatePath("/shoot-log"); } // ── Attachments ────────────────────────────────────────────────────────────── const addAttachmentSchema = z.object({ takeId: z.string().cuid(), fileUrl: z.string().min(1), fileKey: z.string().default(""), fileName: z.string().min(1), fileSize: z.number().optional(), fileType: z.nativeEnum(AttachmentFileType).default("IMAGE"), category: z.nativeEnum(AttachmentCategory).default("MISCELLANEOUS"), caption: z.string().max(200).optional(), }); export async function addTakeAttachment(data: z.infer) { const user = await requireShootAccess(); const parsed = addAttachmentSchema.parse(data); const lastAttachment = await db.takeAttachment.findFirst({ where: { takeId: parsed.takeId }, orderBy: { sortOrder: "desc" }, }); const attachment = await db.takeAttachment.create({ data: { takeId: parsed.takeId, fileUrl: parsed.fileUrl, fileKey: parsed.fileKey, fileName: parsed.fileName, fileSize: parsed.fileSize ? BigInt(parsed.fileSize) : undefined, fileType: parsed.fileType, category: parsed.category, caption: parsed.caption, sortOrder: (lastAttachment?.sortOrder ?? -1) + 1, uploadedById: user.id, }, }); revalidatePath("/shoot-log"); return attachment; } export async function updateAttachmentCaption(id: string, caption: string) { await requireShootAccess(); return db.takeAttachment.update({ where: { id }, data: { caption } }); } export async function updateAttachmentCategory(id: string, category: AttachmentCategory) { await requireShootAccess(); return db.takeAttachment.update({ where: { id }, data: { category } }); } export async function deleteTakeAttachment(id: string) { await requireShootAccess(); await db.takeAttachment.delete({ where: { id } }); revalidatePath("/shoot-log"); } // ── Helpers ────────────────────────────────────────────────────────────────── function sanitizeTakeData(data: Record) { const clean: Record = {}; for (const [k, v] of Object.entries(data)) { if (v === undefined) continue; if (v === "") { clean[k] = null; } else { clean[k] = v; } } return clean; } /** Increment trailing clip number: A001_C002 → A001_C003, ROLL_007 → ROLL_008 */ function incrementClipName(clipName: string | null | undefined): string | null { if (!clipName) return null; const match = clipName.match(/^(.*?)(\d+)$/); if (!match) return clipName; const [, prefix, numStr] = match; const next = String(Number(numStr) + 1).padStart(numStr.length, "0"); return `${prefix}${next}`; }