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
23 changed files with 962 additions and 1375 deletions
+1 -1
View File
@@ -23,7 +23,7 @@ Dockerfile*
.github
migrations
README.md
uploads
uploads*
.git
*.tar
*.sql
-1
View File
@@ -542,5 +542,4 @@ For reference, the endpoints called by `VFXReviewConnector.jsx`:
| Retry Export | `POST /api/ext/exports/{id}/retry` (E14) |
| Regenerate Previews | `POST /api/ext/exports/{id}/regenerate-preview` |
| Load QC Queue | `GET /api/ext/qc/queue?projectCode={PROJECT_CODE}` (E5) |
| Build QC Review Session | `GET /api/ext/qc/queue?projectCode={PROJECT_CODE}` (E5): uses `scene`, `episode`, `seqTimecodeStart`, `handleHead`, `handleTail` |
| QC Pass / QC Fail | `POST /api/ext/exports/{id}/qc` (E4) |
+15 -1201
View File
File diff suppressed because it is too large Load Diff
-85
View File
@@ -245,7 +245,6 @@ shot record, and submits it to the farm.
|---|---|
| **Urgent** | Priority 20 — claimable outside the render window, ahead of normal jobs |
| **No preview** | Renders the EXRs but builds no preview, inheriting the previous export's MOV, review MP4 and thumbnail |
| **Keep AEP** | Saves a reduced copy of the project in the export's version folder (see below) |
**No preview** exists for deliverables framed differently from the review —
unscaled or un-punched-in EXRs. A preview rebuilt from those frames would be
@@ -256,37 +255,6 @@ package still gets a MOV.
Leave it unticked for an ordinary submission: the preview is what QC and the
client review see.
#### Export AEP (Keep AEP)
With **Keep AEP** ticked (the default, remembered per machine), every export
keeps a copy of the project it was queued from, so the exact comp behind a
render can be reopened later:
```
{version folder}/{shotCode}_cmp_TT_{version}.aep e.g. .../UNG_102_019_010/v005/UNG_102_019_010_cmp_TT_v005.aep
```
- The copy is **reduced** to the exported comp and everything it uses (nested
comps, footage, solids). Everything else in the project is dropped.
- Nothing is collected. Plates and EXRs stay where they are and the copy links
to them by path, so it stays a few MB.
- It is saved after the export is submitted, into the same version folder the
farm renders into. The worker only reads and clears `*.exr` files there, so
the `.aep` is never touched. Re-queuing the same version overwrites it, just
as the render overwrites the EXRs.
- AE can only reduce the open project, so the panel does Save As, Reduce Project,
Save, and then **reopens your project**. Your project was saved first and is
never changed, but the reopen clears undo. Untick **Keep AEP** to skip it.
- The copy is the project as it was when you queued. The farm still renders
your working AEP, so a save made before the job starts goes into the render
but not into the copy.
- Reduce Project keeps only comps that are used as layers. An expression that
points at a comp with `comp("Name")`, where that comp isn't nested anywhere,
will break in the copy.
If the copy can't be saved, the export is still queued and the status line says
`AEP not saved: …` with the reason.
---
## QC
@@ -299,63 +267,10 @@ QC is per-export: a new export always needs new QC.
|---|---|
| **Load QC Queue** | Lists every export in `READY_FOR_QC` for the selected project, oldest first. Entries flagged with a warning count had a non-blocking validation warning worth a closer look. |
| **Open Render** (or double-click) | Imports that export's actual EXR sequence into a `QC_{shot}_{version}` comp at full res and opens it. Re-selecting an already-imported shot just reopens the comp. |
| **Compare Versions** | Opens the QC comp and stacks earlier versions of the shot under the export, one layer each (`v003`, `v002`…), lined up by EXR timecode (or frame number). Solo a layer to see that version, or set the top layer to **Difference** to see exactly what changed. With more than one earlier version, a dialog lets you untick the ones you don't need. Versions already in the comp are skipped. |
| **Build QC Review Session** | Lays out the whole project's QC pass in one go. Every shot in the queue gets its `QC_{shot}_{version}` comp, and each scene gets an edit and a contact sheet. See *QC review session* below. |
| **Notes** | Sent with the decision. Optional on pass; a fail with no notes asks for confirmation, because the artist only sees what you write here. |
| **QC Pass** | Marks the version **ready for delivery** and nothing else — nothing is shared to the client, no approval state changes, no task moves. |
| **QC Fail** | Sets the export to `QC_FAILED` and the comp task to **CHANGES**. The artist fixes and queues a new version; the old export is superseded. |
**Where Compare Versions looks:** next to the export's own render folder (resolved
as for Open Render), in both on-disk layouts. That means farm version folders
(`{shot}/v002/`, `{shot}/v003/`…) and the older flat folder with
`{shot}_cmp_TT_{ver}.#####.exr` side by side. Only versions numbered below the
export's are used. If earlier exports were written under a different root (see
*More than one on-disk convention*), they won't be found.
### QC review session
**Build QC Review Session** loads the queue for the selected project. A dialog
lists its scenes. Ctrl-click a scene to leave it out, or untick *Also import
earlier versions*. Everything is built in one undo step, under
`QC_SESSION_{project}` in the Project panel:
| Item | What it is |
|---|---|
| `{scene}/shots/QC_{shot}_{version}` | One comp per shot, reusing the comp from Open Render if there is one. The export sits on top, earlier versions under it (`v003`, `v002`…), and the shot's plates at the bottom, found as Build Shot finds them. **Only the export and the main plate (the longest) are switched on.** |
| `{scene}/QC_EDIT_{scene}` | The scene's shot comps as one continuous edit. Each shot is cut to the edit, with the handles the render carries trimmed off, and has a layer marker with its name. |
| `{scene}/QC_CONTACT_{scene}` | Every shot of the scene in a grid, each starting at its cut in, with its name under it. |
| `_footage` | The EXR sequences it imported. Plates already in the project are reused, not imported again. |
A scene is the shot record's scene, which is not always the scene in the shot
code. Episodic shows group by episode too (`UNG_109_SC013`), and standard ones
by show (`DH2_VFX_SC033`). Shots run in sequence-timecode order when every shot
in the scene has one, and in shot-code order otherwise. Only the newest queued
version of a shot gets a comp; older queued versions appear as earlier versions
in it.
Plates and earlier versions are lined up with the export by EXR timecode. A
timecode more than a shot's length away is ignored, because a shot's elements
can carry unrelated camera timecodes. The file frame number is used next, then
the export's first frame. Exports from before handles were recorded have no
handle count, so they play in full in the edit, and the status line counts them.
Running the button again adds anything new to the shot comps and rebuilds the
scene edits and contact sheets from scratch. Don't do your own work in those
two comps.
**Approving from the session.** QC Pass and QC Fail act on the shot in front of
you, without finding it in the list:
- the `QC_{shot}_{version}` comp in the viewer, or
- the shot layer (or its label) selected in a scene edit or contact sheet, or
- with nothing selected in a scene edit, the shot under the playhead.
The confirmation names the shot, and a fail with notes is confirmed too when it
came from the viewer. If a scene comp is active and no shot is picked, nothing
is sent. Pick a shot, or switch to another comp to use the list selection. After
a decision, that shot's comp and its layers in the scene comps are labelled
**green** (pass) or **red** (fail).
Passing an export is what makes it eligible for a delivery package on the web
app's **Pipeline → Deliveries** page.
+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);
}
+1 -6
View File
@@ -147,7 +147,7 @@ export async function getQCQueue(filter: QCQueueFilter) {
orderBy: { statusChangedAt: "asc" }, // oldest first — nothing should sit in QC
take: limit,
include: {
shot: { select: { id: true, shotCode: true, episode: true, scene: true, seqTimecodeStart: true, project: { select: { code: true, name: true } } } },
shot: { select: { id: true, shotCode: true, episode: true, scene: true, project: { select: { code: true, name: true } } } },
validations: { where: { status: { in: ["FAIL", "WARN"] } }, select: { checkName: true, status: true, message: true } },
},
});
@@ -167,11 +167,6 @@ export async function getQCQueue(filter: QCQueueFilter) {
frameStart: e.frameStart,
frameEnd: e.frameEnd,
fps: e.fps,
// The panel's QC session cuts each shot into a scene edit: in edit order,
// with the handles the render carries trimmed off. Null on old exports.
seqTimecodeStart: e.shot.seqTimecodeStart,
handleHead: e.handleHead,
handleTail: e.handleTail,
deliveryMovPath: e.deliveryMovPath,
thumbnailUrl: e.thumbnailKey ? `/api/files/${e.thumbnailKey}` : null,
previewUrl: e.previewMovKey ? `/api/files/${e.previewMovKey}` : null,
+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