caaa333f02
Deploy / deploy (push) Failing after 19m11s
Two independent features landing together: Generated shot thumbnails — a shot can now carry a thumbnail promoted from a render export (Shot.generatedThumbnailUrl/generatedThumbnailFrom) alongside the existing brief-supplied thumbnailUrl, which always takes display precedence (lib/shot-thumbnail.ts) so a promoted frame can only ever fill a gap, never overwrite the brief. Shows with pipelineConfig.autoShotThumbnail promote automatically on every finished export (lib/render-pipeline/preview.ts); promoteExportThumbnail/ clearGeneratedThumbnail (actions/shots.ts) are the manual equivalent for everyone else. Worker-side seek logic for picking the promoted frame is covered by RenderWorker/VFXReviewWorker.Tests/ThumbnailSeekTests.cs. Bulk shot delete — the shot-status page's selection bar gets a "Bulk Actions" dropdown (alongside the existing Change Due Date) with Delete Shots. bulkDeleteShots() (actions/shots.ts) deletes one at a time rather than in a single statement: exports_shotId_fkey is ON DELETE RESTRICT by design, so a shot with renders or deliveries on record is protected and gets skipped and reported by name rather than aborting the whole selection or being silently destroyed along with its history. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
196 lines
7.2 KiB
TypeScript
196 lines
7.2 KiB
TypeScript
"use server";
|
||
|
||
import { auth } from "@/auth";
|
||
import { db } from "@/lib/db";
|
||
import { revalidatePath } from "next/cache";
|
||
import { z } from "zod";
|
||
import { Prisma, ProjectStatus } from "@prisma/client";
|
||
import { pipelineConfigSchema } from "@/lib/render-pipeline/project-templates";
|
||
|
||
const createProjectSchema = z.object({
|
||
name: z.string().min(1).max(100),
|
||
code: z.string().min(1).max(20).toUpperCase(),
|
||
showId: z.string().min(1).max(10).regex(/^[A-Z0-9_]+$/i, "1–10 chars, letters/numbers/underscores").toUpperCase(),
|
||
projectType: z.enum(["STANDARD", "EPISODIC"]).default("STANDARD"),
|
||
description: z.string().optional(),
|
||
clientId: z.string().cuid().optional(),
|
||
dueDate: z.string().optional(),
|
||
deadline: z.union([z.string(), z.date()]).optional(),
|
||
startDate: z.string().optional(),
|
||
slackWebhook: z.string().url().optional().or(z.literal("")),
|
||
slackChannel: z.string().optional(),
|
||
});
|
||
|
||
export async function createProject(data: z.infer<typeof createProjectSchema>) {
|
||
const session = await auth();
|
||
if (!session?.user) throw new Error("Unauthorized");
|
||
if (!["ADMIN", "PRODUCER"].includes(session.user.role)) {
|
||
throw new Error("Insufficient permissions");
|
||
}
|
||
|
||
const parsed = createProjectSchema.parse(data);
|
||
|
||
// Verify the session user still exists in the DB (guards against stale JWT after a DB reset)
|
||
const dbUser = await db.user.findUnique({
|
||
where: { id: session.user.id },
|
||
select: { id: true },
|
||
});
|
||
if (!dbUser) throw new Error("Session expired — please sign out and sign back in.");
|
||
|
||
const project = await db.project.create({
|
||
data: {
|
||
name: parsed.name,
|
||
code: parsed.code,
|
||
showId: parsed.showId,
|
||
projectType: parsed.projectType,
|
||
description: parsed.description,
|
||
clientId: parsed.clientId || undefined,
|
||
producerId: session.user.id,
|
||
dueDate: parsed.dueDate
|
||
? new Date(parsed.dueDate)
|
||
: parsed.deadline
|
||
? new Date(parsed.deadline)
|
||
: undefined,
|
||
startDate: parsed.startDate ? new Date(parsed.startDate) : undefined,
|
||
slackWebhook: parsed.slackWebhook || undefined,
|
||
slackChannel: parsed.slackChannel || undefined,
|
||
},
|
||
include: { client: true },
|
||
});
|
||
|
||
revalidatePath("/projects");
|
||
return { success: true, project };
|
||
}
|
||
|
||
const updateProjectSchema = z.object({
|
||
id: z.string().cuid(),
|
||
name: z.string().min(1).max(100).optional(),
|
||
code: z.string().min(1).max(20).optional(),
|
||
showId: z.string().min(1).max(10).regex(/^[A-Z0-9_]+$/i).toUpperCase().optional(),
|
||
projectType: z.enum(["STANDARD", "EPISODIC"]).optional(),
|
||
description: z.string().optional().nullable(),
|
||
status: z.nativeEnum(ProjectStatus).optional(),
|
||
clientId: z.string().cuid().optional().nullable(),
|
||
producerId: z.string().cuid().optional().nullable(),
|
||
supervisorId: z.string().cuid().optional().nullable(),
|
||
dueDate: z.string().optional().nullable(),
|
||
startDate: z.string().optional().nullable(),
|
||
slackWebhook: z.string().url().optional().or(z.literal("")).nullable(),
|
||
slackChannel: z.string().optional().nullable(),
|
||
/** Per-production AE workspace: template AEP, renders root, comp overrides. */
|
||
pipelineConfig: pipelineConfigSchema.optional().nullable(),
|
||
});
|
||
|
||
export async function updateProject(data: z.infer<typeof updateProjectSchema>) {
|
||
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 { id, ...rest } = updateProjectSchema.parse(data);
|
||
|
||
const updated = await db.project.update({
|
||
where: { id },
|
||
data: {
|
||
...(rest.name !== undefined && { name: rest.name }),
|
||
...(rest.code !== undefined && { code: rest.code.toUpperCase() }),
|
||
...(rest.showId !== undefined && { showId: rest.showId }),
|
||
...(rest.projectType !== undefined && { projectType: rest.projectType }),
|
||
...(rest.description !== undefined && { description: rest.description ?? undefined }),
|
||
...(rest.status !== undefined && { status: rest.status }),
|
||
...(rest.clientId !== undefined && { clientId: rest.clientId }),
|
||
...(rest.producerId !== undefined && { producerId: rest.producerId }),
|
||
...(rest.supervisorId !== undefined && { supervisorId: rest.supervisorId }),
|
||
...(rest.dueDate !== undefined && { dueDate: rest.dueDate ? new Date(rest.dueDate) : null }),
|
||
...(rest.startDate !== undefined && { startDate: rest.startDate ? new Date(rest.startDate) : null }),
|
||
...(rest.slackWebhook !== undefined && { slackWebhook: rest.slackWebhook || null }),
|
||
...(rest.slackChannel !== undefined && { slackChannel: rest.slackChannel || null }),
|
||
// Blank fields are dropped rather than stored as empty strings, so a comp
|
||
// name left blank keeps following the convention instead of pinning "".
|
||
// Booleans are kept as-is: `false` is a meaningful stored value, and the
|
||
// blank-string test would otherwise discard every non-string setting.
|
||
...(rest.pipelineConfig !== undefined && {
|
||
pipelineConfig: rest.pipelineConfig
|
||
? (Object.fromEntries(
|
||
Object.entries(rest.pipelineConfig).filter(([, v]) =>
|
||
typeof v === "boolean" ? true : typeof v === "string" && !!v.trim()
|
||
)
|
||
) as Prisma.InputJsonValue)
|
||
: Prisma.DbNull,
|
||
}),
|
||
},
|
||
});
|
||
|
||
revalidatePath(`/projects/${id}`);
|
||
revalidatePath("/projects");
|
||
return { success: true, project: updated };
|
||
}
|
||
|
||
export async function updateProjectStatus(
|
||
projectId: string,
|
||
status: ProjectStatus
|
||
) {
|
||
const session = await auth();
|
||
if (!session?.user) throw new Error("Unauthorized");
|
||
if (!["ADMIN", "PRODUCER"].includes(session.user.role)) {
|
||
throw new Error("Insufficient permissions");
|
||
}
|
||
|
||
await db.project.update({ where: { id: projectId }, data: { status } });
|
||
revalidatePath(`/projects/${projectId}`);
|
||
return { success: true };
|
||
}
|
||
|
||
export async function getProjects() {
|
||
const session = await auth();
|
||
if (!session?.user) throw new Error("Unauthorized");
|
||
|
||
// Clients only see their assigned projects
|
||
if (session.user.role === "CLIENT") {
|
||
const access = await db.clientAccess.findMany({
|
||
where: { userId: session.user.id },
|
||
select: { clientId: true },
|
||
});
|
||
const clientIds = access.map((a) => a.clientId);
|
||
return db.project.findMany({
|
||
where: { clientId: { in: clientIds }, status: { not: "ARCHIVED" } },
|
||
include: {
|
||
client: true,
|
||
_count: { select: { shots: true } },
|
||
},
|
||
orderBy: { updatedAt: "desc" },
|
||
});
|
||
}
|
||
|
||
return db.project.findMany({
|
||
include: {
|
||
client: true,
|
||
producer: { select: { id: true, name: true } },
|
||
_count: { select: { shots: true } },
|
||
},
|
||
orderBy: { updatedAt: "desc" },
|
||
});
|
||
}
|
||
|
||
export async function getProjectById(id: string) {
|
||
const session = await auth();
|
||
if (!session?.user) throw new Error("Unauthorized");
|
||
|
||
return db.project.findUnique({
|
||
where: { id },
|
||
include: {
|
||
client: true,
|
||
producer: { select: { id: true, name: true, email: true } },
|
||
supervisor: { select: { id: true, name: true, email: true } },
|
||
shots: {
|
||
include: {
|
||
artist: { select: { id: true, name: true, image: true } },
|
||
_count: { select: { versions: true } },
|
||
},
|
||
orderBy: { shotCode: "asc" },
|
||
},
|
||
},
|
||
});
|
||
}
|