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>
This commit is contained in:
twotalesanimation
2026-10-17 09:06:26 +02:00
parent 7a7088f2ad
commit 41cf79abbe
5 changed files with 223 additions and 38 deletions
+13 -11
View File
@@ -4,7 +4,7 @@ import { auth } from "@/auth";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { z } from "zod"; import { z } from "zod";
import { notifyFeedbackAdded, notifyCommentReply } from "@/lib/notifications"; import { notifyCommentsAdded, notifyCommentReply } from "@/lib/notifications";
import { slackNotifyNewFeedback } from "@/lib/slack"; import { slackNotifyNewFeedback } from "@/lib/slack";
import { createDisplayEvent } from "@/lib/display-events"; import { createDisplayEvent } from "@/lib/display-events";
@@ -36,7 +36,6 @@ export async function addComment(data: z.infer<typeof addCommentSchema>) {
}, },
}); });
// Notify the shot artist if commenter is not the artist
const version = await db.version.findUnique({ const version = await db.version.findUnique({
where: { id: parsed.versionId }, where: { id: parsed.versionId },
include: { include: {
@@ -56,16 +55,19 @@ export async function addComment(data: z.infer<typeof addCommentSchema>) {
version?.task?.title ?? version?.task?.title ??
"Task"; "Task";
if (version?.shot?.artistId && version.shot.artistId !== session.user.id) { // Staff and the shot's artist — never the commenter. A failed notification
const user = await db.user.findUnique({ where: { id: session.user.id } }); // must not fail a comment that is already saved.
await notifyFeedbackAdded({ const user = await db.user.findUnique({ where: { id: session.user.id }, select: { name: true } });
artistId: version.shot.artistId, await notifyCommentsAdded({
shotCode: version.shot.shotCode, notes: [{
frameNumber: parsed.frameNumber,
versionId: parsed.versionId, versionId: parsed.versionId,
authorName: user?.name ?? session.user.email ?? "Someone", shotCode,
}); frameNumber: parsed.frameNumber,
} artistIds: [version?.shot?.artistId, version?.task?.assignedArtistId],
}],
authorId: session.user.id,
authorName: user?.name ?? session.user.email ?? "Someone",
}).catch((err) => console.error("[comments] notify failed", err));
// Slack notification // Slack notification
if (slackWebhook) { if (slackWebhook) {
+26 -2
View File
@@ -7,6 +7,7 @@ import { z } from "zod";
import { findShot, suggestShot, type BoardShot } from "@/lib/board-parse"; import { findShot, suggestShot, type BoardShot } from "@/lib/board-parse";
import { parseFeedback, slateFrames, toPlayerFrame } from "@/lib/feedback-parse"; import { parseFeedback, slateFrames, toPlayerFrame } from "@/lib/feedback-parse";
import { updateTaskStatus } from "@/actions/tasks"; import { updateTaskStatus } from "@/actions/tasks";
import { notifyCommentsAdded, type NewCommentNote } from "@/lib/notifications";
/** Comment.source for notes pasted from a client message */ /** Comment.source for notes pasted from a client message */
const SOURCE = "WHATSAPP"; const SOURCE = "WHATSAPP";
@@ -176,7 +177,13 @@ export async function commitFeedback(data: z.infer<typeof commitSchema>) {
const versionIds = [...new Set(notes.map((n) => n.versionId).filter(Boolean) as string[])]; const versionIds = [...new Set(notes.map((n) => n.versionId).filter(Boolean) as string[])];
const versions = await db.version.findMany({ const versions = await db.version.findMany({
where: { id: { in: versionIds } }, where: { id: { in: versionIds } },
select: { id: true, fps: true, shotId: true, task: { select: { id: true, shotId: true, status: true, projectId: true } }, shot: { select: { projectId: true } } }, 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])); const vById = new Map(versions.map((v) => [v.id, v]));
for (const n of notes) { for (const n of notes) {
@@ -195,6 +202,7 @@ export async function commitFeedback(data: z.infer<typeof commitSchema>) {
let sort = (last?.sortOrder ?? -1) + 1; let sort = (last?.sortOrder ?? -1) + 1;
let comments = 0; let comments = 0;
const saved: NewCommentNote[] = [];
await db.$transaction(async (tx) => { await db.$transaction(async (tx) => {
for (const r of parsed.rounds) { for (const r of parsed.rounds) {
const round = await tx.feedbackRound.create({ const round = await tx.feedbackRound.create({
@@ -221,7 +229,15 @@ export async function commitFeedback(data: z.infer<typeof commitSchema>) {
select: { id: true }, select: { id: true },
}) })
: null; : null;
if (comment) comments++; 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({ await tx.boardItem.create({
data: { data: {
projectId: parsed.projectId, projectId: parsed.projectId,
@@ -247,6 +263,14 @@ export async function commitFeedback(data: z.infer<typeof commitSchema>) {
} }
for (const taskId of tasks) await updateTaskStatus(taskId, "CHANGES"); 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}`); for (const id of versionIds) revalidatePath(`/review/${id}`);
revalidatePath("/board"); revalidatePath("/board");
return { success: true, notes: notes.length, comments, tasksChanged: tasks.size }; return { success: true, notes: notes.length, comments, tasksChanged: tasks.size };
+15 -2
View File
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { slackNotifyNewFeedback } from "@/lib/slack"; import { slackNotifyNewFeedback } from "@/lib/slack";
import { notifyCommentsAdded } from "@/lib/notifications";
import { validateReviewToken, getOrCreateReviewUser } from "@/lib/review-auth"; import { validateReviewToken, getOrCreateReviewUser } from "@/lib/review-auth";
export async function POST( export async function POST(
@@ -28,8 +29,8 @@ export async function POST(
const version = await db.version.findUnique({ const version = await db.version.findUnique({
where: { id: versionId }, where: { id: versionId },
include: { include: {
shot: { select: { projectId: true, shotCode: true, project: { select: { slackWebhook: true } } } }, shot: { select: { projectId: true, shotCode: true, artistId: true, project: { select: { slackWebhook: true } } } },
task: { select: { projectId: true, title: true, project: { select: { slackWebhook: true } }, shot: { select: { shotCode: true } } } }, task: { select: { projectId: true, title: true, assignedArtistId: true, project: { select: { slackWebhook: true } }, shot: { select: { shotCode: true } } } },
}, },
}); });
const projectId = version?.shot?.projectId ?? version?.task?.projectId; const projectId = version?.shot?.projectId ?? version?.task?.projectId;
@@ -70,6 +71,18 @@ export async function POST(
}); });
} }
await notifyCommentsAdded({
notes: [{
versionId,
shotCode,
frameNumber,
artistIds: [version.shot?.artistId, version.task?.assignedArtistId],
}],
authorId: user.id,
authorName: user.name ?? user.email,
origin: "client",
}).catch((err) => console.error("[client comment] notify failed", err));
// canEdit mirrors the GET shape — a note you just wrote is always your own. // canEdit mirrors the GET shape — a note you just wrote is always your own.
return NextResponse.json({ comment: { ...comment, canEdit: true } }, { status: 201 }); return NextResponse.json({ comment: { ...comment, canEdit: true } }, { status: 201 });
} }
+75
View File
@@ -0,0 +1,75 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("@/lib/db", () => ({ db: {} }));
import { buildCommentNotifications } from "./notifications";
const note = (versionId: string, shotCode: string, frameNumber: number, artistIds: (string | null)[] = []) => ({
versionId,
shotCode,
frameNumber,
artistIds,
});
describe("buildCommentNotifications", () => {
it("tells every staff member and the artist, never the author", () => {
const out = buildCommentNotifications({
notes: [note("v1", "DH2_010", 1012, ["artist"])],
authorId: "sup",
authorName: "Sam",
staffIds: ["sup", "prod", "admin"],
});
expect(out.map((p) => p.userId).sort()).toEqual(["admin", "artist", "prod"]);
expect(out[0]).toMatchObject({
type: "FEEDBACK_ADDED",
title: "New feedback",
message: "Sam commented on DH2_010 at frame 1012",
data: { versionId: "v1", shotCode: "DH2_010", frameNumber: 1012 },
});
});
it("does not notify an artist twice when they are also staff or on both shot and task", () => {
const out = buildCommentNotifications({
notes: [note("v1", "A", 1, ["prod", "prod", null])],
authorId: "x",
authorName: "X",
staffIds: ["prod"],
});
expect(out.map((p) => p.userId)).toEqual(["prod"]);
});
it("labels client comments", () => {
const [p] = buildCommentNotifications({
notes: [note("v1", "A", 5)],
authorId: "client",
authorName: "Netflix",
origin: "client",
staffIds: ["prod"],
});
expect(p.title).toBe("New client feedback");
expect(p.message).toBe("Client Netflix commented on A at frame 5");
});
it("groups a pasted round into one notification per version", () => {
const out = buildCommentNotifications({
notes: [note("v1", "A", 1, ["art1"]), note("v1", "A", 9), note("v2", "B", 3, ["art2"])],
authorId: "prod",
authorName: "Pat",
origin: "whatsapp",
staffIds: ["prod", "sup"],
});
const msgs = out.map((p) => `${p.userId}: ${p.message}`).sort();
expect(msgs).toEqual([
"art1: Client feedback (logged by Pat) left 2 notes on A",
"art2: Client feedback (logged by Pat) commented on B at frame 3",
"sup: Client feedback (logged by Pat) commented on B at frame 3",
"sup: Client feedback (logged by Pat) left 2 notes on A",
]);
});
it("sends nothing for no notes", () => {
expect(
buildCommentNotifications({ notes: [], authorId: "a", authorName: "A", staffIds: ["b"] })
).toEqual([]);
});
});
+94 -23
View File
@@ -78,29 +78,6 @@ export async function notifyNewVersionUploaded(params: {
); );
} }
/**
* Notify the shot's artist about new feedback.
*/
export async function notifyFeedbackAdded(params: {
artistId: string;
shotCode: string;
frameNumber: number;
versionId: string;
authorName: string;
}) {
await createNotification({
userId: params.artistId,
type: NotificationType.FEEDBACK_ADDED,
title: "New feedback on your shot",
message: `${params.authorName} commented on ${params.shotCode} at frame ${params.frameNumber}`,
data: {
versionId: params.versionId,
shotCode: params.shotCode,
frameNumber: params.frameNumber,
},
});
}
/** /**
* Notify relevant users of an approval status change. * Notify relevant users of an approval status change.
*/ */
@@ -250,3 +227,97 @@ export async function notifyCommentReply(params: {
}, },
}); });
} }
/** One new comment, as the recipients of its notification need it. */
export interface NewCommentNote {
versionId: string;
shotCode: string;
frameNumber: number;
/** The shot's artist and, for a task version, the task's artist. */
artistIds: (string | null | undefined)[];
}
/**
* Who hears about new comments, and what they read. Pure so it can be tested:
* every active staff member plus each note's artist, never the author.
* Notes are grouped per version so a pasted round of client feedback is one
* notification per shot rather than one per line.
*/
export function buildCommentNotifications(params: {
notes: NewCommentNote[];
authorId: string;
authorName: string;
/** "client" = typed on a client review link; "whatsapp" = pasted by staff. */
origin?: "client" | "whatsapp";
staffIds: string[];
}): CreateNotificationPayload[] {
const byVersion = new Map<string, NewCommentNote[]>();
for (const n of params.notes) {
byVersion.set(n.versionId, [...(byVersion.get(n.versionId) ?? []), n]);
}
const who =
params.origin === "client"
? `Client ${params.authorName}`
: params.origin === "whatsapp"
? `Client feedback (logged by ${params.authorName})`
: params.authorName;
const title = params.origin ? "New client feedback" : "New feedback";
const payloads: CreateNotificationPayload[] = [];
for (const [versionId, notes] of byVersion) {
const { shotCode, frameNumber } = notes[0];
const message =
notes.length === 1
? `${who} commented on ${shotCode} at frame ${frameNumber}`
: `${who} left ${notes.length} notes on ${shotCode}`;
const recipients = new Set([
...params.staffIds,
...notes.flatMap((n) => n.artistIds).filter((id): id is string => !!id),
]);
recipients.delete(params.authorId);
for (const userId of recipients) {
payloads.push({
userId,
type: NotificationType.FEEDBACK_ADDED,
title,
message,
data: { versionId, shotCode, frameNumber },
});
}
}
return payloads;
}
/**
* Notify staff and the shot's artist about new comments. Artists who are not
* staff are only told when they are still active.
*/
export async function notifyCommentsAdded(params: {
notes: NewCommentNote[];
authorId: string;
authorName: string;
origin?: "client" | "whatsapp";
}) {
if (params.notes.length === 0) return;
const artistIds = [
...new Set(params.notes.flatMap((n) => n.artistIds).filter((id): id is string => !!id)),
];
const [staff, activeArtists] = await Promise.all([
db.user.findMany({
where: { role: { in: ["ADMIN", "PRODUCER", "SUPERVISOR"] }, isActive: true },
select: { id: true },
}),
db.user.findMany({
where: { id: { in: artistIds }, isActive: true, role: { not: "CLIENT" } },
select: { id: true },
}),
]);
const active = new Set(activeArtists.map((u) => u.id));
const payloads = buildCommentNotifications({
...params,
notes: params.notes.map((n) => ({ ...n, artistIds: n.artistIds.filter((id) => id && active.has(id)) })),
staffIds: staff.map((u) => u.id),
});
if (payloads.length) await createNotifications(payloads);
}