Files
vfxreview/app/api/client/[token]/project/route.ts
T
twotalesanimation 27d9618995
Deploy / deploy (push) Successful in 3m31s
feat(client-portal): group shots by scene
Collapsible scene sub-headers inside each episode, in scene order with
per-scene status counts; the review queue follows the same order. Aliased
shots go out without a scene so our numbering never reaches the client.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 08:41:19 +02:00

146 lines
4.7 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,
scene: 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.
// The scene is our numbering too, and an alias does not have to share it
// (SNV's "SNV_101_0463_CP_VFX" is our scene 001), so an aliased shot goes
// out without one rather than with a number the client never used.
shots: shots.map(({ generatedThumbnailUrl, clientCode, scene, ...shot }) => ({
...shot,
shotCode: clientShotCode({ ...shot, clientCode }),
scene: clientShotCode({ ...shot, clientCode }) === shot.shotCode ? scene || null : null,
thumbnailUrl: shotThumbnail({ ...shot, generatedThumbnailUrl }),
tasks: shot.tasks.map((task) => ({
...task,
title: clientTaskTitle(task.title, { ...shot, clientCode }),
})),
})),
assetTasks,
sessionLabel: session.label,
});
}