Files
twotalesanimation 41cf79abbe fix(notifications): tell staff about every new comment
Only the shot's assigned artist was notified of player comments, and
client-link comments and pasted WhatsApp feedback notified no one.
notifyCommentsAdded now notifies all active staff plus the shot/task
artist, never the author, from all three paths. A pasted round becomes
one notification per version.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 09:06:26 +02:00

278 lines
9.4 KiB
TypeScript

"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<T extends BoardShot>(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<FeedbackPreview> {
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<string, (typeof shots)[number] | null>();
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<string, typeof versions>();
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<typeof commitSchema>) {
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<string>();
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 };
}