Initial commit

This commit is contained in:
twotalesanimation
2026-05-19 22:20:29 +02:00
commit 0fbe856dce
173 changed files with 38316 additions and 0 deletions
+102
View File
@@ -0,0 +1,102 @@
"use server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import type { AnnotationDrawingData } from "@/types";
const saveAnnotationSchema = z.object({
versionId: z.string().cuid(),
commentId: z.string().cuid().optional(),
frameNumber: z.number().int().min(0),
drawingData: z.object({
shapes: z.array(z.any()),
canvasWidth: z.number(),
canvasHeight: z.number(),
version: z.literal("1.0"),
}),
color: z.string().default("#ef4444"),
});
export async function saveAnnotation(data: z.infer<typeof saveAnnotationSchema>) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
const parsed = saveAnnotationSchema.parse(data);
const annotation = await db.annotation.create({
data: {
versionId: parsed.versionId,
commentId: parsed.commentId,
authorId: session.user.id,
frameNumber: parsed.frameNumber,
drawingData: parsed.drawingData as any,
color: parsed.color,
},
});
revalidatePath(`/review/${parsed.versionId}`);
return { success: true, annotation };
}
export async function getAnnotationsForVersion(versionId: string) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
return db.annotation.findMany({
where: { versionId, isVisible: true },
include: {
author: { select: { id: true, name: true, image: true } },
},
orderBy: { frameNumber: "asc" },
});
}
export async function getAnnotationsForFrame(
versionId: string,
frameNumber: number
) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
return db.annotation.findMany({
where: { versionId, frameNumber, isVisible: true },
include: {
author: { select: { id: true, name: true, image: true } },
},
});
}
export async function toggleAnnotationVisibility(
annotationId: string,
visible: boolean
) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
await db.annotation.update({
where: { id: annotationId },
data: { isVisible: visible },
});
return { success: true };
}
export async function deleteAnnotation(annotationId: string) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
const annotation = await db.annotation.findUnique({
where: { id: annotationId },
});
if (!annotation) throw new Error("Annotation not found");
if (annotation.authorId !== session.user.id && session.user.role !== "ADMIN") {
throw new Error("Unauthorized");
}
await db.annotation.delete({ where: { id: annotationId } });
revalidatePath(`/review/${annotation.versionId}`);
return { success: true };
}
+130
View File
@@ -0,0 +1,130 @@
"use server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { ApprovalStatus } from "@prisma/client";
import { recalcShotStatus } from "@/lib/shot-status";
import { notifyApprovalChange } from "@/lib/notifications";
import { slackNotifyApproval } from "@/lib/slack";
import { versionLabel } from "@/lib/utils";
const approvalSchema = z.object({
versionId: z.string().cuid(),
status: z.nativeEnum(ApprovalStatus),
notes: z.string().optional(),
});
export async function submitApproval(data: z.infer<typeof approvalSchema>) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
const parsed = approvalSchema.parse(data);
// Only supervisors, producers, admins, and clients can approve
const allowedRoles = ["ADMIN", "PRODUCER", "SUPERVISOR", "CLIENT"];
if (!allowedRoles.includes(session.user.role)) {
throw new Error("Insufficient permissions to review versions");
}
// Create the approval record
const approval = await db.approval.create({
data: {
versionId: parsed.versionId,
userId: session.user.id,
status: parsed.status,
notes: parsed.notes,
},
});
// Update version approval status
await db.version.update({
where: { id: parsed.versionId },
data: { approvalStatus: parsed.status },
});
// Load version + task + shot + project for downstream effects
const version = await db.version.findUnique({
where: { id: parsed.versionId },
include: {
task: {
include: {
shot: true,
project: true,
},
},
},
});
if (!version) throw new Error("Version not found");
// Update task status based on approval result
if (version.task && parsed.status !== "PENDING_REVIEW") {
if (parsed.status === "APPROVED") {
await db.task.update({
where: { id: version.task.id },
data: { status: "DONE" },
});
} else if (parsed.status === "REJECTED" || parsed.status === "NEEDS_CHANGES") {
await db.task.update({
where: { id: version.task.id },
data: { status: "CHANGES" },
});
}
// Recalculate shot status from updated task states
if (version.task.shot) {
await recalcShotStatus(version.task.shot.id).catch(() => {});
}
}
// Notifications
const reviewer = await db.user.findUnique({ where: { id: session.user.id } });
const reviewerName = reviewer?.name ?? "Reviewer";
if (version.task && parsed.status !== "PENDING_REVIEW") {
const contextCode = version.task.shot?.shotCode ?? null;
await notifyApprovalChange({
artistId: version.task.assignedArtistId ?? version.artistId,
shotCode: contextCode ?? version.task.title,
versionId: parsed.versionId,
status: parsed.status as "APPROVED" | "REJECTED" | "NEEDS_CHANGES",
reviewerName,
});
// Slack
if (version.task.project.slackWebhook) {
const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000";
await slackNotifyApproval(version.task.project.slackWebhook, {
shotCode: contextCode ?? version.task.title,
versionLabel: versionLabel(version.versionNumber),
status: parsed.status as "APPROVED" | "REJECTED" | "NEEDS_CHANGES",
reviewerName,
projectName: version.task.project.name,
reviewUrl: `${appUrl}/review/${parsed.versionId}`,
});
}
}
revalidatePath(`/review/${parsed.versionId}`);
if (version.task) {
revalidatePath(`/tasks/${version.task.id}`);
revalidatePath(`/projects/${version.task.projectId}`);
}
return { success: true, approval };
}
export async function getApprovalHistory(versionId: string) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
return db.approval.findMany({
where: { versionId },
include: {
user: { select: { id: true, name: true, email: true, image: true, role: true } },
},
orderBy: { createdAt: "desc" },
});
}
+116
View File
@@ -0,0 +1,116 @@
"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 createAssetSchema = z.object({
assetCode: z.string().min(1, "Asset code is required").max(30).regex(/^[A-Z0-9_\-]+$/i),
name: z.string().min(1, "Name is required").max(200),
description: z.string().optional(),
status: z.nativeEnum(ShotStatus).default("WAITING"),
priority: z.nativeEnum(ShotPriority).default("NORMAL"),
leadId: z.string().cuid().optional().or(z.literal("")),
dueDate: z.string().optional(),
projectId: z.string().cuid(),
});
export async function createAsset(data: z.infer<typeof createAssetSchema>) {
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 = createAssetSchema.parse(data);
const asset = await db.asset.create({
data: {
assetCode: parsed.assetCode.toUpperCase(),
name: parsed.name,
description: parsed.description,
status: parsed.status,
priority: parsed.priority,
leadId: parsed.leadId || undefined,
dueDate: parsed.dueDate ? new Date(parsed.dueDate) : undefined,
projectId: parsed.projectId,
},
});
revalidatePath(`/projects/${parsed.projectId}`);
return { success: true, asset };
}
const updateAssetSchema = z.object({
name: z.string().min(1).max(200).optional(),
description: z.string().optional(),
status: z.nativeEnum(ShotStatus).optional(),
priority: z.nativeEnum(ShotPriority).optional(),
leadId: z.string().cuid().nullable().optional(),
dueDate: z.string().nullable().optional(),
});
export async function updateAsset(assetId: string, data: z.infer<typeof updateAssetSchema>) {
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 = updateAssetSchema.parse(data);
const asset = await db.asset.findUnique({ where: { id: assetId } });
if (!asset) throw new Error("Asset not found");
const updated = await db.asset.update({
where: { id: assetId },
data: {
...(parsed.name !== undefined && { name: parsed.name }),
...(parsed.description !== undefined && { description: parsed.description }),
...(parsed.status !== undefined && { status: parsed.status }),
...(parsed.priority !== undefined && { priority: parsed.priority }),
...(parsed.leadId !== undefined && { leadId: parsed.leadId }),
...(parsed.dueDate !== undefined && {
dueDate: parsed.dueDate ? new Date(parsed.dueDate) : null,
}),
},
});
revalidatePath(`/projects/${asset.projectId}`);
return { success: true, asset: updated };
}
export async function deleteAsset(assetId: 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");
}
const asset = await db.asset.findUnique({ where: { id: assetId } });
if (!asset) throw new Error("Asset not found");
await db.asset.delete({ where: { id: assetId } });
revalidatePath(`/projects/${asset.projectId}`);
return { success: true };
}
export async function getProjectAssets(projectId: string) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
return db.asset.findMany({
where: { projectId },
orderBy: { assetCode: "asc" },
include: {
lead: { select: { id: true, name: true, email: true, image: true } },
_count: { select: { tasks: true } },
tasks: {
orderBy: { sortOrder: "asc" },
select: { id: true, status: true, title: true, type: true },
},
},
});
}
+154
View File
@@ -0,0 +1,154 @@
"use server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { notifyFeedbackAdded, notifyCommentReply } from "@/lib/notifications";
import { slackNotifyNewFeedback } from "@/lib/slack";
const addCommentSchema = z.object({
versionId: z.string().cuid(),
frameNumber: z.number().int().min(0),
timestamp: z.number().min(0),
text: z.string().min(1).max(5000),
});
export async function addComment(data: z.infer<typeof addCommentSchema>) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
const parsed = addCommentSchema.parse(data);
const comment = await db.comment.create({
data: {
versionId: parsed.versionId,
authorId: session.user.id,
frameNumber: parsed.frameNumber,
timestamp: parsed.timestamp,
text: parsed.text,
},
include: {
author: { select: { id: true, name: true, email: true, image: true, role: true } },
replies: true,
annotations: true,
},
});
// Notify the shot artist if commenter is not the artist
const version = await db.version.findUnique({
where: { id: parsed.versionId },
include: {
shot: { include: { project: true } },
task: { include: { project: true, shot: true } },
},
});
const slackWebhook =
version?.shot?.project?.slackWebhook ??
version?.task?.project?.slackWebhook ??
null;
const shotCode =
version?.shot?.shotCode ??
version?.task?.shot?.shotCode ??
version?.task?.title ??
"Task";
if (version?.shot?.artistId && version.shot.artistId !== session.user.id) {
const user = await db.user.findUnique({ where: { id: session.user.id } });
await notifyFeedbackAdded({
artistId: version.shot.artistId,
shotCode: version.shot.shotCode,
frameNumber: parsed.frameNumber,
versionId: parsed.versionId,
authorName: user?.name ?? session.user.email ?? "Someone",
});
}
// Slack notification
if (slackWebhook) {
const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000";
const user = await db.user.findUnique({ where: { id: session.user.id } });
await slackNotifyNewFeedback(slackWebhook, {
shotCode,
frameNumber: parsed.frameNumber,
authorName: user?.name ?? "Someone",
commentText: parsed.text,
reviewUrl: `${appUrl}/review/${parsed.versionId}`,
});
}
revalidatePath(`/review/${parsed.versionId}`);
return { success: true, comment };
}
export async function addReply(commentId: string, text: string) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
if (!text?.trim()) throw new Error("Reply text is required");
const reply = await db.commentReply.create({
data: {
commentId,
authorId: session.user.id,
text: text.trim(),
},
include: {
author: { select: { id: true, name: true, email: true, image: true, role: true } },
},
});
// Notify the original comment author
const comment = await db.comment.findUnique({
where: { id: commentId },
include: {
version: { include: { shot: true, task: { select: { title: true } } } },
},
});
if (comment && comment.authorId && comment.authorId !== session.user.id) {
const user = await db.user.findUnique({ where: { id: session.user.id } });
await notifyCommentReply({
commentAuthorId: comment.authorId,
replierName: user?.name ?? "Someone",
shotCode: comment.version.shot?.shotCode ?? comment.version.task?.title ?? "Task",
frameNumber: comment.frameNumber,
versionId: comment.versionId,
});
}
revalidatePath(`/review/${comment?.versionId}`);
return { success: true, reply };
}
export async function resolveComment(commentId: string, resolved: boolean) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
const comment = await db.comment.update({
where: { id: commentId },
data: { isResolved: resolved },
});
revalidatePath(`/review/${comment.versionId}`);
return { success: true };
}
export async function deleteComment(commentId: string) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
const comment = await db.comment.findUnique({ where: { id: commentId } });
if (!comment) throw new Error("Comment not found");
// Only the author or admin can delete
if (comment.authorId !== session.user.id && session.user.role !== "ADMIN") {
throw new Error("Unauthorized");
}
await db.comment.delete({ where: { id: commentId } });
revalidatePath(`/review/${comment.versionId}`);
return { success: true };
}
+179
View File
@@ -0,0 +1,179 @@
"use server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { ProjectStatus } from "@prisma/client";
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, "110 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(),
});
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 }),
},
});
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" },
},
},
});
}
+100
View File
@@ -0,0 +1,100 @@
"use server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { revalidatePath } from "next/cache";
import { z } from "zod";
function requireScheduleAccess(role: string) {
if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(role)) {
throw new Error("Insufficient permissions");
}
}
const scheduleTaskSchema = z.object({
taskId: z.string().cuid(),
scheduledStartDate: z.string().nullable(),
scheduledEndDate: z.string().nullable(),
assignedArtistId: z.string().cuid().nullable().optional(),
scheduleNotes: z.string().nullable().optional(),
estimatedHours: z.number().positive().optional(),
});
export async function scheduleTask(data: z.infer<typeof scheduleTaskSchema>) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
requireScheduleAccess(session.user.role);
const parsed = scheduleTaskSchema.parse(data);
await db.task.update({
where: { id: parsed.taskId },
data: {
scheduledStartDate: parsed.scheduledStartDate
? new Date(parsed.scheduledStartDate)
: null,
scheduledEndDate: parsed.scheduledEndDate
? new Date(parsed.scheduledEndDate)
: null,
...(parsed.assignedArtistId !== undefined && {
assignedArtistId: parsed.assignedArtistId,
}),
...(parsed.scheduleNotes !== undefined && {
scheduleNotes: parsed.scheduleNotes,
}),
...(parsed.estimatedHours !== undefined && {
estimatedHours: parsed.estimatedHours,
}),
},
});
revalidatePath("/schedule");
revalidatePath("/dashboard");
return { success: true };
}
export async function unscheduleTask(taskId: string) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
requireScheduleAccess(session.user.role);
await db.task.update({
where: { id: taskId },
data: {
scheduledStartDate: null,
scheduledEndDate: null,
},
});
revalidatePath("/schedule");
return { success: true };
}
export async function bulkSchedule(
tasks: {
taskId: string;
scheduledStartDate: string;
scheduledEndDate: string;
assignedArtistId?: string;
}[]
) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
requireScheduleAccess(session.user.role);
await db.$transaction(
tasks.map((t) =>
db.task.update({
where: { id: t.taskId },
data: {
scheduledStartDate: new Date(t.scheduledStartDate),
scheduledEndDate: new Date(t.scheduledEndDate),
...(t.assignedArtistId ? { assignedArtistId: t.assignedArtistId } : {}),
},
})
)
);
revalidatePath("/schedule");
return { success: true };
}
+278
View File
@@ -0,0 +1,278 @@
"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 };
}
// ── 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(),
});
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" }],
});
}
+394
View File
@@ -0,0 +1,394 @@
"use server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { TaskStatus, TaskType, ShotPriority } from "@prisma/client";
import { recalcShotStatus } from "@/lib/shot-status";
import {
notifyTaskAssigned,
notifyTaskReadyForReview,
notifyTaskApproved,
notifyTaskChangesRequested,
} from "@/lib/notifications";
import {
slackNotifyTaskAssigned,
slackNotifyTaskReadyForReview,
} from "@/lib/slack";
const createTaskSchema = z.object({
title: z.string().min(1, "Title is required").max(200),
description: z.string().optional(),
type: z.nativeEnum(TaskType).default("GENERAL"),
priority: z.nativeEnum(ShotPriority).default("NORMAL"),
dueDate: z.string().optional(),
estimatedHours: z.coerce.number().positive().optional().or(z.literal("")),
shotId: z.string().cuid().optional().or(z.literal("")),
assetId: z.string().cuid().optional().or(z.literal("")),
assignedArtistId: z.string().cuid().optional().or(z.literal("")),
projectId: z.string().cuid(),
});
export async function createTask(data: z.infer<typeof createTaskSchema>) {
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 = createTaskSchema.parse(data);
// Get sort order (append at end)
const lastTask = await db.task.findFirst({
where: {
shotId: parsed.shotId || undefined,
assetId: parsed.assetId || undefined,
},
orderBy: { sortOrder: "desc" },
select: { sortOrder: true },
});
const task = await db.task.create({
data: {
title: parsed.title,
description: parsed.description,
type: parsed.type,
priority: parsed.priority,
dueDate: parsed.dueDate ? new Date(parsed.dueDate) : undefined,
estimatedHours: parsed.estimatedHours ? Number(parsed.estimatedHours) : undefined,
sortOrder: (lastTask?.sortOrder ?? -1) + 1,
shotId: parsed.shotId || undefined,
assetId: parsed.assetId || undefined,
assignedArtistId: parsed.assignedArtistId || undefined,
createdById: session.user.id,
projectId: parsed.projectId,
},
});
// Recalculate shot status when a new task is added
if (parsed.shotId) {
await recalcShotStatus(parsed.shotId).catch(() => {});
}
// Notifications: task assigned
if (parsed.assignedArtistId) {
const project = await db.project.findUnique({
where: { id: parsed.projectId },
select: { name: true, code: true, slackWebhook: true },
});
const contextShot = parsed.shotId
? await db.shot.findUnique({ where: { id: parsed.shotId }, select: { shotCode: true } })
: null;
const contextAsset = parsed.assetId
? await db.asset.findUnique({ where: { id: parsed.assetId }, select: { assetCode: true } })
: null;
const contextCode = contextShot?.shotCode ?? contextAsset?.assetCode ?? null;
const taskUrl = `${process.env.NEXTAUTH_URL ?? ""}/tasks/${task.id}`;
const assignedByName = session.user.name ?? session.user.email ?? "Someone";
const artist = await db.user.findUnique({
where: { id: parsed.assignedArtistId },
select: { name: true, email: true },
});
await notifyTaskAssigned({
artistId: parsed.assignedArtistId,
taskTitle: task.title,
taskId: task.id,
contextCode,
assignedByName,
}).catch(() => {});
if (project?.slackWebhook) {
await slackNotifyTaskAssigned(project.slackWebhook, {
taskTitle: task.title,
contextCode,
artistName: artist?.name ?? artist?.email ?? "artist",
assignedByName,
projectName: project.name,
taskUrl,
}).catch(() => {});
}
}
revalidatePath(`/projects/${parsed.projectId}`);
if (parsed.shotId) revalidatePath(`/projects/${parsed.projectId}/shots/${parsed.shotId}`);
return { success: true, task };
}
const updateTaskSchema = z.object({
title: z.string().min(1).max(200).optional(),
description: z.string().optional(),
type: z.nativeEnum(TaskType).optional(),
status: z.nativeEnum(TaskStatus).optional(),
priority: z.nativeEnum(ShotPriority).optional(),
dueDate: z.string().nullable().optional(),
estimatedHours: z.coerce.number().positive().nullable().optional(),
assignedArtistId: z.string().cuid().nullable().optional(),
sortOrder: z.number().int().optional(),
});
export async function updateTask(taskId: string, data: z.infer<typeof updateTaskSchema>) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
const parsed = updateTaskSchema.parse(data);
const task = await db.task.findUnique({
where: { id: taskId },
include: {
shot: { select: { shotCode: true } },
asset: { select: { assetCode: true } },
project: { select: { name: true, slackWebhook: true } },
},
});
if (!task) throw new Error("Task not found");
const updated = await db.task.update({
where: { id: taskId },
data: {
...(parsed.title !== undefined && { title: parsed.title }),
...(parsed.description !== undefined && { description: parsed.description }),
...(parsed.type !== undefined && { type: parsed.type }),
...(parsed.status !== undefined && { status: parsed.status }),
...(parsed.priority !== undefined && { priority: parsed.priority }),
...(parsed.dueDate !== undefined && {
dueDate: parsed.dueDate ? new Date(parsed.dueDate) : null,
}),
...(parsed.estimatedHours !== undefined && { estimatedHours: parsed.estimatedHours }),
...(parsed.assignedArtistId !== undefined && { assignedArtistId: parsed.assignedArtistId }),
...(parsed.sortOrder !== undefined && { sortOrder: parsed.sortOrder }),
},
});
// Notify new assignee when assignee changes
if (
parsed.assignedArtistId !== undefined &&
parsed.assignedArtistId !== task.assignedArtistId &&
parsed.assignedArtistId !== null
) {
const contextCode = task.shot?.shotCode ?? task.asset?.assetCode ?? null;
const assignedByName = session.user.name ?? session.user.email ?? "Someone";
const artist = await db.user.findUnique({
where: { id: parsed.assignedArtistId },
select: { name: true, email: true },
});
await notifyTaskAssigned({
artistId: parsed.assignedArtistId,
taskTitle: task.title,
taskId,
contextCode,
assignedByName,
}).catch(() => {});
if (task.project.slackWebhook) {
const taskUrl = `${process.env.NEXTAUTH_URL ?? ""}/tasks/${taskId}`;
await slackNotifyTaskAssigned(task.project.slackWebhook, {
taskTitle: task.title,
contextCode,
artistName: artist?.name ?? artist?.email ?? "artist",
assignedByName,
projectName: task.project.name,
taskUrl,
}).catch(() => {});
}
}
revalidatePath(`/projects/${task.projectId}`);
revalidatePath(`/tasks/${taskId}`);
return { success: true, task: updated };
}
export async function updateTaskStatus(taskId: string, status: TaskStatus) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
const task = await db.task.findUnique({
where: { id: taskId },
include: {
shot: { select: { id: true, shotCode: true } },
asset: { select: { assetCode: true } },
project: { select: { id: true, name: true, slackWebhook: true } },
},
});
if (!task) throw new Error("Task not found");
const updated = await db.task.update({
where: { id: taskId },
data: { status },
});
const contextCode = task.shot?.shotCode ?? task.asset?.assetCode ?? null;
const taskUrl = `${process.env.NEXTAUTH_URL ?? ""}/tasks/${taskId}`;
const actorName = session.user.name ?? session.user.email ?? "Someone";
// Task moved to internal review — notify supervisors/producers
if (status === "INTERNAL_REVIEW") {
await notifyTaskReadyForReview({
taskId,
taskTitle: task.title,
contextCode,
artistName: actorName,
projectId: task.projectId,
taskUrl,
}).catch(() => {});
if (task.project.slackWebhook) {
await slackNotifyTaskReadyForReview(task.project.slackWebhook, {
taskTitle: task.title,
contextCode,
artistName: actorName,
projectName: task.project.name,
taskUrl,
}).catch(() => {});
}
}
// Task approved (moved to DONE by reviewer) — notify artist
if (status === "DONE" && task.assignedArtistId && task.assignedArtistId !== session.user.id) {
await notifyTaskApproved({
artistId: task.assignedArtistId,
taskTitle: task.title,
taskId,
contextCode,
reviewerName: actorName,
}).catch(() => {});
}
// Changes requested — notify artist
if (status === "CHANGES" && task.assignedArtistId && task.assignedArtistId !== session.user.id) {
await notifyTaskChangesRequested({
artistId: task.assignedArtistId,
taskTitle: task.title,
taskId,
contextCode,
reviewerName: actorName,
}).catch(() => {});
}
// Recalculate shot status from task states
if (task.shot) {
await recalcShotStatus(task.shot.id).catch(() => {});
}
revalidatePath(`/projects/${task.projectId}`);
revalidatePath(`/tasks/${taskId}`);
return { success: true };
}
export async function deleteTask(taskId: 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");
}
const task = await db.task.findUnique({ where: { id: taskId } });
if (!task) throw new Error("Task not found");
const shotId = task.shotId;
await db.task.delete({ where: { id: taskId } });
// Recalculate shot status after task removal
if (shotId) {
await recalcShotStatus(shotId).catch(() => {});
}
revalidatePath(`/projects/${task.projectId}`);
return { success: true };
}
export async function getTaskById(taskId: string) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
return db.task.findUnique({
where: { id: taskId },
include: {
shot: { select: { id: true, shotCode: true, projectId: true } },
asset: { select: { id: true, assetCode: true, name: true, projectId: true } },
assignedArtist: { select: { id: true, name: true, email: true, image: true } },
createdBy: { select: { id: true, name: true, email: true } },
project: { select: { id: true, name: true, code: true } },
versions: {
orderBy: { versionNumber: "desc" },
include: {
artist: { select: { id: true, name: true, image: true } },
_count: { select: { comments: true } },
},
},
_count: { select: { versions: true } },
},
});
}
export async function getProjectTasks(projectId: string) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
return db.task.findMany({
where: { projectId },
orderBy: [{ shotId: "asc" }, { sortOrder: "asc" }],
include: {
shot: { select: { id: true, shotCode: true } },
asset: { select: { id: true, assetCode: true, name: true } },
assignedArtist: { select: { id: true, name: true, email: true, image: true } },
_count: { select: { versions: true } },
},
});
}
export async function getShotTasks(shotId: string) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
return db.task.findMany({
where: { shotId },
orderBy: { sortOrder: "asc" },
include: {
assignedArtist: { select: { id: true, name: true, email: true, image: true } },
_count: { select: { versions: true } },
versions: {
take: 1,
orderBy: { versionNumber: "desc" },
select: { id: true, versionNumber: true, approvalStatus: true, createdAt: true },
},
},
});
}
export async function getAssetTasks(assetId: string) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
return db.task.findMany({
where: { assetId },
orderBy: { sortOrder: "asc" },
include: {
assignedArtist: { select: { id: true, name: true, email: true, image: true } },
_count: { select: { versions: true } },
versions: {
take: 1,
orderBy: { versionNumber: "desc" },
select: { id: true, versionNumber: true, approvalStatus: true, createdAt: true },
},
},
});
}
export async function getMyTasks() {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
return db.task.findMany({
where: { assignedArtistId: session.user.id },
orderBy: [{ status: "asc" }, { dueDate: "asc" }],
include: {
shot: { select: { id: true, shotCode: true } },
asset: { select: { id: true, assetCode: true, name: true } },
project: { select: { id: true, name: true, code: true } },
_count: { select: { versions: true } },
},
});
}
+143
View File
@@ -0,0 +1,143 @@
"use server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { Role } from "@prisma/client";
import bcrypt from "bcryptjs";
const MANAGED_BY_ADMIN_ONLY: Role[] = [Role.ADMIN];
function requireAdmin(role: string) {
if (role !== "ADMIN") throw new Error("Admin access required");
}
// ── Create User ──────────────────────────────────────────────────────────────
const createUserSchema = z.object({
name: z.string().max(100).optional(),
email: z.string().email("Invalid email"),
password: z.string().min(8, "Password must be at least 8 characters"),
role: z.nativeEnum(Role),
});
export async function createUser(data: z.infer<typeof createUserSchema>) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
requireAdmin(session.user.role as string);
const parsed = createUserSchema.parse(data);
const existing = await db.user.findUnique({ where: { email: parsed.email } });
if (existing) throw new Error("A user with that email already exists");
const passwordHash = await bcrypt.hash(parsed.password, 12);
const user = await db.user.create({
data: {
name: parsed.name || null,
email: parsed.email,
passwordHash,
role: parsed.role,
isActive: true,
mustChangePassword: true,
},
});
revalidatePath("/users");
return { success: true, userId: user.id };
}
// ── Update User ──────────────────────────────────────────────────────────────
const updateUserSchema = z.object({
userId: z.string().cuid(),
name: z.string().max(100).optional(),
role: z.nativeEnum(Role),
isActive: z.boolean(),
newPassword: z.string().min(8, "Password must be at least 8 characters").optional().or(z.literal("")),
});
export async function updateUser(data: z.infer<typeof updateUserSchema>) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
requireAdmin(session.user.role as string);
const parsed = updateUserSchema.parse(data);
// Prevent demoting or deactivating yourself
if (parsed.userId === session.user.id) {
if (!parsed.isActive) throw new Error("You cannot deactivate your own account");
if (parsed.role !== "ADMIN") throw new Error("You cannot change your own role");
}
const updateData: Record<string, unknown> = {
name: parsed.name || null,
role: parsed.role,
isActive: parsed.isActive,
};
if (parsed.newPassword) {
updateData.passwordHash = await bcrypt.hash(parsed.newPassword, 12);
updateData.mustChangePassword = true;
}
await db.user.update({ where: { id: parsed.userId }, data: updateData });
revalidatePath("/users");
return { success: true };
}
// ── Change Own Password ───────────────────────────────────────────────────────
const changeOwnPasswordSchema = z.object({
currentPassword: z.string().min(1, "Current password is required"),
newPassword: z.string().min(8, "New password must be at least 8 characters"),
});
export async function changeOwnPassword(data: z.infer<typeof changeOwnPasswordSchema>) {
const session = await auth();
if (!session?.user?.id) throw new Error("Unauthorized");
const parsed = changeOwnPasswordSchema.parse(data);
const user = await db.user.findUnique({
where: { id: session.user.id },
select: { passwordHash: true },
});
if (!user?.passwordHash) throw new Error("No password set on this account");
const isValid = await bcrypt.compare(parsed.currentPassword, user.passwordHash);
if (!isValid) throw new Error("Current password is incorrect");
if (parsed.currentPassword === parsed.newPassword) {
throw new Error("New password must be different from the current password");
}
const passwordHash = await bcrypt.hash(parsed.newPassword, 12);
await db.user.update({
where: { id: session.user.id },
data: { passwordHash, mustChangePassword: false },
});
revalidatePath("/settings");
return { success: true };
}
// ── Delete User ───────────────────────────────────────────────────────────────
export async function deleteUser(userId: string) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
requireAdmin(session.user.role as string);
if (userId === session.user.id) throw new Error("You cannot delete your own account");
await db.user.delete({ where: { id: userId } });
revalidatePath("/users");
return { success: true };
}
+212
View File
@@ -0,0 +1,212 @@
"use server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { recalcShotStatus } from "@/lib/shot-status";
import { notifyNewVersionUploaded } from "@/lib/notifications";
import { slackNotifyVersionUploaded } from "@/lib/slack";
const createVersionSchema = z.object({
taskId: z.string().cuid(),
fileUrl: z.string().min(1),
fileName: z.string(),
fileSize: z.number().optional(),
mimeType: z.string().optional(),
thumbnailUrl: z.string().min(1).optional().or(z.literal("")),
fps: z.number().default(24),
duration: z.number().optional(),
frameCount: z.number().int().optional(),
width: z.number().int().optional(),
height: z.number().int().optional(),
notes: z.string().optional(),
});
export async function createVersion(data: z.infer<typeof createVersionSchema>) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
const parsed = createVersionSchema.parse(data);
// All versions must belong to a task
const task = await db.task.findUnique({
where: { id: parsed.taskId },
include: { project: true, shot: { select: { id: true, shotCode: true } } },
});
if (!task) throw new Error("Task not found");
const projectId = task.projectId;
const slackWebhook = task.project.slackWebhook ?? undefined;
const projectName = task.project.name;
const shotCode = task.shot?.shotCode;
// Mark all existing versions for this task as not latest
await db.version.updateMany({ where: { taskId: parsed.taskId }, data: { isLatest: false } });
// Move task to INTERNAL_REVIEW on upload
await db.task.update({
where: { id: parsed.taskId },
data: { status: "INTERNAL_REVIEW" },
});
// Recalculate shot status
if (task.shot) {
await recalcShotStatus(task.shot.id).catch(() => {});
}
// Get the next version number for this task
const lastVersion = await db.version.findFirst({
where: { taskId: parsed.taskId },
orderBy: { versionNumber: "desc" },
});
const versionNumber = (lastVersion?.versionNumber ?? 0) + 1;
// Create the new version
const version = await db.version.create({
data: {
versionNumber,
taskId: parsed.taskId,
artistId: session.user.id,
fileUrl: parsed.fileUrl,
fileName: parsed.fileName,
fileSize: parsed.fileSize ? BigInt(parsed.fileSize) : undefined,
mimeType: parsed.mimeType,
thumbnailUrl: parsed.thumbnailUrl || undefined,
fps: parsed.fps,
duration: parsed.duration,
frameCount: parsed.frameCount,
width: parsed.width,
height: parsed.height,
notes: parsed.notes,
isLatest: true,
},
});
// Send notifications
const user = await db.user.findUnique({ where: { id: session.user.id } });
const versionLabelStr = `v${String(versionNumber).padStart(3, "0")}`;
if (shotCode) {
await notifyNewVersionUploaded({
shotCode,
versionNumber,
projectId,
versionId: version.id,
artistName: user?.name ?? session.user.email ?? "Artist",
});
}
// Slack notification
if (slackWebhook && shotCode) {
const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000";
await slackNotifyVersionUploaded(slackWebhook, {
shotCode,
versionLabel: versionLabelStr,
artistName: user?.name ?? "Artist",
projectName,
reviewUrl: `${appUrl}/review/${version.id}`,
});
}
revalidatePath(`/projects/${projectId}`);
if (task.shot) revalidatePath(`/projects/${projectId}/shots/${task.shot.id}`);
revalidatePath(`/tasks/${parsed.taskId}`);
return { success: true, version, versionNumber };
}
/**
* Share a version with the client — marks it as client-visible,
* records who shared it, and moves the associated task to CLIENT_REVIEW.
*/
export async function shareVersionWithClient(versionId: 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");
}
const version = await db.version.findUnique({
where: { id: versionId },
select: { id: true, taskId: true, isClientVisible: true },
});
if (!version) throw new Error("Version not found");
await db.version.update({
where: { id: versionId },
data: {
isClientVisible: true,
sharedAt: new Date(),
sharedById: session.user.id,
},
});
// Move task to CLIENT_REVIEW if it is in INTERNAL_REVIEW; recalc shot status
if (version.taskId) {
const task = await db.task.findUnique({
where: { id: version.taskId },
select: { status: true, projectId: true, shotId: true },
});
if (task && task.status === "INTERNAL_REVIEW") {
await db.task.update({
where: { id: version.taskId },
data: { status: "CLIENT_REVIEW" },
});
if (task.shotId) {
await recalcShotStatus(task.shotId).catch(() => {});
}
revalidatePath(`/tasks/${version.taskId}`);
revalidatePath(`/projects/${task.projectId}`);
}
}
revalidatePath(`/review/${versionId}`);
return { success: true };
}
export async function getVersionById(versionId: string) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
return db.version.findUnique({
where: { id: versionId },
include: {
task: {
include: {
project: { include: { client: true } },
shot: true,
asset: true,
versions: {
orderBy: { versionNumber: "desc" },
select: { id: true, versionNumber: true, approvalStatus: true, createdAt: true },
},
},
},
artist: { select: { id: true, name: true, email: true, image: true } },
approvals: {
include: { user: { select: { id: true, name: true, role: true } } },
orderBy: { createdAt: "desc" },
},
},
});
}
export async function getVersionComments(versionId: string) {
return db.comment.findMany({
where: { versionId },
include: {
author: { select: { id: true, name: true, email: true, image: true, role: true } },
replies: {
include: {
author: { select: { id: true, name: true, email: true, image: true, role: true } },
},
orderBy: { createdAt: "asc" },
},
annotations: {
select: { id: true, frameNumber: true, drawingData: true, color: true, isVisible: true, authorId: true },
},
},
orderBy: { frameNumber: "asc" },
});
}