375 lines
12 KiB
TypeScript
375 lines
12 KiB
TypeScript
"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<typeof createShotSchema>) {
|
|
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<typeof updateShotSchema>) {
|
|
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" }],
|
|
});
|
|
}
|