Files
vfxreview/lib/notifications.ts
T
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

324 lines
9.0 KiB
TypeScript

import { db } from "@/lib/db";
import { NotificationType } from "@prisma/client";
export interface CreateNotificationPayload {
userId: string;
type: NotificationType;
title: string;
message: string;
data?: Record<string, unknown>;
}
/**
* Create a single in-app notification.
*/
export async function createNotification(
payload: CreateNotificationPayload
) {
return db.notification.create({
data: {
userId: payload.userId,
type: payload.type,
title: payload.title,
message: payload.message,
data: (payload.data ?? {}) as any,
},
});
}
/**
* Create notifications for multiple users at once.
*/
export async function createNotifications(
payloads: CreateNotificationPayload[]
) {
return db.notification.createMany({
data: payloads.map((p) => ({
userId: p.userId,
type: p.type,
title: p.title,
message: p.message,
data: (p.data ?? {}) as any,
})),
});
}
/**
* Notify all ADMIN, PRODUCER, and SUPERVISOR users about a new version upload.
*/
export async function notifyNewVersionUploaded(params: {
shotCode: string;
versionNumber: number;
projectId: string;
versionId: string;
artistName: string;
}) {
const staff = await db.user.findMany({
where: {
role: { in: ["ADMIN", "PRODUCER", "SUPERVISOR"] },
isActive: true,
},
select: { id: true },
});
const label = `v${String(params.versionNumber).padStart(3, "0")}`;
await createNotifications(
staff.map((u) => ({
userId: u.id,
type: NotificationType.VERSION_UPLOADED,
title: "New version uploaded",
message: `${params.shotCode} ${label} uploaded by ${params.artistName}`,
data: {
versionId: params.versionId,
shotCode: params.shotCode,
versionNumber: params.versionNumber,
},
}))
);
}
/**
* Notify relevant users of an approval status change.
*/
export async function notifyApprovalChange(params: {
artistId: string | null;
shotCode: string;
versionId: string;
status: "APPROVED" | "REJECTED" | "NEEDS_CHANGES";
reviewerName: string;
}) {
if (!params.artistId) return;
const typeMap = {
APPROVED: NotificationType.SHOT_APPROVED,
REJECTED: NotificationType.SHOT_REJECTED,
NEEDS_CHANGES: NotificationType.REVISION_REQUESTED,
} as const;
const msgMap = {
APPROVED: `${params.shotCode} was approved by ${params.reviewerName}`,
REJECTED: `${params.shotCode} was rejected by ${params.reviewerName}`,
NEEDS_CHANGES: `${params.shotCode} needs changes — ${params.reviewerName}`,
} as const;
await createNotification({
userId: params.artistId,
type: typeMap[params.status],
title: msgMap[params.status],
message: msgMap[params.status],
data: { versionId: params.versionId, shotCode: params.shotCode },
});
}
/**
* Notify an artist that a task has been assigned to them.
*/
export async function notifyTaskAssigned(params: {
artistId: string;
taskTitle: string;
taskId: string;
contextCode: string | null;
assignedByName: string;
}) {
await createNotification({
userId: params.artistId,
type: NotificationType.TASK_ASSIGNED,
title: "New task assigned to you",
message: `${params.assignedByName} assigned you: ${params.taskTitle}${
params.contextCode ? ` (${params.contextCode})` : ""
}`,
data: { taskId: params.taskId },
});
}
/**
* Notify supervisors/producers that a task is ready for internal review.
*/
export async function notifyTaskReadyForReview(params: {
taskId: string;
taskTitle: string;
contextCode: string | null;
artistName: string;
projectId: string;
taskUrl: string;
}) {
const staff = await db.user.findMany({
where: {
role: { in: ["ADMIN", "PRODUCER", "SUPERVISOR"] },
isActive: true,
},
select: { id: true },
});
const label = params.contextCode ? `${params.contextCode} — ` : "";
await createNotifications(
staff.map((u) => ({
userId: u.id,
type: NotificationType.TASK_READY_FOR_REVIEW,
title: "Task ready for review",
message: `${label}${params.taskTitle} marked ready by ${params.artistName}`,
data: { taskId: params.taskId, taskUrl: params.taskUrl },
}))
);
}
/**
* Notify an artist that a task has been approved.
*/
export async function notifyTaskApproved(params: {
artistId: string;
taskTitle: string;
taskId: string;
contextCode: string | null;
reviewerName: string;
}) {
await createNotification({
userId: params.artistId,
type: NotificationType.TASK_APPROVED,
title: "Task approved",
message: `${params.reviewerName} approved: ${params.taskTitle}${
params.contextCode ? ` (${params.contextCode})` : ""
}`,
data: { taskId: params.taskId },
});
}
/**
* Notify an artist that changes were requested on their task.
*/
export async function notifyTaskChangesRequested(params: {
artistId: string;
taskTitle: string;
taskId: string;
contextCode: string | null;
reviewerName: string;
}) {
await createNotification({
userId: params.artistId,
type: NotificationType.TASK_CHANGES_REQUESTED,
title: "Changes requested on task",
message: `${params.reviewerName} requested changes: ${params.taskTitle}${
params.contextCode ? ` (${params.contextCode})` : ""
}`,
data: { taskId: params.taskId },
});
}
/**
* Notify a comment author about a new reply.
*/
export async function notifyCommentReply(params: {
commentAuthorId: string;
replierName: string;
shotCode: string;
frameNumber: number;
versionId: string;
}) {
await createNotification({
userId: params.commentAuthorId,
type: NotificationType.COMMENT_REPLY,
title: "New reply to your comment",
message: `${params.replierName} replied on ${params.shotCode} frame ${params.frameNumber}`,
data: {
versionId: params.versionId,
shotCode: params.shotCode,
frameNumber: params.frameNumber,
},
});
}
/** 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);
}