Initial commit
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
@@ -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" },
|
||||
});
|
||||
}
|
||||
@@ -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 },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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, "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(),
|
||||
});
|
||||
|
||||
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" },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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" }],
|
||||
});
|
||||
}
|
||||
@@ -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 } },
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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" },
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user