Files
vfxreview/app/api/client/[token]/project/route.ts
T
twotalesanimation ed9f9b13c1
Deploy / deploy (push) Successful in 6m8s
feat(client-portal): show shots and task titles in the client's own naming
Shot.clientCode already drove slates, overlay burn-ins and delivered file
names, but the client portal — the one place the client actually reads — was
still showing our internal codes.

Task titles were the larger leak. They follow the house convention
"UNG_106_010_020_cmp_TT_v004", so the internal code sat embedded in the
string the client reads and copies, in three places on the shot list and
again in the player header. Swapping only the heading would have left it on
display directly underneath.

lib/shot-code.ts resolves both: clientShotCode picks the alias when set, and
clientTaskTitle swaps the code out of a title by substring, leaving a
hand-edited title that does not carry the code untouched rather than guessing
at it.

Applied at the API boundary rather than at each render site, so the shot
list, search, playlist labels, alt text, player header and every
copy-to-clipboard button inherit it and nothing downstream has to remember
which code it is holding. The internal code is no longer sent to the browser
on these routes at all.

Slack notifications deliberately keep the internal code: those go to our
artists, who need the code that matches the comp and the SAN path.

The two existing "clientCode || shotCode" expressions in preview.ts and
deliveries.ts now use the same helper so slates, deliveries and the portal
cannot drift apart. Shots with no clientCode are unchanged throughout.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 23:16:53 +02:00

141 lines
4.3 KiB
TypeScript

import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db";
import { validateReviewToken } from "@/lib/review-auth";
import { shotThumbnail } from "@/lib/shot-thumbnail";
import { clientShotCode, clientTaskTitle } from "@/lib/shot-code";
/** GET /api/client/[token]/project — returns project + shots with tasks that have client-visible versions */
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ token: string }> }
) {
const { token } = await params;
const result = await validateReviewToken(token, req);
if (result.type === "requiresPassword") {
return NextResponse.json({ requiresPassword: true }, { status: 401 });
}
if (result.type === "invalid") {
return NextResponse.json({ error: "Invalid or expired review link" }, { status: 403 });
}
const session = result.session;
const project = await db.project.findUnique({
where: { id: session.projectId },
select: { id: true, name: true, code: true, description: true, status: true },
});
if (!project) {
return NextResponse.json({ error: "Project not found" }, { status: 404 });
}
// Only return shots that have been explicitly shared with the client.
// If the session has episode restrictions, further filter to those episodes.
const episodeFilter =
session.allowedEpisodes && session.allowedEpisodes.length > 0
? session.allowedEpisodes
: undefined;
const shots = await db.shot.findMany({
where: {
projectId: session.projectId,
sharedWithClient: true,
...(episodeFilter ? { episode: { in: episodeFilter } } : {}),
},
orderBy: [{ episode: "asc" }, { sequence: "asc" }, { shotCode: "asc" }],
select: {
id: true,
shotCode: true,
clientCode: true,
episode: true,
sequence: true,
description: true,
status: true,
shotApprovalStatus: true,
thumbnailUrl: true,
generatedThumbnailUrl: true,
tasks: {
where: {
versions: { some: { isClientVisible: true } },
},
select: {
id: true,
title: true,
type: true,
status: true,
versions: {
where: { isClientVisible: true, isLatest: true },
take: 1,
select: {
id: true,
versionNumber: true,
approvalStatus: true,
fps: true,
duration: true,
thumbnailUrl: true,
createdAt: true,
},
},
},
},
},
});
// Asset tasks with client-visible versions (no shotId)
const assetTasks = await db.task.findMany({
where: {
projectId: session.projectId,
shotId: null,
versions: { some: { isClientVisible: true } },
},
select: {
id: true,
title: true,
type: true,
status: true,
asset: { select: { id: true, assetCode: true, name: true } },
versions: {
where: { isClientVisible: true, isLatest: true },
take: 1,
select: {
id: true,
versionNumber: true,
approvalStatus: true,
fps: true,
duration: true,
thumbnailUrl: true,
createdAt: true,
},
},
},
});
// Increment access count
await db.reviewSession.update({
where: { id: session.id },
data: { accessCount: { increment: 1 } },
});
return NextResponse.json({
project,
// Shots the client sees resolve the same way as internally: the reference
// still first, a render-derived one only where the show supplied none.
// Everything the client reads is in their own naming: the alias replaces
// our shot code, and the same swap runs over task titles, which carry the
// code by convention ("UNG_106_010_020_cmp_TT_v004"). The internal code is
// not sent at all — there is nothing here for it to be useful for, and
// leaking it invites the client to quote it back at us.
shots: shots.map(({ generatedThumbnailUrl, clientCode, ...shot }) => ({
...shot,
shotCode: clientShotCode({ ...shot, clientCode }),
thumbnailUrl: shotThumbnail({ ...shot, generatedThumbnailUrl }),
tasks: shot.tasks.map((task) => ({
...task,
title: clientTaskTitle(task.title, { ...shot, clientCode }),
})),
})),
assetTasks,
sessionLabel: session.label,
});
}