8 Commits

Author SHA1 Message Date
twotalesanimation 633301c491 fix(docker): exclude any uploads* folder from the build context
Deploy / deploy (push) Successful in 9m34s
A renamed backup (uploads.old) was sent as build context and filled
the VPS root disk during deploy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 21:45:38 +02:00
twotalesanimation 83aad38053 chore(deploy): store uploads on the Hetzner volume
Deploy / deploy (push) Failing after 1s
Mount /mnt/HC_Volume_105945736/vfxreview-uploads at /app/uploads
instead of ./uploads, so uploads no longer fill the VPS root disk.
The host folder must be owned by uid 1001 (the nextjs user).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 21:13:39 +02:00
twotalesanimation a4d5777e1d fix(shot-status): sort shots by shot code, not shotNumber
Imports and the external API assign shotNumber as max+10 (or 0), which
needn't match the number in the shot code, so new shots could sink to
the bottom of the list. Sort by episode then natural shot-code order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 05:18:18 +02:00
twotalesanimation 3a7ba689ed feat(shot-status): show new-comment count on section headers
Deploy / deploy (push) Successful in 3m56s
Episode, scene and group headers carry an orange "!" with the number of
their shots that have new comments, so a collapsed section still shows
there is something to read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 09:08:35 +02:00
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
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
twotalesanimation 0aedc4b1fa feat(shots): previous/next shot navigation on the shot page
Deploy / deploy (push) Successful in 3m26s
Prev/next buttons beside the breadcrumb walk the project's shot list in
the same order as the Shots tab. The ordering now lives in
lib/shot-order.ts, shared by the project page and the shot API.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 07:45:24 +02:00
twotalesanimation d7e37de18f feat(deliveries): choose which ready shots go into a package
Deploy / deploy (push) Successful in 3m35s
Each QC-passed shot in "Ready for delivery" gets a checkbox; all start
selected, with a per-episode Select/Deselect all. Build sends the explicit
exportIds ticked on screen, so a shot that turns ready after the page
loaded never slips into a package unseen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 18:10:51 +02:00
19 changed files with 946 additions and 82 deletions
+1 -1
View File
@@ -23,7 +23,7 @@ Dockerfile*
.github
migrations
README.md
uploads
uploads*
.git
*.tar
*.sql
+42
View File
@@ -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 };
}
+13 -11
View File
@@ -4,7 +4,7 @@ import { auth } from "@/auth";
import { db } from "@/lib/db";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { notifyFeedbackAdded, notifyCommentReply } from "@/lib/notifications";
import { notifyCommentsAdded, notifyCommentReply } from "@/lib/notifications";
import { slackNotifyNewFeedback } from "@/lib/slack";
import { createDisplayEvent } from "@/lib/display-events";
@@ -36,7 +36,6 @@ export async function addComment(data: z.infer<typeof addCommentSchema>) {
},
});
// Notify the shot artist if commenter is not the artist
const version = await db.version.findUnique({
where: { id: parsed.versionId },
include: {
@@ -56,16 +55,19 @@ export async function addComment(data: z.infer<typeof addCommentSchema>) {
version?.task?.title ??
"Task";
if (version?.shot?.artistId && version.shot.artistId !== session.user.id) {
const user = await db.user.findUnique({ where: { id: session.user.id } });
await notifyFeedbackAdded({
artistId: version.shot.artistId,
shotCode: version.shot.shotCode,
frameNumber: parsed.frameNumber,
// Staff and the shot's artist — never the commenter. A failed notification
// must not fail a comment that is already saved.
const user = await db.user.findUnique({ where: { id: session.user.id }, select: { name: true } });
await notifyCommentsAdded({
notes: [{
versionId: parsed.versionId,
authorName: user?.name ?? session.user.email ?? "Someone",
});
}
shotCode,
frameNumber: parsed.frameNumber,
artistIds: [version?.shot?.artistId, version?.task?.assignedArtistId],
}],
authorId: session.user.id,
authorName: user?.name ?? session.user.email ?? "Someone",
}).catch((err) => console.error("[comments] notify failed", err));
// Slack notification
if (slackWebhook) {
+26 -2
View File
@@ -7,6 +7,7 @@ import { z } from "zod";
import { findShot, suggestShot, type BoardShot } from "@/lib/board-parse";
import { parseFeedback, slateFrames, toPlayerFrame } from "@/lib/feedback-parse";
import { updateTaskStatus } from "@/actions/tasks";
import { notifyCommentsAdded, type NewCommentNote } from "@/lib/notifications";
/** Comment.source for notes pasted from a client message */
const SOURCE = "WHATSAPP";
@@ -176,7 +177,13 @@ export async function commitFeedback(data: z.infer<typeof commitSchema>) {
const versionIds = [...new Set(notes.map((n) => n.versionId).filter(Boolean) as string[])];
const versions = await db.version.findMany({
where: { id: { in: versionIds } },
select: { id: true, fps: true, shotId: true, task: { select: { id: true, shotId: true, status: true, projectId: true } }, shot: { select: { projectId: true } } },
select: {
id: true,
fps: true,
shotId: true,
task: { select: { id: true, shotId: true, status: true, projectId: true, assignedArtistId: true, shot: { select: { shotCode: true } } } },
shot: { select: { projectId: true, shotCode: true, artistId: true } },
},
});
const vById = new Map(versions.map((v) => [v.id, v]));
for (const n of notes) {
@@ -195,6 +202,7 @@ export async function commitFeedback(data: z.infer<typeof commitSchema>) {
let sort = (last?.sortOrder ?? -1) + 1;
let comments = 0;
const saved: NewCommentNote[] = [];
await db.$transaction(async (tx) => {
for (const r of parsed.rounds) {
const round = await tx.feedbackRound.create({
@@ -221,7 +229,15 @@ export async function commitFeedback(data: z.infer<typeof commitSchema>) {
select: { id: true },
})
: null;
if (comment) comments++;
if (comment && v) {
comments++;
saved.push({
versionId: v.id,
shotCode: v.shot?.shotCode ?? v.task?.shot?.shotCode ?? n.code.toUpperCase(),
frameNumber: frame,
artistIds: [v.shot?.artistId, v.task?.assignedArtistId],
});
}
await tx.boardItem.create({
data: {
projectId: parsed.projectId,
@@ -247,6 +263,14 @@ export async function commitFeedback(data: z.infer<typeof commitSchema>) {
}
for (const taskId of tasks) await updateTaskStatus(taskId, "CHANGES");
// Only after the transaction commits — a rolled-back paste notifies no one.
await notifyCommentsAdded({
notes: saved,
authorId: user.id,
authorName: user.name ?? user.email ?? "Someone",
origin: "whatsapp",
}).catch((err) => console.error("[feedback] notify failed", err));
for (const id of versionIds) revalidatePath(`/review/${id}`);
revalidatePath("/board");
return { success: true, notes: notes.length, comments, tasksChanged: tasks.size };
@@ -83,6 +83,9 @@ export function DeliveriesClient({ canBuild }: { canBuild: boolean }) {
const [projectId, setProjectId] = useState<string>("");
const [episode, setEpisode] = useState<string>("");
const [building, setBuilding] = useState(false);
// Tracked as exclusions so every ready shot — including one that passes QC
// while the page is open — starts selected.
const [deselected, setDeselected] = useState<Set<string>>(() => new Set());
const [handoff, setHandoff] = useState<{ step: "upload" | "share"; packageId: string } | null>(null);
const { data: projects } = useQuery({
@@ -116,14 +119,36 @@ export function DeliveriesClient({ canBuild }: { canBuild: boolean }) {
refetchInterval: 5000,
});
async function build(ep: string) {
if (!projectId) return;
function toggleExport(exportId: string, selected: boolean) {
setDeselected((prev) => {
const next = new Set(prev);
if (selected) next.delete(exportId);
else next.add(exportId);
return next;
});
}
function setEpisodeSelected(exportIds: string[], selected: boolean) {
setDeselected((prev) => {
const next = new Set(prev);
for (const id of exportIds) {
if (selected) next.delete(id);
else next.add(id);
}
return next;
});
}
async function build(ep: string, exportIds: string[]) {
if (!projectId || exportIds.length === 0) return;
setBuilding(true);
try {
// Always send the explicit list: the package holds exactly the shots
// ticked on screen, never one that turned ready since the page loaded.
const res = await fetch("/api/deliveries", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ projectId, episode: ep || null }),
body: JSON.stringify({ projectId, episode: ep || null, exportIds }),
});
const body = await res.json().catch(() => ({}));
if (!res.ok) {
@@ -179,6 +204,7 @@ export function DeliveriesClient({ canBuild }: { canBuild: boolean }) {
onChange={(e) => {
setProjectId(e.target.value);
setEpisode("");
setDeselected(new Set());
}}
className="rounded-md border border-zinc-800 bg-zinc-900 px-3 py-1.5 text-sm text-zinc-200"
>
@@ -219,30 +245,69 @@ export function DeliveriesClient({ canBuild }: { canBuild: boolean }) {
Nothing has passed QC yet. Exports appear here once QC passes them.
</div>
)}
{shown.map((ep) => (
{shown.map((ep) => {
const allIds = ep.exports.map((x) => x.exportId);
const selectedIds = allIds.filter((id) => !deselected.has(id));
const allSelected = selectedIds.length === allIds.length;
const selectedExports = ep.exports.filter((x) => !deselected.has(x.exportId));
const missingChecksum = selectedExports.filter((x) => !x.hasChecksum).length;
return (
<div key={ep.episode} className="border-b border-zinc-800/60 last:border-0">
<div className="flex items-center justify-between gap-4 px-4 py-3 flex-wrap">
<div>
<span className="text-white text-sm font-medium">
{ep.episode ? `Episode ${ep.episode}` : "No episode"}
</span>
<span className="ml-3 text-sm text-zinc-400">Ready: {ep.count} shots</span>
{ep.exports.some((x) => !x.hasChecksum) && (
<span className="ml-3 text-sm text-zinc-400">
{canBuild ? `Selected: ${selectedIds.length} of ${ep.count} shots` : `Ready: ${ep.count} shots`}
</span>
{canBuild && selectedIds.length > 0 && (
<span className="ml-3 text-xs text-zinc-500">
{formatBytes(selectedExports.reduce((sum, x) => sum + (x.totalBytes ?? 0), 0))}
</span>
)}
{missingChecksum > 0 && (
<span className="ml-3 text-xs text-amber-400">
{ep.exports.filter((x) => !x.hasChecksum).length} without a validation checksum
{missingChecksum} without a validation checksum
</span>
)}
</div>
{canBuild && (
<Button size="sm" onClick={() => build(ep.episode)} disabled={building}>
<Package className="h-4 w-4 mr-2" />
Build delivery
</Button>
<span className="flex items-center gap-2">
<Button
size="sm"
variant="outline"
onClick={() => setEpisodeSelected(allIds, !allSelected)}
>
{allSelected ? "Deselect all" : "Select all"}
</Button>
<Button
size="sm"
onClick={() => build(ep.episode, selectedIds)}
disabled={building || selectedIds.length === 0}
>
<Package className="h-4 w-4 mr-2" />
Build delivery ({selectedIds.length})
</Button>
</span>
)}
</div>
<div className="px-4 pb-3 grid sm:grid-cols-2 lg:grid-cols-3 gap-x-6 gap-y-1">
{ep.exports.map((x) => (
<div key={x.exportId} className="flex items-center gap-2 text-xs">
{ep.exports.map((x) => {
const selected = !deselected.has(x.exportId);
return (
<div
key={x.exportId}
className={cn("flex items-center gap-2 text-xs", canBuild && !selected && "opacity-50")}
>
{canBuild && (
<input
type="checkbox"
checked={selected}
onChange={(e) => toggleExport(x.exportId, e.target.checked)}
aria-label={`Include ${x.shotCode} in the package`}
/>
)}
<Link href={`/pipeline/exports/${x.exportId}`} className="text-zinc-300 hover:text-white">
{x.shotCode}
</Link>
@@ -251,10 +316,12 @@ export function DeliveriesClient({ canBuild }: { canBuild: boolean }) {
<span className="text-zinc-600">{formatBytes(x.totalBytes)}</span>
{!x.deliveryMovPath && <span className="text-amber-500/80">no MOV</span>}
</div>
))}
);
})}
</div>
</div>
))}
);
})}
</div>
)}
@@ -13,6 +13,7 @@ import { TaskCard } from "@/components/tasks/TaskCard";
import { NewTaskDialog } from "@/components/tasks/NewTaskDialog";
import { KanbanBoard } from "@/components/tasks/KanbanBoard";
import { cn } from "@/lib/utils";
import { sortShotsForList } from "@/lib/shot-order";
import { Film, Package, ListTodo, LayoutDashboard, Plus, Settings, FileUp, ChevronDown, ChevronRight, Calendar, Pencil, Check, X } from "lucide-react";
import type { ShotWithDetails } from "@/types";
import { ProjectSettingsTab } from "@/components/projects/ProjectSettingsTab";
@@ -147,13 +148,7 @@ export function ProjectTabsClient({
// For episodic projects: group and sort shots by episode → scene → shotNumber
const episodeGroups: [string, ShotWithDetails[]][] = projectType === "EPISODIC"
? (() => {
const sorted = [...shots].sort((a, b) => {
const ea = a.episode ?? "";
const eb = b.episode ?? "";
if (ea !== eb) return ea.localeCompare(eb, undefined, { numeric: true });
if (a.scene !== b.scene) return a.scene.localeCompare(b.scene, undefined, { numeric: true });
return a.shotNumber - b.shotNumber;
});
const sorted = sortShotsForList(shots, projectType);
const map = new Map<string, ShotWithDetails[]>();
for (const shot of sorted) {
const key = shot.episode ?? "(No Episode)";
@@ -167,13 +162,7 @@ export function ProjectTabsClient({
// For standard projects: group by custom shot group when any exist
const standardGroups: [string, ShotWithDetails[]][] = projectType === "STANDARD" && shots.some((s) => s.shotGroup)
? (() => {
const sorted = [...shots].sort((a, b) => {
const ga = a.shotGroup?.name ?? "\uFFFF";
const gb = b.shotGroup?.name ?? "\uFFFF";
if (ga !== gb) return ga.localeCompare(gb, undefined, { numeric: true });
if (a.scene !== b.scene) return a.scene.localeCompare(b.scene, undefined, { numeric: true });
return a.shotNumber - b.shotNumber;
});
const sorted = sortShotsForList(shots, projectType);
const map = new Map<string, ShotWithDetails[]>();
for (const shot of sorted) {
const key = shot.shotGroup?.name ?? "(Ungrouped)";
@@ -31,6 +31,8 @@ import {
Pencil,
Check,
LayoutList,
ChevronLeft,
ChevronRight,
} from "lucide-react";
import { Input } from "@/components/ui/input";
import type { ShotWithDetails } from "@/types";
@@ -78,12 +80,47 @@ const PRIORITY_CONFIG: Record<string, { label: string; dot: string }> = {
import { useToast } from "@/components/ui/use-toast";
import { shotThumbnail } from "@/lib/shot-thumbnail";
type ShotNavTarget = { id: string; shotCode: string } | null;
type ShotNav = { prev: ShotNavTarget; next: ShotNavTarget; position: number; total: number };
function ShotNavLink({
projectId,
target,
direction,
}: {
projectId: string;
target: ShotNavTarget;
direction: "prev" | "next";
}) {
const Icon = direction === "prev" ? ChevronLeft : ChevronRight;
const label = direction === "prev" ? "Previous shot" : "Next shot";
if (!target) {
return (
<Button variant="outline" size="sm" disabled className="h-7 gap-1 px-2 font-mono text-xs">
{direction === "prev" && <Icon className="h-3.5 w-3.5" />}
<span className="hidden sm:inline">{direction === "prev" ? "Prev" : "Next"}</span>
{direction === "next" && <Icon className="h-3.5 w-3.5" />}
</Button>
);
}
return (
<Button variant="outline" size="sm" asChild className="h-7 gap-1 px-2 font-mono text-xs">
<Link href={`/projects/${projectId}/shots/${target.id}`} title={`${label}: ${target.shotCode}`}>
{direction === "prev" && <Icon className="h-3.5 w-3.5" />}
<span className="hidden sm:inline max-w-[10rem] truncate">{target.shotCode}</span>
{direction === "next" && <Icon className="h-3.5 w-3.5" />}
</Link>
</Button>
);
}
export default function ShotDetailPage() {
const params = useParams<{ id: string; shotId: string }>();
const router = useRouter();
const { toast } = useToast();
const [shot, setShot] = useState<ShotWithDetails | null>(null);
const [projectName, setProjectName] = useState<string>("");
const [shotNav, setShotNav] = useState<ShotNav | null>(null);
const [loading, setLoading] = useState(true);
const [canApprove, setCanApprove] = useState(false);
const [canInternallyApprove, setCanInternallyApprove] = useState(false);
@@ -108,6 +145,7 @@ export default function ShotDetailPage() {
const data = await res.json();
setShot(data.shot);
setProjectName(data.projectName ?? "");
setShotNav(data.shotNav ?? null);
setCanApprove(data.canApprove ?? false);
setCanInternallyApprove(data.canInternallyApprove ?? false);
setTasks(data.tasks ?? []);
@@ -242,6 +280,17 @@ export default function ShotDetailPage() {
</Link>
<span>/</span>
<span className="text-foreground font-mono">{shot.shotCode}</span>
{/* Prev / next shot, in the project page's list order */}
{shotNav && shotNav.total > 1 && (
<div className="ml-auto flex items-center gap-1">
<ShotNavLink projectId={params.id} target={shotNav.prev} direction="prev" />
<span className="px-1 text-xs tabular-nums">
{shotNav.position} / {shotNav.total}
</span>
<ShotNavLink projectId={params.id} target={shotNav.next} direction="next" />
</div>
)}
</div>
{/* Header */}
@@ -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;
};
@@ -905,6 +909,23 @@ function DeliveryCell({ delivery, shotVersion }: { delivery: ShotDeliverySummary
);
}
/** On a section header: how many of its shots have new comments, so a
* collapsed episode, scene or group still shows there is something to read. */
function SectionUnreadBadge({ shots }: { shots: ShotRow[] }) {
const flagged = shots.filter((s) => s.unreadComments).length;
if (!flagged) return null;
const label = `${flagged} shot${flagged !== 1 ? "s" : ""} with new comments`;
return (
<span
title={label}
aria-label={label}
className="flex items-center gap-0.5 rounded-full bg-orange-500 px-1.5 h-4 text-[10px] font-bold leading-none text-white shrink-0"
>
!<span className="font-medium">{flagged}</span>
</span>
);
}
function ShotTable({
shots,
canManage,
@@ -912,6 +933,7 @@ function ShotTable({
selectedIds,
onToggle,
onToggleAll,
onCommentsRead,
}: {
shots: ShotRow[];
canManage: boolean;
@@ -919,6 +941,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 +1037,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 +1138,7 @@ export function ShotStatusClient({
projects,
selectedProjectId,
selectedProject,
shots,
shots: initialShots,
episodeDueDates,
artists,
canManage,
@@ -1114,6 +1146,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 +1188,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 +1291,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 +1532,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 +1560,43 @@ export function ShotStatusClient({
</button>
);
})}
{statusFilter.size > 0 && (
{unreadShotCount > 0 && (
<span className="flex items-center gap-1">
<button
type="button"
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())}
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" />
@@ -1589,6 +1697,7 @@ export function ShotStatusClient({
<span className="text-xs text-zinc-500 font-normal">
{episodeShots.length} shot{episodeShots.length !== 1 ? "s" : ""}
</span>
<SectionUnreadBadge shots={episodeShots} />
{epDueDate && (
<span className={cn(
"flex items-center gap-1 text-xs",
@@ -1608,6 +1717,7 @@ export function ShotStatusClient({
projectId={selectedProjectId!}
selectedIds={selectedIds}
onToggle={toggleShot}
onCommentsRead={markShotReadLocally}
onToggleAll={toggleMany}
/>
</div>
@@ -1647,6 +1757,7 @@ export function ShotStatusClient({
<span className="text-xs text-zinc-500 font-normal">
{sceneShots.length} shot{sceneShots.length !== 1 ? "s" : ""}
</span>
<SectionUnreadBadge shots={sceneShots} />
</button>
</div>
{!collapsed && (
@@ -1657,6 +1768,7 @@ export function ShotStatusClient({
projectId={selectedProjectId!}
selectedIds={selectedIds}
onToggle={toggleShot}
onCommentsRead={markShotReadLocally}
onToggleAll={toggleMany}
/>
</div>
@@ -1696,6 +1808,7 @@ export function ShotStatusClient({
<span className="text-xs text-zinc-500 font-normal">
{groupShots.length} shot{groupShots.length !== 1 ? "s" : ""}
</span>
<SectionUnreadBadge shots={groupShots} />
</button>
</div>
{!collapsed && (
@@ -1706,6 +1819,7 @@ export function ShotStatusClient({
projectId={selectedProjectId!}
selectedIds={selectedIds}
onToggle={toggleShot}
onCommentsRead={markShotReadLocally}
onToggleAll={toggleMany}
/>
</div>
@@ -1722,6 +1836,7 @@ export function ShotStatusClient({
projectId={selectedProjectId!}
selectedIds={selectedIds}
onToggle={toggleShot}
onCommentsRead={markShotReadLocally}
onToggleAll={toggleMany}
/>
</div>
+16 -6
View File
@@ -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({
@@ -29,14 +30,11 @@ async function getAssignableArtists() {
});
}
const shotCodeCollator = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" });
async function getShotsForProject(projectId: string) {
const shots = await db.shot.findMany({
where: { projectId },
orderBy: [
{ episode: { sort: "asc", nulls: "last" } },
{ scene: "asc" },
{ shotNumber: "asc" },
],
select: {
id: true,
shotCode: true,
@@ -89,6 +87,16 @@ async function getShotsForProject(projectId: string) {
},
});
// Sorted by shot code rather than scene + shotNumber: imports and the
// external API assign shotNumber as "max + 10" (or 0), which needn't match
// the number in the code, so new shots could sink to the bottom.
shots.sort(
(a, b) =>
(a.episode === null ? 1 : 0) - (b.episode === null ? 1 : 0) ||
shotCodeCollator.compare(a.episode ?? "", b.episode ?? "") ||
shotCodeCollator.compare(a.shotCode, b.shotCode)
);
return shots.map(({ exports, ...shot }) => ({
...shot,
delivery: summariseShotDelivery({
@@ -116,12 +124,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;
+15 -2
View File
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db";
import { slackNotifyNewFeedback } from "@/lib/slack";
import { notifyCommentsAdded } from "@/lib/notifications";
import { validateReviewToken, getOrCreateReviewUser } from "@/lib/review-auth";
export async function POST(
@@ -28,8 +29,8 @@ export async function POST(
const version = await db.version.findUnique({
where: { id: versionId },
include: {
shot: { select: { projectId: true, shotCode: true, project: { select: { slackWebhook: true } } } },
task: { select: { projectId: true, title: true, project: { select: { slackWebhook: true } }, shot: { select: { shotCode: true } } } },
shot: { select: { projectId: true, shotCode: true, artistId: true, project: { select: { slackWebhook: true } } } },
task: { select: { projectId: true, title: true, assignedArtistId: true, project: { select: { slackWebhook: true } }, shot: { select: { shotCode: true } } } },
},
});
const projectId = version?.shot?.projectId ?? version?.task?.projectId;
@@ -70,6 +71,18 @@ export async function POST(
});
}
await notifyCommentsAdded({
notes: [{
versionId,
shotCode,
frameNumber,
artistIds: [version.shot?.artistId, version.task?.assignedArtistId],
}],
authorId: user.id,
authorName: user.name ?? user.email,
origin: "client",
}).catch((err) => console.error("[client comment] notify failed", err));
// canEdit mirrors the GET shape — a note you just wrote is always your own.
return NextResponse.json({ comment: { ...comment, canEdit: true } }, { status: 201 });
}
+29 -1
View File
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { sortShotsForList } from "@/lib/shot-order";
export async function GET(
req: NextRequest,
@@ -38,9 +39,35 @@ export async function GET(
const project = await db.project.findUnique({
where: { id: shot.projectId },
select: { name: true },
select: {
name: true,
projectType: true,
shots: {
orderBy: { createdAt: "asc" },
select: {
id: true,
shotCode: true,
episode: true,
scene: true,
shotNumber: true,
shotGroup: { select: { name: true } },
},
},
},
});
// Neighbours in the project page's shot list order, for prev/next navigation
const orderedShots = project ? sortShotsForList(project.shots, project.projectType) : [];
const shotIndex = orderedShots.findIndex((s) => s.id === shotId);
const neighbour = (i: number) =>
shotIndex >= 0 && orderedShots[i] ? { id: orderedShots[i].id, shotCode: orderedShots[i].shotCode } : null;
const shotNav = {
prev: neighbour(shotIndex - 1),
next: neighbour(shotIndex + 1),
position: shotIndex + 1,
total: orderedShots.length,
};
const [tasks, artists] = await Promise.all([
db.task.findMany({
where: { shotId },
@@ -99,6 +126,7 @@ export async function GET(
return NextResponse.json({
shot: shotSerialized,
projectName: project?.name ?? "",
shotNav,
canApprove,
canInternallyApprove: ["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role as string),
tasks,
+2 -1
View File
@@ -11,7 +11,8 @@ services:
- .env
volumes:
- ./uploads:/app/uploads
# Uploads live on the Hetzner volume, not the root disk (uid 1001 must own it)
- /mnt/HC_Volume_105945736/vfxreview-uploads:/app/uploads
depends_on:
postgres:
+146
View File
@@ -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 },
});
}
+75
View File
@@ -0,0 +1,75 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("@/lib/db", () => ({ db: {} }));
import { buildCommentNotifications } from "./notifications";
const note = (versionId: string, shotCode: string, frameNumber: number, artistIds: (string | null)[] = []) => ({
versionId,
shotCode,
frameNumber,
artistIds,
});
describe("buildCommentNotifications", () => {
it("tells every staff member and the artist, never the author", () => {
const out = buildCommentNotifications({
notes: [note("v1", "DH2_010", 1012, ["artist"])],
authorId: "sup",
authorName: "Sam",
staffIds: ["sup", "prod", "admin"],
});
expect(out.map((p) => p.userId).sort()).toEqual(["admin", "artist", "prod"]);
expect(out[0]).toMatchObject({
type: "FEEDBACK_ADDED",
title: "New feedback",
message: "Sam commented on DH2_010 at frame 1012",
data: { versionId: "v1", shotCode: "DH2_010", frameNumber: 1012 },
});
});
it("does not notify an artist twice when they are also staff or on both shot and task", () => {
const out = buildCommentNotifications({
notes: [note("v1", "A", 1, ["prod", "prod", null])],
authorId: "x",
authorName: "X",
staffIds: ["prod"],
});
expect(out.map((p) => p.userId)).toEqual(["prod"]);
});
it("labels client comments", () => {
const [p] = buildCommentNotifications({
notes: [note("v1", "A", 5)],
authorId: "client",
authorName: "Netflix",
origin: "client",
staffIds: ["prod"],
});
expect(p.title).toBe("New client feedback");
expect(p.message).toBe("Client Netflix commented on A at frame 5");
});
it("groups a pasted round into one notification per version", () => {
const out = buildCommentNotifications({
notes: [note("v1", "A", 1, ["art1"]), note("v1", "A", 9), note("v2", "B", 3, ["art2"])],
authorId: "prod",
authorName: "Pat",
origin: "whatsapp",
staffIds: ["prod", "sup"],
});
const msgs = out.map((p) => `${p.userId}: ${p.message}`).sort();
expect(msgs).toEqual([
"art1: Client feedback (logged by Pat) left 2 notes on A",
"art2: Client feedback (logged by Pat) commented on B at frame 3",
"sup: Client feedback (logged by Pat) commented on B at frame 3",
"sup: Client feedback (logged by Pat) left 2 notes on A",
]);
});
it("sends nothing for no notes", () => {
expect(
buildCommentNotifications({ notes: [], authorId: "a", authorName: "A", staffIds: ["b"] })
).toEqual([]);
});
});
+94 -23
View File
@@ -78,29 +78,6 @@ export async function notifyNewVersionUploaded(params: {
);
}
/**
* Notify the shot's artist about new feedback.
*/
export async function notifyFeedbackAdded(params: {
artistId: string;
shotCode: string;
frameNumber: number;
versionId: string;
authorName: string;
}) {
await createNotification({
userId: params.artistId,
type: NotificationType.FEEDBACK_ADDED,
title: "New feedback on your shot",
message: `${params.authorName} commented on ${params.shotCode} at frame ${params.frameNumber}`,
data: {
versionId: params.versionId,
shotCode: params.shotCode,
frameNumber: params.frameNumber,
},
});
}
/**
* Notify relevant users of an approval status change.
*/
@@ -250,3 +227,97 @@ export async function notifyCommentReply(params: {
},
});
}
/** 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);
}
+36
View File
@@ -0,0 +1,36 @@
// The order shots appear in on the project page's Shots tab. Shared with the
// shot detail page so its previous/next navigation walks the same list.
type OrderableShot = {
episode?: string | null;
scene: string;
shotNumber: number;
shotGroup?: { name: string } | null;
};
export function sortShotsForList<T extends OrderableShot>(shots: T[], projectType: string): T[] {
// Episodic: episode → scene → shotNumber
if (projectType === "EPISODIC") {
return [...shots].sort((a, b) => {
const ea = a.episode ?? "";
const eb = b.episode ?? "";
if (ea !== eb) return ea.localeCompare(eb, undefined, { numeric: true });
if (a.scene !== b.scene) return a.scene.localeCompare(b.scene, undefined, { numeric: true });
return a.shotNumber - b.shotNumber;
});
}
// Standard with custom groups: group → scene → shotNumber, ungrouped last
if (projectType === "STANDARD" && shots.some((s) => s.shotGroup)) {
return [...shots].sort((a, b) => {
const ga = a.shotGroup?.name ?? "￿";
const gb = b.shotGroup?.name ?? "￿";
if (ga !== gb) return ga.localeCompare(gb, undefined, { numeric: true });
if (a.scene !== b.scene) return a.scene.localeCompare(b.scene, undefined, { numeric: true });
return a.shotNumber - b.shotNumber;
});
}
// Otherwise the list keeps the query's createdAt order
return shots;
}
@@ -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"
);
+20
View File
@@ -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