"use server"; import { auth } from "@/auth"; import { db } from "@/lib/db"; import { revalidatePath } from "next/cache"; import { z } from "zod"; import { findShot, suggestShot, type BoardShot } from "@/lib/board-parse"; import { parseFeedback, slateFrames, toPlayerFrame } from "@/lib/feedback-parse"; import { updateTaskStatus } from "@/actions/tasks"; import { notifyCommentsAdded, type NewCommentNote } from "@/lib/notifications"; /** Comment.source for notes pasted from a client message */ const SOURCE = "WHATSAPP"; async function requireStaff() { const session = await auth(); if (!session?.user) throw new Error("Unauthorized"); if (session.user.role === "CLIENT") throw new Error("Insufficient permissions"); return session.user; } /** * The shot a pasted code means: exact code or client alias first, then a code * the client shortened by dropping the show prefix ("113_018_040" for * "UNG_113_018_040") when exactly one shot ends with it. */ function resolveShot(code: string, shots: T[]): T | null { const exact = findShot(code, shots) as T | null; if (exact) return exact; const tail = `_${code.toUpperCase()}`; const hits = shots.filter((s) => s.shotCode.toUpperCase().endsWith(tail)); return hits.length === 1 ? hits[0] : null; } export interface FeedbackVersionOption { id: string; label: string; sent: boolean; sharedAt: string | null; taskId: string | null; taskStatus: string | null; /** The note's quoted frame converted for this version's media; null = no frame quoted */ playerFrame: number | null; fromComp: boolean; } export interface FeedbackPreviewNote { key: string; code: string; text: string; quotedFrame: number | null; shot: { id: string; shotCode: string } | null; suggestion: string | null; versions: FeedbackVersionOption[]; defaultVersionId: string | null; } export interface FeedbackPreview { rounds: { title: string; dueText: string | null; notes: FeedbackPreviewNote[] }[]; ignored: string[]; } export async function previewFeedback(projectId: string, input: string): Promise { await requireStaff(); z.string().cuid().parse(projectId); const parsed = parseFeedback(z.string().max(50000).parse(input)); const shots = await db.shot.findMany({ where: { projectId }, select: { id: true, shotCode: true, clientCode: true, frameStart: true }, }); const matched = new Map(); for (const r of parsed.rounds) for (const n of r.notes) { const code = n.codes[0]; if (!matched.has(code)) matched.set(code, resolveShot(code, shots)); } const shotIds = [...new Set([...matched.values()].filter(Boolean).map((s) => s!.id))]; // Recent versions per shot, whether hung off the shot or its task const versions = shotIds.length ? await db.version.findMany({ where: { OR: [{ shotId: { in: shotIds } }, { task: { shotId: { in: shotIds } } }] }, orderBy: { createdAt: "desc" }, select: { id: true, versionNumber: true, shotId: true, isClientVisible: true, sharedAt: true, frameCount: true, pictureStartFrame: true, task: { select: { id: true, status: true, shotId: true } }, }, }) : []; const byShot = new Map(); for (const v of versions) { const sid = v.shotId ?? v.task?.shotId; if (!sid) continue; const list = byShot.get(sid) ?? []; if (list.length < 6) list.push(v); byShot.set(sid, list); } let k = 0; return { ignored: parsed.ignored, rounds: parsed.rounds.map((r) => ({ title: r.title, dueText: r.dueText, notes: r.notes.map((n) => { const code = n.codes[0]; const shot = matched.get(code) ?? null; const options: FeedbackVersionOption[] = (shot ? byShot.get(shot.id) ?? [] : []).map((v) => { const conv = n.frame != null ? toPlayerFrame(n.frame, { shotFrameStart: shot!.frameStart, pictureStart: slateFrames(v), frameCount: v.frameCount, }) : null; return { id: v.id, label: `v${String(v.versionNumber).padStart(3, "0")}`, sent: v.isClientVisible, sharedAt: v.sharedAt?.toISOString() ?? null, taskId: v.task?.id ?? null, taskStatus: v.task?.status ?? null, playerFrame: conv?.frame ?? null, fromComp: conv?.fromComp ?? false, }; }); // The client is commenting on what they were sent const def = options.find((o) => o.sent) ?? options[0] ?? null; return { key: `n${k++}`, code: shot?.shotCode ?? code, text: n.text, quotedFrame: n.frame, shot: shot ? { id: shot.id, shotCode: shot.shotCode } : null, suggestion: shot ? null : suggestShot(code, shots)?.shotCode ?? null, versions: options, defaultVersionId: def?.id ?? null, }; }), })), }; } const commitSchema = z.object({ projectId: z.string().cuid(), rounds: z.array(z.object({ title: z.string().max(200), dueAt: z.string().datetime().nullable(), notes: z.array(z.object({ code: z.string().min(1).max(100), shotId: z.string().cuid().nullable(), text: z.string().min(1).max(5000), versionId: z.string().cuid().nullable(), frame: z.number().int().min(0).nullable(), setChanges: z.boolean(), })).min(1), })).min(1), }); /** * Saves pasted client feedback: one FeedbackRound per round, each note as a * player comment (tagged as client feedback) and a board line, and — where * ticked — the version's task moved to Changes. */ export async function commitFeedback(data: z.infer) { const user = await requireStaff(); const parsed = commitSchema.parse(data); const notes = parsed.rounds.flatMap((r) => r.notes); const versionIds = [...new Set(notes.map((n) => n.versionId).filter(Boolean) as string[])]; const versions = await db.version.findMany({ where: { id: { in: versionIds } }, select: { id: true, fps: true, shotId: true, task: { select: { id: true, shotId: true, status: true, projectId: true, assignedArtistId: true, shot: { select: { shotCode: true } } } }, shot: { select: { projectId: true, shotCode: true, artistId: true } }, }, }); const vById = new Map(versions.map((v) => [v.id, v])); for (const n of notes) { if (!n.versionId) continue; const v = vById.get(n.versionId); const projectOk = v && (v.shot?.projectId ?? v.task?.projectId) === parsed.projectId; const shotOk = v && (v.shotId ?? v.task?.shotId) === n.shotId; if (!projectOk || !shotOk) throw new Error(`Version does not belong to ${n.code}`); } const last = await db.boardItem.findFirst({ where: { projectId: parsed.projectId }, orderBy: { sortOrder: "desc" }, select: { sortOrder: true }, }); let sort = (last?.sortOrder ?? -1) + 1; let comments = 0; const saved: NewCommentNote[] = []; await db.$transaction(async (tx) => { for (const r of parsed.rounds) { const round = await tx.feedbackRound.create({ data: { projectId: parsed.projectId, title: r.title.trim() || "Client feedback", dueAt: r.dueAt ? new Date(r.dueAt) : null, createdById: user.id, }, }); for (const n of r.notes) { const v = n.versionId ? vById.get(n.versionId)! : null; const frame = n.frame ?? 0; const comment = v ? await tx.comment.create({ data: { versionId: v.id, authorId: user.id, frameNumber: frame, timestamp: frame / v.fps, text: n.text.trim(), source: SOURCE, }, select: { id: true }, }) : null; if (comment && v) { comments++; saved.push({ versionId: v.id, shotCode: v.shot?.shotCode ?? v.task?.shot?.shotCode ?? n.code.toUpperCase(), frameNumber: frame, artistIds: [v.shot?.artistId, v.task?.assignedArtistId], }); } await tx.boardItem.create({ data: { projectId: parsed.projectId, shotId: n.shotId, code: n.code.toUpperCase(), text: n.text.trim(), roundId: round.id, commentId: comment?.id ?? null, sortOrder: sort++, createdById: user.id, }, }); } } }); // Outside the transaction: updateTaskStatus notifies the artist and // recalculates shot status, exactly as moving the task by hand does. const tasks = new Set(); for (const n of notes) { const task = n.versionId ? vById.get(n.versionId)?.task : null; if (n.setChanges && task && task.status !== "CHANGES") tasks.add(task.id); } for (const taskId of tasks) await updateTaskStatus(taskId, "CHANGES"); // Only after the transaction commits — a rolled-back paste notifies no one. await notifyCommentsAdded({ notes: saved, authorId: user.id, authorName: user.name ?? user.email ?? "Someone", origin: "whatsapp", }).catch((err) => console.error("[feedback] notify failed", err)); for (const id of versionIds) revalidatePath(`/review/${id}`); revalidatePath("/board"); return { success: true, notes: notes.length, comments, tasksChanged: tasks.size }; }