Files
vfxreview/lib/comment-reads.ts
T
twotalesanimation 7a7088f2ad feat(shot-status): flag shots with new comments until marked read
A per-user ShotCommentRead marker records how far each person has read a
shot's comments. Comments and replies from others after the marker put an
orange "!" beside the shot code; its popover lists them and only "Mark
read" clears it. A chip filters the board to flagged shots and marks all
read. The migration marks existing comments as read so the board starts
clean.

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

147 lines
5.2 KiB
TypeScript

import { db } from "@/lib/db";
import { Prisma } from "@prisma/client";
/**
* New-comment tracking for the shot-status board. A comment or reply is new to
* a user when someone else wrote it on one of the shot's versions (directly or
* through a task on the shot) after the user's ShotCommentRead marker. With no
* marker the baseline is when the account was created, so a new user does not
* inherit the whole back catalogue.
*
* Comments with no author (the author's account was deleted) count as someone
* else's. Pasted WhatsApp notes are authored by whoever pasted them, so they
* are new to everyone but that person.
*/
export type UnreadCommentSummary = {
count: number;
/** createdAt of the newest unread item — what "mark read" reads up to. */
latestAt: string;
};
export async function getUnreadCommentSummaries(
userId: string,
projectId: string
): Promise<Record<string, UnreadCommentSummary>> {
const rows = await db.$queryRaw<{ shotId: string; count: number; latestAt: Date }[]>(Prisma.sql`
WITH me AS (
SELECT "createdAt" FROM "users" WHERE "id" = ${userId}
),
items AS (
SELECT COALESCE(v."shotId", t."shotId") AS "shotId", c."createdAt", c."authorId"
FROM "comments" c
JOIN "versions" v ON v."id" = c."versionId"
LEFT JOIN "tasks" t ON t."id" = v."taskId"
UNION ALL
SELECT COALESCE(v."shotId", t."shotId") AS "shotId", r."createdAt", r."authorId"
FROM "comment_replies" r
JOIN "comments" c ON c."id" = r."commentId"
JOIN "versions" v ON v."id" = c."versionId"
LEFT JOIN "tasks" t ON t."id" = v."taskId"
)
SELECT i."shotId", COUNT(*)::int AS "count", MAX(i."createdAt") AS "latestAt"
FROM items i
JOIN "shots" s ON s."id" = i."shotId"
LEFT JOIN "shot_comment_reads" scr ON scr."shotId" = s."id" AND scr."userId" = ${userId}
WHERE s."projectId" = ${projectId}
AND i."authorId" IS DISTINCT FROM ${userId}
AND i."createdAt" > COALESCE(scr."readAt", (SELECT "createdAt" FROM me))
GROUP BY i."shotId"
`);
return Object.fromEntries(
rows.map((r) => [r.shotId, { count: r.count, latestAt: r.latestAt.toISOString() }])
);
}
export type UnreadCommentItem = {
id: string;
kind: "comment" | "reply";
text: string;
authorName: string;
source: string | null;
versionId: string;
versionNumber: number;
frameNumber: number;
createdAt: string;
};
/** The unread comments and replies on one shot, newest first. */
export async function getUnreadCommentsForShot(
userId: string,
shotId: string
): Promise<UnreadCommentItem[]> {
const [user, marker] = await Promise.all([
db.user.findUnique({ where: { id: userId }, select: { createdAt: true } }),
db.shotCommentRead.findUnique({ where: { userId_shotId: { userId, shotId } } }),
]);
const since = marker?.readAt ?? user?.createdAt ?? new Date(0);
const onShot: Prisma.VersionWhereInput = { OR: [{ shotId }, { task: { shotId } }] };
// `not: userId` alone would also drop authorless rows (SQL NULL), which count.
const byOthers = { OR: [{ authorId: null }, { authorId: { not: userId } }] };
const author = { select: { name: true, email: true } };
const version = { select: { id: true, versionNumber: true } };
const [comments, replies] = await Promise.all([
db.comment.findMany({
where: { version: onShot, createdAt: { gt: since }, ...byOthers },
select: { id: true, text: true, source: true, frameNumber: true, createdAt: true, author, version },
}),
db.commentReply.findMany({
where: { comment: { version: onShot }, createdAt: { gt: since }, ...byOthers },
select: {
id: true,
text: true,
createdAt: true,
author,
comment: { select: { frameNumber: true, version } },
},
}),
]);
const items: UnreadCommentItem[] = [
...comments.map((c) => ({
id: c.id,
kind: "comment" as const,
text: c.text,
authorName: c.author?.name ?? c.author?.email ?? "Unknown",
source: c.source,
versionId: c.version.id,
versionNumber: c.version.versionNumber,
frameNumber: c.frameNumber,
createdAt: c.createdAt.toISOString(),
})),
...replies.map((r) => ({
id: r.id,
kind: "reply" as const,
text: r.text,
authorName: r.author?.name ?? r.author?.email ?? "Unknown",
source: null,
versionId: r.comment.version.id,
versionNumber: r.comment.version.versionNumber,
frameNumber: r.comment.frameNumber,
createdAt: r.createdAt.toISOString(),
})),
];
return items.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
}
/**
* Move a user's marker forward to `upTo` — the newest item they were shown,
* not "now", so a comment that lands while the list is open stays unread.
* Never moves a marker backwards.
*/
export async function markShotCommentsRead(userId: string, shotId: string, upTo: Date) {
const readAt = upTo > new Date() ? new Date() : upTo;
const existing = await db.shotCommentRead.findUnique({
where: { userId_shotId: { userId, shotId } },
});
if (existing && existing.readAt >= readAt) return;
await db.shotCommentRead.upsert({
where: { userId_shotId: { userId, shotId } },
create: { userId, shotId, readAt },
update: { readAt },
});
}