@@ -0,0 +1,347 @@
|
||||
"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}`;
|
||||
}
|
||||
Reference in New Issue
Block a user