"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) { 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) { 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 } }, }, }); }