7a7088f2ad
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>
147 lines
5.2 KiB
TypeScript
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 },
|
|
});
|
|
}
|