Files
vfxreview/app/api/ext/shots/route.ts
T
twotalesanimation 15046892d1
Deploy / deploy (push) Successful in 2m33s
api update
2026-05-30 10:19:35 +02:00

297 lines
9.7 KiB
TypeScript

import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db";
import { uploadFile } from "@/lib/storage";
import { ShotPriority } from "@prisma/client";
import { z } from "zod";
// ── Auth ─────────────────────────────────────────────────────────────────────
function isAuthorized(req: NextRequest): boolean {
const apiKey = process.env.API_SECRET_KEY;
if (!apiKey) return false; // key not configured → deny all
const authHeader = req.headers.get("authorization") ?? "";
if (authHeader.startsWith("Bearer ")) {
return authHeader.slice(7) === apiKey;
}
// Also accept X-API-Key header
const headerKey = req.headers.get("x-api-key") ?? "";
return headerKey === apiKey;
}
// ── Validation ────────────────────────────────────────────────────────────────
const createShotSchema = z.object({
projectId: z.string().cuid(),
scene: z
.string()
.min(1)
.max(50)
.regex(/^[A-Z0-9_]+$/i, "Alphanumeric and underscore only"),
episode: z.string().max(50).optional(),
description: z.string().optional(),
artistId: z.string().cuid().optional(),
priority: z.nativeEnum(ShotPriority).default("NORMAL"),
fps: z.coerce.number().default(24),
frameStart: z.coerce.number().int().optional(),
frameEnd: z.coerce.number().int().optional(),
dueDate: z.string().optional(),
thumbnailUrl: z.string().url().optional(),
shotGroupName: z.string().max(100).optional(),
// Optional explicit shot code — bypasses auto-generation
shotCode: z.string().max(100).optional(),
});
// ── POST /api/ext/shots ───────────────────────────────────────────────────────
export async function POST(req: NextRequest) {
if (!isAuthorized(req)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
let fields: Record<string, string> = {};
let thumbnailFile: File | null = null;
const contentType = req.headers.get("content-type") ?? "";
if (contentType.includes("multipart/form-data")) {
const formData = await req.formData();
for (const [key, value] of formData.entries()) {
if (key === "thumbnail" && value instanceof File) {
thumbnailFile = value;
} else if (typeof value === "string") {
fields[key] = value;
}
}
} else {
// application/json
fields = await req.json();
}
// Parse and validate fields
const parsed = createShotSchema.parse({
projectId: fields.projectId,
scene: fields.scene,
episode: fields.episode || undefined,
description: fields.description || undefined,
artistId: fields.artistId || undefined,
priority: fields.priority || undefined,
fps: fields.fps || undefined,
frameStart: fields.frameStart || undefined,
frameEnd: fields.frameEnd || undefined,
dueDate: fields.dueDate || undefined,
thumbnailUrl: fields.thumbnailUrl || undefined,
shotGroupName: fields.shotGroupName || undefined,
});
const scene = parsed.scene.toUpperCase();
const episode = parsed.episode?.toUpperCase() ?? null;
// Fetch project for showId and projectType
const project = await db.project.findUnique({
where: { id: parsed.projectId },
select: { showId: true, projectType: true },
});
if (!project) {
return NextResponse.json({ error: "Project not found" }, { status: 404 });
}
if (!project.showId) {
return NextResponse.json(
{ error: "Project has no Show ID set. Please edit the project to add one." },
{ status: 422 }
);
}
// Episodic projects require episode
if (project.projectType === "EPISODIC" && !episode) {
return NextResponse.json(
{ error: "Episode is required for episodic projects." },
{ status: 422 }
);
}
// Use explicit shotCode if provided, otherwise auto-increment
let shotCode: string;
let shotNumber: number;
if (parsed.shotCode) {
// Check uniqueness manually since we bypass auto-generation
const existing = await db.shot.findUnique({
where: { projectId_shotCode: { projectId: parsed.projectId, shotCode: parsed.shotCode } },
select: { id: true },
});
if (existing) {
return NextResponse.json(
{ error: "A shot with that code already exists in this project", shotCode: parsed.shotCode },
{ status: 409 }
);
}
shotCode = parsed.shotCode;
shotNumber = 0;
} else {
// Auto-increment shot number within scene (+ episode for episodic)
const scopeWhere = {
projectId: parsed.projectId,
scene,
...(project.projectType === "EPISODIC" ? { episode } : {}),
};
const maxShot = await db.shot.findFirst({
where: scopeWhere,
orderBy: { shotNumber: "desc" },
select: { shotNumber: true },
});
shotNumber = (maxShot?.shotNumber ?? 0) + 10;
const paddedNumber = shotNumber.toString().padStart(4, "0");
shotCode =
project.projectType === "EPISODIC" && episode
? `${project.showId}_${episode}_${scene}_${paddedNumber}`
: `${project.showId}_${scene}_${paddedNumber}`;
}
// Upload thumbnail if provided as a file
let thumbnailUrl = parsed.thumbnailUrl;
if (thumbnailFile) {
if (!thumbnailFile.type.startsWith("image/")) {
return NextResponse.json(
{ error: "Thumbnail must be an image file" },
{ status: 400 }
);
}
const maxSize = 50 * 1024 * 1024; // 50 MB
if (thumbnailFile.size > maxSize) {
return NextResponse.json(
{ error: "Thumbnail too large (max 50 MB)" },
{ status: 413 }
);
}
const buffer = Buffer.from(await thumbnailFile.arrayBuffer());
const result = await uploadFile(buffer, thumbnailFile.name, thumbnailFile.type, "image");
thumbnailUrl = result.url;
}
// Resolve shot group
let shotGroupId: string | undefined;
if (parsed.shotGroupName?.trim()) {
const group = await db.shotGroup.upsert({
where: {
projectId_name: {
projectId: parsed.projectId,
name: parsed.shotGroupName.trim(),
},
},
create: { projectId: parsed.projectId, name: parsed.shotGroupName.trim() },
update: {},
});
shotGroupId = group.id;
}
const shot = await db.shot.create({
data: {
shotCode,
scene,
episode,
shotNumber,
description: parsed.description,
projectId: parsed.projectId,
artistId: parsed.artistId || undefined,
priority: parsed.priority,
fps: parsed.fps,
frameStart: parsed.frameStart,
frameEnd: parsed.frameEnd,
dueDate: parsed.dueDate ? new Date(parsed.dueDate) : undefined,
thumbnailUrl,
shotGroupId,
},
select: {
id: true,
shotCode: true,
scene: true,
episode: true,
shotNumber: true,
description: true,
status: true,
priority: true,
fps: true,
frameStart: true,
frameEnd: true,
dueDate: true,
thumbnailUrl: true,
projectId: true,
artistId: true,
shotGroupId: true,
createdAt: true,
},
});
return NextResponse.json({ shot }, { status: 201 });
} catch (err) {
if (err instanceof z.ZodError) {
return NextResponse.json({ error: "Validation error", details: err.errors }, { status: 422 });
}
console.error("[POST /api/ext/shots]", err);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}
// ── GET /api/ext/shots?projectId=...&episode=...&status=...&shotCode=... ──────
//
// Query params:
// projectId (required) CUID of the project
// episode (optional) filter by episode, e.g. 103
// status (optional) filter by ShotStatus, e.g. COMPLETE
// shotCode (optional) return a single shot by exact code
export async function GET(req: NextRequest) {
if (!isAuthorized(req)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { searchParams } = new URL(req.url);
const projectId = searchParams.get("projectId");
if (!projectId) {
return NextResponse.json({ error: "projectId is required" }, { status: 400 });
}
const episode = searchParams.get("episode") ?? undefined;
const status = searchParams.get("status") ?? undefined;
const shotCode = searchParams.get("shotCode") ?? undefined;
const shots = await db.shot.findMany({
where: {
projectId,
...(episode ? { episode } : {}),
...(status ? { status: status as any } : {}),
...(shotCode ? { shotCode } : {}),
},
orderBy: [{ episode: "asc" }, { scene: "asc" }, { shotNumber: "asc" }],
select: {
id: true,
shotCode: true,
scene: true,
episode: true,
shotNumber: true,
description: true,
status: true,
priority: true,
frameStart: true,
frameEnd: true,
fps: true,
dueDate: true,
createdAt: true,
updatedAt: true,
artist: {
select: { id: true, name: true, email: true },
},
_count: {
select: { versions: true, tasks: true },
},
},
});
return NextResponse.json({ shots, total: shots.length });
}