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>
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
"use server";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
getUnreadCommentsForShot,
|
||||
getUnreadCommentSummaries,
|
||||
markShotCommentsRead as markRead,
|
||||
} from "@/lib/comment-reads";
|
||||
|
||||
export async function listUnreadShotComments(shotId: string) {
|
||||
const session = await auth();
|
||||
if (!session?.user) throw new Error("Unauthorized");
|
||||
return getUnreadCommentsForShot(session.user.id, z.string().min(1).parse(shotId));
|
||||
}
|
||||
|
||||
const markSchema = z.object({
|
||||
shotId: z.string().min(1),
|
||||
upTo: z.string().datetime(),
|
||||
});
|
||||
|
||||
export async function markShotCommentsRead(shotId: string, upTo: string) {
|
||||
const session = await auth();
|
||||
if (!session?.user) throw new Error("Unauthorized");
|
||||
const parsed = markSchema.parse({ shotId, upTo });
|
||||
await markRead(session.user.id, parsed.shotId, new Date(parsed.upTo));
|
||||
revalidatePath("/shot-status");
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/** Mark every flagged shot in a project read, up to what each one had when checked. */
|
||||
export async function markProjectCommentsRead(projectId: string) {
|
||||
const session = await auth();
|
||||
if (!session?.user) throw new Error("Unauthorized");
|
||||
const summaries = await getUnreadCommentSummaries(session.user.id, z.string().min(1).parse(projectId));
|
||||
for (const [shotId, s] of Object.entries(summaries)) {
|
||||
await markRead(session.user.id, shotId, new Date(s.latestAt));
|
||||
}
|
||||
revalidatePath("/shot-status");
|
||||
return { success: true, count: Object.keys(summaries).length };
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import Link from "next/link";
|
||||
import * as Popover from "@radix-ui/react-popover";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { AlertCircle, Check, Loader2, MessageSquareReply } from "lucide-react";
|
||||
import { listUnreadShotComments, markShotCommentsRead } from "@/actions/comment-reads";
|
||||
import type { UnreadCommentItem } from "@/lib/comment-reads";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
|
||||
/**
|
||||
* The "!" beside a shot code while it has comments this user has not marked
|
||||
* read. Opening it lists them; they stay flagged until "Mark read" is clicked —
|
||||
* opening the list or the shot is not enough.
|
||||
*/
|
||||
export function NewCommentsMarker({
|
||||
shotId,
|
||||
shotCode,
|
||||
count,
|
||||
latestAt,
|
||||
onRead,
|
||||
}: {
|
||||
shotId: string;
|
||||
shotCode: string;
|
||||
count: number;
|
||||
latestAt: string;
|
||||
onRead: (shotId: string) => void;
|
||||
}) {
|
||||
const { toast } = useToast();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [items, setItems] = useState<UnreadCommentItem[] | null>(null);
|
||||
const [loadError, setLoadError] = useState(false);
|
||||
const [marking, startMarking] = useTransition();
|
||||
|
||||
const load = async () => {
|
||||
setLoadError(false);
|
||||
try {
|
||||
setItems(await listUnreadShotComments(shotId));
|
||||
} catch {
|
||||
setLoadError(true);
|
||||
}
|
||||
};
|
||||
|
||||
const markRead = () =>
|
||||
startMarking(async () => {
|
||||
// Read up to what was listed; with the list not loaded, up to what the
|
||||
// board showed. Anything newer stays flagged.
|
||||
const upTo = items?.[0]?.createdAt ?? latestAt;
|
||||
try {
|
||||
await markShotCommentsRead(shotId, upTo);
|
||||
setOpen(false);
|
||||
onRead(shotId);
|
||||
} catch {
|
||||
toast({ title: "Could not mark comments read", variant: "destructive" });
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Popover.Root
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
setOpen(next);
|
||||
if (next) load();
|
||||
}}
|
||||
>
|
||||
<Popover.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
title={`${count} new comment${count !== 1 ? "s" : ""} on ${shotCode}`}
|
||||
aria-label={`${count} new comment${count !== 1 ? "s" : ""} on ${shotCode}`}
|
||||
className="flex items-center justify-center h-4 min-w-4 rounded-full bg-orange-500 px-1 text-[10px] font-bold leading-none text-white hover:bg-orange-400 shrink-0"
|
||||
>
|
||||
!{count > 1 && <span className="ml-0.5 font-medium">{count}</span>}
|
||||
</button>
|
||||
</Popover.Trigger>
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
align="start"
|
||||
sideOffset={6}
|
||||
className="z-50 w-96 rounded-lg border border-zinc-700 bg-zinc-900 shadow-xl"
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-zinc-800 px-3 py-2">
|
||||
<span className="text-xs font-medium text-zinc-300">
|
||||
New on <span className="font-mono text-white">{shotCode}</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={markRead}
|
||||
disabled={marking}
|
||||
className="flex items-center gap-1 rounded border border-zinc-700 px-2 py-1 text-xs text-zinc-200 hover:bg-zinc-800 disabled:opacity-50"
|
||||
>
|
||||
{marking ? <Loader2 className="h-3 w-3 animate-spin" /> : <Check className="h-3 w-3" />}
|
||||
Mark read
|
||||
</button>
|
||||
</div>
|
||||
<div className="max-h-80 overflow-y-auto">
|
||||
{loadError ? (
|
||||
<p className="flex items-center gap-1.5 px-3 py-4 text-xs text-red-400">
|
||||
<AlertCircle className="h-3.5 w-3.5" />
|
||||
Could not load the comments.
|
||||
</p>
|
||||
) : items === null ? (
|
||||
<p className="flex items-center gap-1.5 px-3 py-4 text-xs text-zinc-500">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
Loading…
|
||||
</p>
|
||||
) : items.length === 0 ? (
|
||||
<p className="px-3 py-4 text-xs text-zinc-500">
|
||||
Nothing new — the comments may have been deleted.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-zinc-800">
|
||||
{items.map((item) => (
|
||||
<li key={`${item.kind}-${item.id}`}>
|
||||
<Link
|
||||
href={`/review/${item.versionId}`}
|
||||
className="block px-3 py-2 hover:bg-zinc-800/60"
|
||||
>
|
||||
<div className="flex items-center gap-1.5 text-[11px] text-zinc-500">
|
||||
{item.kind === "reply" && <MessageSquareReply className="h-3 w-3" />}
|
||||
<span className="text-zinc-300">{item.authorName}</span>
|
||||
{item.source === "WHATSAPP" && (
|
||||
<span className="rounded bg-green-500/15 px-1 text-green-400">client</span>
|
||||
)}
|
||||
<span className="font-mono">
|
||||
v{String(item.versionNumber).padStart(3, "0")} · f{item.frameNumber}
|
||||
</span>
|
||||
<span className="ml-auto">
|
||||
{formatDistanceToNow(new Date(item.createdAt), { addSuffix: true })}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-0.5 line-clamp-3 whitespace-pre-line text-xs text-zinc-200">
|
||||
{item.text}
|
||||
</p>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
);
|
||||
}
|
||||
@@ -36,6 +36,8 @@ import { updateShotNotes, bulkUpdateDueDates, bulkAssignArtist, bulkDeleteShots,
|
||||
import { previewMovReview, buildMovReview } from "@/actions/deliveries";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { LogRequestDialog } from "@/components/delivery/LogRequestDialog";
|
||||
import { markProjectCommentsRead } from "@/actions/comment-reads";
|
||||
import { NewCommentsMarker } from "./NewCommentsMarker";
|
||||
import {
|
||||
Film,
|
||||
Clock,
|
||||
@@ -81,6 +83,8 @@ type ShotRow = {
|
||||
manualDeliveryNote: string | null;
|
||||
shotVersion: string | null;
|
||||
delivery: ShotDeliverySummary;
|
||||
/** Comments from others this user has not marked read; null = none. */
|
||||
unreadComments: { count: number; latestAt: string } | null;
|
||||
artist: { id: string; name: string | null; image: string | null; email: string } | null;
|
||||
shotGroup: { id: string; name: string } | null;
|
||||
};
|
||||
@@ -912,6 +916,7 @@ function ShotTable({
|
||||
selectedIds,
|
||||
onToggle,
|
||||
onToggleAll,
|
||||
onCommentsRead,
|
||||
}: {
|
||||
shots: ShotRow[];
|
||||
canManage: boolean;
|
||||
@@ -919,6 +924,7 @@ function ShotTable({
|
||||
selectedIds: Set<string>;
|
||||
onToggle: (id: string) => void;
|
||||
onToggleAll: (ids: string[], checked: boolean) => void;
|
||||
onCommentsRead: (shotId: string) => void;
|
||||
}) {
|
||||
const allChecked = shots.length > 0 && shots.every((s) => selectedIds.has(s.id));
|
||||
const someChecked = shots.some((s) => selectedIds.has(s.id));
|
||||
@@ -1014,6 +1020,15 @@ function ShotTable({
|
||||
>
|
||||
{shot.shotCode}
|
||||
</Link>
|
||||
{shot.unreadComments && (
|
||||
<NewCommentsMarker
|
||||
shotId={shot.id}
|
||||
shotCode={shot.shotCode}
|
||||
count={shot.unreadComments.count}
|
||||
latestAt={shot.unreadComments.latestAt}
|
||||
onRead={onCommentsRead}
|
||||
/>
|
||||
)}
|
||||
{shot.shotVersion && (
|
||||
<span className="font-mono text-xs text-zinc-400 shrink-0">{shot.shotVersion}</span>
|
||||
)}
|
||||
@@ -1106,7 +1121,7 @@ export function ShotStatusClient({
|
||||
projects,
|
||||
selectedProjectId,
|
||||
selectedProject,
|
||||
shots,
|
||||
shots: initialShots,
|
||||
episodeDueDates,
|
||||
artists,
|
||||
canManage,
|
||||
@@ -1114,6 +1129,18 @@ export function ShotStatusClient({
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
|
||||
// Shots marked read here drop their "!" at once, ahead of the refresh; the
|
||||
// refreshed props are authoritative, so the local set is cleared when they land.
|
||||
const [readShotIds, setReadShotIds] = useState<Set<string>>(new Set());
|
||||
useEffect(() => setReadShotIds(new Set()), [initialShots]);
|
||||
const shots = readShotIds.size
|
||||
? initialShots.map((s) => (readShotIds.has(s.id) ? { ...s, unreadComments: null } : s))
|
||||
: initialShots;
|
||||
const markShotReadLocally = (shotId: string) =>
|
||||
setReadShotIds((prev) => new Set(prev).add(shotId));
|
||||
const [unreadOnly, setUnreadOnly] = useState(false);
|
||||
const [markingAllRead, startMarkingAllRead] = useTransition();
|
||||
|
||||
// Restore last selected project if no project is currently selected
|
||||
useEffect(() => {
|
||||
if (!selectedProjectId) {
|
||||
@@ -1144,6 +1171,7 @@ export function ShotStatusClient({
|
||||
if (!selectedProjectId) return;
|
||||
setExpandRestored(false);
|
||||
setStatusFilter(new Set());
|
||||
setUnreadOnly(false);
|
||||
const mode = (readStored(`shotStatus:${selectedProjectId}:groupMode`) ?? "flat") as "flat" | "scene" | "group";
|
||||
setGroupMode(mode);
|
||||
setExpandedEpisodes(new Set(readStoredList(`shotStatus:${selectedProjectId}:episodeExpand`) ?? []));
|
||||
@@ -1246,13 +1274,43 @@ export function ShotStatusClient({
|
||||
if (next.size > 0) {
|
||||
setSelectedIds((prev) => {
|
||||
const kept = new Set<string>();
|
||||
for (const s of shots) if (prev.has(s.id) && next.has(s.status)) kept.add(s.id);
|
||||
for (const s of shots)
|
||||
if (prev.has(s.id) && next.has(s.status) && (!unreadOnly || s.unreadComments)) kept.add(s.id);
|
||||
return kept;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const visibleShots = statusFilter.size > 0 ? shots.filter((s) => statusFilter.has(s.status)) : shots;
|
||||
const visibleShots = shots.filter(
|
||||
(s) => (statusFilter.size === 0 || statusFilter.has(s.status)) && (!unreadOnly || s.unreadComments)
|
||||
);
|
||||
const unreadShotCount = shots.filter((s) => s.unreadComments).length;
|
||||
const isFiltered = statusFilter.size > 0 || unreadOnly;
|
||||
|
||||
const toggleUnreadOnly = () => {
|
||||
const next = !unreadOnly;
|
||||
setUnreadOnly(next);
|
||||
// Bulk actions must never reach shots the filter has hidden.
|
||||
if (next) {
|
||||
setSelectedIds((prev) => {
|
||||
const kept = new Set<string>();
|
||||
for (const s of shots) if (prev.has(s.id) && s.unreadComments) kept.add(s.id);
|
||||
return kept;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleMarkAllRead = () =>
|
||||
startMarkingAllRead(async () => {
|
||||
if (!selectedProjectId) return;
|
||||
try {
|
||||
await markProjectCommentsRead(selectedProjectId);
|
||||
setReadShotIds(new Set(shots.filter((s) => s.unreadComments).map((s) => s.id)));
|
||||
setUnreadOnly(false);
|
||||
} catch {
|
||||
toast({ title: "Could not mark comments read", variant: "destructive" });
|
||||
}
|
||||
});
|
||||
|
||||
const handleBulkDueDate = async (date: string, includeTasks: boolean) => {
|
||||
try {
|
||||
@@ -1457,7 +1515,7 @@ export function ShotStatusClient({
|
||||
{selectionCount === 0 ? (
|
||||
<>
|
||||
<span className="text-xs text-zinc-500 self-center">
|
||||
{statusFilter.size > 0 ? `${visibleShots.length} of ${shots.length} shots` : `${shots.length} shots total`}
|
||||
{isFiltered ? `${visibleShots.length} of ${shots.length} shots` : `${shots.length} shots total`}
|
||||
</span>
|
||||
{Object.entries(STATUS_CONFIG).map(([key, cfg]) => {
|
||||
const count = statusCounts[key] ?? 0;
|
||||
@@ -1485,10 +1543,43 @@ export function ShotStatusClient({
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{statusFilter.size > 0 && (
|
||||
{unreadShotCount > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStatusFilter(new Set())}
|
||||
onClick={toggleUnreadOnly}
|
||||
aria-pressed={unreadOnly}
|
||||
title={unreadOnly ? "Show all shots" : "Show only shots with new comments"}
|
||||
className="rounded-full"
|
||||
>
|
||||
<Badge
|
||||
className={cn(
|
||||
"gap-1 text-xs cursor-pointer bg-orange-500/15 text-orange-300 border-orange-500/30",
|
||||
unreadOnly && "ring-1 ring-current"
|
||||
)}
|
||||
>
|
||||
<span className="font-bold">!</span>
|
||||
{unreadShotCount} with new comments
|
||||
</Badge>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleMarkAllRead}
|
||||
disabled={markingAllRead}
|
||||
className="flex items-center gap-1 text-xs text-zinc-500 hover:text-zinc-300 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{markingAllRead ? <Loader2 className="h-3 w-3 animate-spin" /> : <CheckCircle2 className="h-3 w-3" />}
|
||||
Mark all read
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
{isFiltered && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setStatusFilter(new Set());
|
||||
setUnreadOnly(false);
|
||||
}}
|
||||
className="flex items-center gap-1 text-xs text-zinc-500 hover:text-zinc-300 transition-colors"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
@@ -1608,6 +1699,7 @@ export function ShotStatusClient({
|
||||
projectId={selectedProjectId!}
|
||||
selectedIds={selectedIds}
|
||||
onToggle={toggleShot}
|
||||
onCommentsRead={markShotReadLocally}
|
||||
onToggleAll={toggleMany}
|
||||
/>
|
||||
</div>
|
||||
@@ -1657,6 +1749,7 @@ export function ShotStatusClient({
|
||||
projectId={selectedProjectId!}
|
||||
selectedIds={selectedIds}
|
||||
onToggle={toggleShot}
|
||||
onCommentsRead={markShotReadLocally}
|
||||
onToggleAll={toggleMany}
|
||||
/>
|
||||
</div>
|
||||
@@ -1706,6 +1799,7 @@ export function ShotStatusClient({
|
||||
projectId={selectedProjectId!}
|
||||
selectedIds={selectedIds}
|
||||
onToggle={toggleShot}
|
||||
onCommentsRead={markShotReadLocally}
|
||||
onToggleAll={toggleMany}
|
||||
/>
|
||||
</div>
|
||||
@@ -1722,6 +1816,7 @@ export function ShotStatusClient({
|
||||
projectId={selectedProjectId!}
|
||||
selectedIds={selectedIds}
|
||||
onToggle={toggleShot}
|
||||
onCommentsRead={markShotReadLocally}
|
||||
onToggleAll={toggleMany}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { auth } from "@/auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import { ShotStatusClient } from "./ShotStatusClient";
|
||||
import { summariseShotDelivery } from "@/lib/render-pipeline/shot-delivery-summary";
|
||||
import { getUnreadCommentSummaries, type UnreadCommentSummary } from "@/lib/comment-reads";
|
||||
|
||||
async function getProjects() {
|
||||
return db.project.findMany({
|
||||
@@ -116,12 +117,14 @@ export default async function ShotStatusPage({
|
||||
const { projectId } = await searchParams;
|
||||
const canManage = ["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role);
|
||||
|
||||
const [projects, shots, episodeDueDates, artists] = await Promise.all([
|
||||
const [projects, shotRows, episodeDueDates, artists, unread] = await Promise.all([
|
||||
getProjects(),
|
||||
projectId ? getShotsForProject(projectId) : Promise.resolve([]),
|
||||
projectId ? getEpisodeDueDatesForProject(projectId) : Promise.resolve([]),
|
||||
canManage ? getAssignableArtists() : Promise.resolve([]),
|
||||
projectId ? getUnreadCommentSummaries(session.user.id, projectId) : Promise.resolve({} as Record<string, UnreadCommentSummary>),
|
||||
]);
|
||||
const shots = shotRows.map((shot) => ({ ...shot, unreadComments: unread[shot.id] ?? null }));
|
||||
|
||||
const selectedProject = projects.find((p) => p.id === projectId) ?? null;
|
||||
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
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 },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
-- Per-user read marker for a shot's comments: the shot-status board flags a
|
||||
-- shot with "!" while it has comments from others newer than the marker.
|
||||
CREATE TABLE "shot_comment_reads" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"shotId" TEXT NOT NULL,
|
||||
"readAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "shot_comment_reads_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "shot_comment_reads_userId_shotId_key" ON "shot_comment_reads"("userId", "shotId");
|
||||
CREATE INDEX "shot_comment_reads_shotId_idx" ON "shot_comment_reads"("shotId");
|
||||
|
||||
ALTER TABLE "shot_comment_reads" ADD CONSTRAINT "shot_comment_reads_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "shot_comment_reads" ADD CONSTRAINT "shot_comment_reads_shotId_fkey" FOREIGN KEY ("shotId") REFERENCES "shots"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- Everything already commented counts as read on launch, so the board starts
|
||||
-- clean instead of flagging every shot that has ever had a note.
|
||||
INSERT INTO "shot_comment_reads" ("id", "userId", "shotId", "readAt")
|
||||
SELECT md5(u."id" || ':' || s."id"), u."id", s."id", CURRENT_TIMESTAMP
|
||||
FROM "users" u
|
||||
CROSS JOIN "shots" s
|
||||
WHERE u."role" <> 'CLIENT'
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM "comments" c
|
||||
JOIN "versions" v ON v."id" = c."versionId"
|
||||
LEFT JOIN "tasks" t ON t."id" = v."taskId"
|
||||
WHERE COALESCE(v."shotId", t."shotId") = s."id"
|
||||
);
|
||||
@@ -263,6 +263,7 @@ model User {
|
||||
createdTakes Take[] @relation("TakeCreator")
|
||||
uploadedTakeAttachments TakeAttachment[] @relation("TakeAttachmentUploader")
|
||||
assignedBoardItems BoardItem[] @relation("BoardItemAssignee")
|
||||
shotCommentReads ShotCommentRead[]
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
@@ -498,6 +499,7 @@ model Shot {
|
||||
exports Export[]
|
||||
deliveryRequestLines DeliveryRequestLine[]
|
||||
boardItems BoardItem[]
|
||||
commentReads ShotCommentRead[]
|
||||
|
||||
@@unique([projectId, shotCode])
|
||||
@@map("shots")
|
||||
@@ -675,6 +677,24 @@ model Approval {
|
||||
@@map("approvals")
|
||||
}
|
||||
|
||||
/// How far each user has read a shot's comments. A comment or reply on any of
|
||||
/// the shot's versions, written by someone else after `readAt`, is new to that
|
||||
/// user and flags the shot on the shot-status board until they mark it read.
|
||||
/// No row = nothing read since the account was created.
|
||||
model ShotCommentRead {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
shotId String
|
||||
readAt DateTime
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
shot Shot @relation(fields: [shotId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId, shotId])
|
||||
@@index([shotId])
|
||||
@@map("shot_comment_reads")
|
||||
}
|
||||
|
||||
model Notification {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
|
||||
Reference in New Issue
Block a user