41cf79abbe
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>
324 lines
9.0 KiB
TypeScript
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);
|
|
}
|