348 lines
11 KiB
TypeScript
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}`;
|
|
}
|