From 1bdb147d24c478f6d71a948a2d80f50c393f3bb3 Mon Sep 17 00:00:00 2001 From: twotalesanimation <80506065+twotalesanimation@users.noreply.github.com> Date: Fri, 12 Jun 2026 19:20:58 +0200 Subject: [PATCH] API UPdate --- .../projects/[projectCode]/episodes/route.ts | 103 ++++++++++++++++ .../ext/projects/[projectCode]/shots/route.ts | 110 ++++++++++++++++++ app/api/ext/shots/[shotId]/route.ts | 11 +- app/api/ext/shots/lookup/route.ts | 104 +++++++++++++++++ 4 files changed, 325 insertions(+), 3 deletions(-) create mode 100644 app/api/ext/projects/[projectCode]/episodes/route.ts create mode 100644 app/api/ext/projects/[projectCode]/shots/route.ts create mode 100644 app/api/ext/shots/lookup/route.ts diff --git a/app/api/ext/projects/[projectCode]/episodes/route.ts b/app/api/ext/projects/[projectCode]/episodes/route.ts new file mode 100644 index 0000000..9ae4afd --- /dev/null +++ b/app/api/ext/projects/[projectCode]/episodes/route.ts @@ -0,0 +1,103 @@ +import { NextRequest, NextResponse } from "next/server"; +import { db } from "@/lib/db"; + +// ── Auth ───────────────────────────────────────────────────────────────────── + +function isAuthorized(req: NextRequest): boolean { + const apiKey = process.env.API_SECRET_KEY; + if (!apiKey) return false; + const authHeader = req.headers.get("authorization") ?? ""; + if (authHeader.startsWith("Bearer ")) return authHeader.slice(7) === apiKey; + return (req.headers.get("x-api-key") ?? "") === apiKey; +} + +// ── GET /api/ext/projects/[projectCode]/episodes ────────────────────────────── +// +// Returns the list of distinct episodes in a project along with their shot +// codes, sequences, and EDL data. Useful for populating episode selectors in +// pipeline tools. +// +// Query params (optional): +// shotNames Pass "1" to include the full shot code list per episode (default off) +// +// Example: +// GET /api/ext/projects/UNG_108/episodes +// GET /api/ext/projects/UNG_108/episodes?shotNames=1 + +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ projectCode: string }> } +) { + if (!isAuthorized(req)) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { projectCode } = await params; + const { searchParams } = new URL(req.url); + const includeShotNames = searchParams.get("shotNames") === "1"; + + const project = await db.project.findUnique({ + where: { code: projectCode }, + select: { id: true, name: true, code: true, showId: true }, + }); + + if (!project) { + return NextResponse.json({ error: "Project not found" }, { status: 404 }); + } + + // Fetch all shots grouped by episode + const shots = await db.shot.findMany({ + where: { projectId: project.id }, + orderBy: [{ episode: "asc" }, { shotCode: "asc" }], + select: { + id: true, + shotCode: true, + episode: true, + sequence: true, + status: true, + exrOutput: true, + sourceClip: true, + timecodeStart: true, + timecodeEnd: true, + clipDuration: true, + }, + }); + + // Group by episode + const episodeMap = new Map< + string, + { + episode: string; + shotCount: number; + sequences: string[]; + shots?: typeof shots; + } + >(); + + for (const shot of shots) { + const ep = shot.episode ?? "(none)"; + if (!episodeMap.has(ep)) { + episodeMap.set(ep, { episode: ep, shotCount: 0, sequences: [], shots: [] }); + } + const entry = episodeMap.get(ep)!; + entry.shotCount++; + + const seq = shot.sequence ?? ""; + if (seq && !entry.sequences.includes(seq)) { + entry.sequences.push(seq); + } + if (includeShotNames) { + entry.shots!.push(shot); + } + } + + const episodes = Array.from(episodeMap.values()).map((e) => { + if (!includeShotNames) { + const { shots: _omit, ...rest } = e; + return rest; + } + return e; + }); + + return NextResponse.json({ project, episodes }); +} diff --git a/app/api/ext/projects/[projectCode]/shots/route.ts b/app/api/ext/projects/[projectCode]/shots/route.ts new file mode 100644 index 0000000..68a6bec --- /dev/null +++ b/app/api/ext/projects/[projectCode]/shots/route.ts @@ -0,0 +1,110 @@ +import { NextRequest, NextResponse } from "next/server"; +import { db } from "@/lib/db"; + +// ── Auth ───────────────────────────────────────────────────────────────────── + +function isAuthorized(req: NextRequest): boolean { + const apiKey = process.env.API_SECRET_KEY; + if (!apiKey) return false; + const authHeader = req.headers.get("authorization") ?? ""; + if (authHeader.startsWith("Bearer ")) return authHeader.slice(7) === apiKey; + return (req.headers.get("x-api-key") ?? "") === apiKey; +} + +// ── GET /api/ext/projects/[projectCode]/shots ───────────────────────────────── +// +// Returns all shots for a project, optionally filtered by episode and/or sequence. +// Uses the human-readable project code (e.g. UNG_108) not the DB id. +// +// Query params (all optional): +// episode e.g. 108 +// sequence e.g. 004 +// status e.g. IN_PROGRESS +// page (default 1) +// limit (default 200, max 500) +// +// Example: +// GET /api/ext/projects/UNG_108/shots?episode=108&sequence=004 +// GET /api/ext/projects/UNG_108/shots + +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ projectCode: string }> } +) { + if (!isAuthorized(req)) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { projectCode } = await params; + const { searchParams } = new URL(req.url); + + const episode = searchParams.get("episode")?.trim() || undefined; + const sequence = searchParams.get("sequence")?.trim() || undefined; + const status = searchParams.get("status")?.trim() || undefined; + const page = Math.max(1, parseInt(searchParams.get("page") ?? "1", 10)); + const limit = Math.min(500, Math.max(1, parseInt(searchParams.get("limit") ?? "200", 10))); + const skip = (page - 1) * limit; + + const project = await db.project.findUnique({ + where: { code: projectCode }, + select: { id: true, name: true, code: true, showId: true }, + }); + + if (!project) { + return NextResponse.json({ error: "Project not found" }, { status: 404 }); + } + + const [shots, total] = await Promise.all([ + db.shot.findMany({ + where: { + projectId: project.id, + ...(episode ? { episode } : {}), + ...(sequence ? { sequence } : {}), + ...(status ? { status: status as never } : {}), + }, + orderBy: [{ episode: "asc" }, { shotCode: "asc" }], + skip, + take: limit, + select: { + id: true, + shotCode: true, + scene: true, + episode: true, + sequence: true, + shotNumber: true, + description: true, + status: true, + priority: true, + frameStart: true, + frameEnd: true, + fps: true, + dueDate: true, + // EDL / pull CSV fields + sourceClip: true, + timecodeStart: true, + timecodeEnd: true, + clipDuration: true, + exrOutput: true, + thumbnailUrl: true, + artist: { + select: { id: true, name: true, email: true }, + }, + _count: { select: { versions: true, tasks: true } }, + }, + }), + db.shot.count({ + where: { + projectId: project.id, + ...(episode ? { episode } : {}), + ...(sequence ? { sequence } : {}), + ...(status ? { status: status as never } : {}), + }, + }), + ]); + + return NextResponse.json({ + project, + pagination: { page, limit, total, pages: Math.ceil(total / limit) }, + shots, + }); +} diff --git a/app/api/ext/shots/[shotId]/route.ts b/app/api/ext/shots/[shotId]/route.ts index 0974f37..d40a455 100644 --- a/app/api/ext/shots/[shotId]/route.ts +++ b/app/api/ext/shots/[shotId]/route.ts @@ -49,9 +49,14 @@ export async function GET( frameStart: true, frameEnd: true, fps: true, - dueDate: true, - createdAt: true, - updatedAt: true, + dueDate: true, + sourceClip: true, + timecodeStart: true, + timecodeEnd: true, + clipDuration: true, + exrOutput: true, + createdAt: true, + updatedAt: true, project: { select: { id: true, name: true, code: true, showId: true }, }, diff --git a/app/api/ext/shots/lookup/route.ts b/app/api/ext/shots/lookup/route.ts new file mode 100644 index 0000000..5e8ff0a --- /dev/null +++ b/app/api/ext/shots/lookup/route.ts @@ -0,0 +1,104 @@ +import { NextRequest, NextResponse } from "next/server"; +import { db } from "@/lib/db"; + +// ── Auth ───────────────────────────────────────────────────────────────────── + +function isAuthorized(req: NextRequest): boolean { + const apiKey = process.env.API_SECRET_KEY; + if (!apiKey) return false; + const authHeader = req.headers.get("authorization") ?? ""; + if (authHeader.startsWith("Bearer ")) return authHeader.slice(7) === apiKey; + return (req.headers.get("x-api-key") ?? "") === apiKey; +} + +// ── GET /api/ext/shots/lookup ───────────────────────────────────────────────── +// +// Look up a single shot by its shot code and (optionally) project code. +// Designed for use in Nuke/After Effects/Blender pipeline tools. +// +// Query params: +// shotCode (required) e.g. UNG_108_004_010 +// projectCode (recommended) e.g. UNG_108 – disambiguates if same code appears across projects +// +// Example: +// GET /api/ext/shots/lookup?shotCode=UNG_108_004_010&projectCode=UNG_108 +// Authorization: Bearer + +export async function GET(req: NextRequest) { + if (!isAuthorized(req)) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { searchParams } = new URL(req.url); + const shotCode = searchParams.get("shotCode")?.trim(); + const projectCode = searchParams.get("projectCode")?.trim(); + + if (!shotCode) { + return NextResponse.json({ error: "shotCode query param is required" }, { status: 400 }); + } + + const shot = await db.shot.findFirst({ + where: { + shotCode, + ...(projectCode ? { project: { code: projectCode } } : {}), + }, + select: { + id: true, + shotCode: true, + scene: true, + episode: true, + sequence: true, + shotNumber: true, + description: true, + notes: true, + status: true, + priority: true, + frameStart: true, + frameEnd: true, + fps: true, + dueDate: true, + // EDL / pull CSV fields + sourceClip: true, + timecodeStart: true, + timecodeEnd: true, + clipDuration: true, + exrOutput: true, + thumbnailUrl: true, + createdAt: true, + updatedAt: true, + project: { + select: { id: true, name: true, code: true, showId: true }, + }, + artist: { + select: { id: true, name: true, email: true }, + }, + tasks: { + orderBy: { sortOrder: "asc" }, + select: { + id: true, + title: true, + type: true, + status: true, + }, + }, + versions: { + where: { isLatest: true }, + take: 1, + select: { + id: true, + versionNumber: true, + approvalStatus: true, + reviewStatus: true, + fileUrl: true, + createdAt: true, + }, + }, + }, + }); + + if (!shot) { + return NextResponse.json({ error: "Shot not found" }, { status: 404 }); + } + + return NextResponse.json({ shot }); +}