Files
vfxreview/actions/shoot-log.ts
T
twotalesanimation e24fd8eda0
Deploy / deploy (push) Successful in 3m5s
Supervisor feature
2026-07-11 15:06:12 +02:00

348 lines
11 KiB
TypeScript

"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<typeof createShootDaySchema>) {
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<typeof createSetupSchema>) {
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<z.infer<typeof takeFieldsSchema>>
) {
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<z.infer<typeof takeFieldsSchema>>
) {
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<typeof addAttachmentSchema>) {
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<string, unknown>) {
const clean: Record<string, unknown> = {};
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}`;
}