feat(client-portal): show shots and task titles in the client's own naming
Deploy / deploy (push) Successful in 6m8s
Deploy / deploy (push) Successful in 6m8s
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>
This commit is contained in:
@@ -2,6 +2,7 @@ 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(
|
||||
@@ -44,6 +45,7 @@ export async function GET(
|
||||
select: {
|
||||
id: true,
|
||||
shotCode: true,
|
||||
clientCode: true,
|
||||
episode: true,
|
||||
sequence: true,
|
||||
description: true,
|
||||
@@ -117,9 +119,19 @@ export async function GET(
|
||||
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.
|
||||
shots: shots.map(({ generatedThumbnailUrl, ...shot }) => ({
|
||||
// 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,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { db } from "@/lib/db";
|
||||
import { validateReviewToken } from "@/lib/review-auth";
|
||||
import { clientShotCode, clientTaskTitle } from "@/lib/shot-code";
|
||||
|
||||
/** GET /api/client/[token]/versions/[versionId] — returns version + comments for client portal */
|
||||
export async function GET(
|
||||
@@ -40,7 +41,7 @@ export async function GET(
|
||||
task: {
|
||||
include: {
|
||||
project: { select: { id: true, name: true, code: true } },
|
||||
shot: { select: { id: true, shotCode: true, highResKey: true, highResFilename: true } },
|
||||
shot: { select: { id: true, shotCode: true, clientCode: true, highResKey: true, highResFilename: true } },
|
||||
asset: { select: { assetCode: true, name: true } },
|
||||
},
|
||||
},
|
||||
@@ -81,6 +82,9 @@ export async function GET(
|
||||
// Resolve the shot that has the high-res file (comes from version.shot OR version.task.shot)
|
||||
const highResShot = version.shot ?? (version.task as any)?.shot ?? null;
|
||||
|
||||
// The shot the title and breadcrumb are drawn from — either relation.
|
||||
const namingShot = version.shot ?? ((version.task as any)?.shot ?? null);
|
||||
|
||||
const serializedVersion = {
|
||||
...version,
|
||||
fileSize: version.fileSize?.toString() ?? null,
|
||||
@@ -92,17 +96,24 @@ export async function GET(
|
||||
shot: version.shot
|
||||
? {
|
||||
...version.shot,
|
||||
shotCode: clientShotCode(version.shot),
|
||||
clientCode: undefined,
|
||||
hasHighRes: !!version.shot.highResKey,
|
||||
highResFilename: version.shot.highResFilename ?? null,
|
||||
highResKey: undefined,
|
||||
}
|
||||
: null,
|
||||
// Task titles carry the shot code by convention, so the player header gets
|
||||
// the same swap as the shot list (lib/shot-code.ts).
|
||||
task: version.task
|
||||
? {
|
||||
...version.task,
|
||||
title: namingShot ? clientTaskTitle(version.task.title, namingShot) : version.task.title,
|
||||
shot: (version.task as any)?.shot
|
||||
? {
|
||||
...((version.task as any).shot),
|
||||
shotCode: clientShotCode((version.task as any).shot),
|
||||
clientCode: undefined,
|
||||
highResKey: undefined, // strip storage key
|
||||
}
|
||||
: null,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { db } from "@/lib/db";
|
||||
import { PipelineError } from "./errors";
|
||||
import { applyTransition } from "./transitions";
|
||||
import { getConfigNumber, getConfigValue } from "./config";
|
||||
import { clientShotCode } from "@/lib/shot-code";
|
||||
|
||||
/**
|
||||
* Delivery builder (RenderPipeline2 §11). The web app decides *what* goes in a
|
||||
@@ -429,7 +430,7 @@ export async function buildDeliveryJobManifest(
|
||||
// naming follows the same rule as the delivered filenames themselves.
|
||||
shotDir: applyTokens(snapshot.layout.shotDir, {
|
||||
...baseTokens,
|
||||
shotCode: item.clientCode || item.shotCode,
|
||||
shotCode: clientShotCode(item),
|
||||
versionString: item.versionString,
|
||||
}),
|
||||
deliveredBaseName: item.clientCode
|
||||
|
||||
@@ -4,6 +4,7 @@ import { PipelineError } from "./errors";
|
||||
import { applyTransition } from "./transitions";
|
||||
import { getConfigNumber, getPreviewConfig } from "./config";
|
||||
import { resolveProjectTemplates, autoShotThumbnailEnabled } from "./project-templates";
|
||||
import { clientShotCode } from "@/lib/shot-code";
|
||||
|
||||
/** Slate date format used by the panel's getDateString (YYYY/MM/DD). */
|
||||
function slateDate(d = new Date()): string {
|
||||
@@ -124,7 +125,7 @@ export async function buildPreviewManifest(exportRow: ExportForPreview): Promise
|
||||
// tab shows as its placeholder.
|
||||
description: shot.slateDescription ?? shot.description,
|
||||
notes: shot.notes,
|
||||
shotCode: shot.clientCode || shot.shotCode,
|
||||
shotCode: clientShotCode(shot),
|
||||
// The production's own name, not its code: this is read off a slate.
|
||||
show: shot.project?.name ?? shot.project?.code ?? null,
|
||||
episode: shot.episode,
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { clientShotCode, clientTaskTitle } from "./shot-code";
|
||||
|
||||
describe("clientShotCode", () => {
|
||||
it("prefers the client's alias", () => {
|
||||
expect(clientShotCode({ shotCode: "UNG_106_010_020", clientCode: "EP6_VFX_012" })).toBe("EP6_VFX_012");
|
||||
});
|
||||
|
||||
it("falls back to our code when there is no alias", () => {
|
||||
expect(clientShotCode({ shotCode: "UNG_106_010_020", clientCode: null })).toBe("UNG_106_010_020");
|
||||
expect(clientShotCode({ shotCode: "UNG_106_010_020" })).toBe("UNG_106_010_020");
|
||||
});
|
||||
|
||||
it("ignores a whitespace-only alias", () => {
|
||||
expect(clientShotCode({ shotCode: "UNG_106_010_020", clientCode: " " })).toBe("UNG_106_010_020");
|
||||
});
|
||||
});
|
||||
|
||||
describe("clientTaskTitle", () => {
|
||||
const shot = { shotCode: "UNG_106_010_020", clientCode: "EP6_VFX_012" };
|
||||
|
||||
it("swaps the internal code out of a house-convention title", () => {
|
||||
expect(clientTaskTitle("UNG_106_010_020_cmp_TT_v004", shot)).toBe("EP6_VFX_012_cmp_TT_v004");
|
||||
});
|
||||
|
||||
it("leaves the title alone when there is no alias", () => {
|
||||
const t = "UNG_106_010_020_cmp_TT_v004";
|
||||
expect(clientTaskTitle(t, { shotCode: "UNG_106_010_020", clientCode: null })).toBe(t);
|
||||
});
|
||||
|
||||
it("leaves a title that does not carry the code untouched", () => {
|
||||
// Hand-edited titles are not guessed at — better an unhelpful title than a
|
||||
// mangled one.
|
||||
expect(clientTaskTitle("Roto pass", shot)).toBe("Roto pass");
|
||||
});
|
||||
|
||||
it("swaps every occurrence", () => {
|
||||
expect(clientTaskTitle("UNG_106_010_020 / UNG_106_010_020_cmp", shot)).toBe("EP6_VFX_012 / EP6_VFX_012_cmp");
|
||||
});
|
||||
|
||||
it("is a no-op when the alias equals the code", () => {
|
||||
const same = { shotCode: "UNG_106_010_020", clientCode: "UNG_106_010_020" };
|
||||
expect(clientTaskTitle("UNG_106_010_020_cmp", same)).toBe("UNG_106_010_020_cmp");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* What a client is shown for a shot.
|
||||
*
|
||||
* `Shot.clientCode` is the naming alias some productions supply — their own
|
||||
* code for the same shot. It already drives slates, overlay burn-ins and
|
||||
* delivered file names (RenderPipeline2 §11.2); internal paths, comp names and
|
||||
* every DB lookup stay on `shotCode`.
|
||||
*
|
||||
* The client portal is the other place the client's own naming belongs, and
|
||||
* that includes task titles: they follow the house convention
|
||||
* `UNG_102_019_010_cmp_TT_v004`, so the internal code is embedded in the
|
||||
* string the client reads and copies.
|
||||
*
|
||||
* Apply these at the API boundary rather than at each render site, so nothing
|
||||
* downstream has to remember which code it is holding.
|
||||
*/
|
||||
|
||||
export interface ShotCodeFields {
|
||||
shotCode: string;
|
||||
clientCode?: string | null;
|
||||
}
|
||||
|
||||
/** The shot's code as the client knows it; their alias if set, ours otherwise. */
|
||||
export function clientShotCode(shot: ShotCodeFields): string {
|
||||
return shot.clientCode?.trim() || shot.shotCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* A task title with the internal shot code swapped for the client's alias.
|
||||
*
|
||||
* Titles are built from the shot code by convention, so this is a substring
|
||||
* swap rather than a rename — a title that does not contain the code (a
|
||||
* hand-edited one, say) is returned untouched rather than guessed at.
|
||||
*/
|
||||
export function clientTaskTitle(title: string, shot: ShotCodeFields): string {
|
||||
const alias = shot.clientCode?.trim();
|
||||
if (!alias || !title || alias === shot.shotCode) return title;
|
||||
// split/join rather than a regex: shot codes are user-supplied and would
|
||||
// otherwise need escaping.
|
||||
return title.split(shot.shotCode).join(alias);
|
||||
}
|
||||
Reference in New Issue
Block a user