395 lines
13 KiB
TypeScript
395 lines
13 KiB
TypeScript
"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 } },
|
|
},
|
|
});
|
|
}
|