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(), }); // ── 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 } ); } // 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 }, }); const shotNumber = (maxShot?.shotNumber ?? 0) + 10; const paddedNumber = shotNumber.toString().padStart(4, "0"); const 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 }); } }