import { NextRequest, NextResponse } from "next/server"; import { db } from "@/lib/db"; import { uploadToHetzner } 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 = {}; 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 { key: thumbKey } = await uploadToHetzner(buffer, thumbnailFile.name, thumbnailFile.type, "image"); thumbnailUrl = `/api/files/${thumbKey}`; } // 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 }); }