import { prisma } from "@/lib/prisma"; import { s3 } from "@/lib/s3"; import { DeleteObjectsCommand, PutObjectCommand } from "@aws-sdk/client-s3"; import { NextResponse } from "next/server"; import { v4 as uuidv4 } from "uuid"; import { z } from "zod/v4"; const payloadSchema = z.object({ typeId: z.string().min(1).optional().nullable(), optionId: z.string().min(1).optional().nullable(), extraIds: z.array(z.string().min(1)).default([]), customerName: z.string().min(1).max(200), customerEmail: z.string().email().max(320), customerSocials: z.string().max(2000).optional().nullable(), message: z.string().min(1).max(20_000), }); function safeJsonParse(input: string) { try { return JSON.parse(input) as unknown; } catch { return null; } } export async function POST(request: Request) { try { const bucket = process.env.BUCKET_NAME; if (!bucket) { return NextResponse.json( { error: "Commission submission failed", message: "BUCKET_NAME is not set" }, { status: 500 } ); } const form = await request.formData(); const payloadRaw = form.get("payload"); if (typeof payloadRaw !== "string") { return NextResponse.json({ error: "Missing payload" }, { status: 400 }); } const parsedJson = safeJsonParse(payloadRaw); if (!parsedJson) { return NextResponse.json({ error: "Invalid payload JSON" }, { status: 400 }); } const payload = payloadSchema.safeParse(parsedJson); if (!payload.success) { return NextResponse.json( { error: "Validation error", issues: payload.error.issues }, { status: 422 } ); } const files = form.getAll("files").filter((v): v is File => v instanceof File); // Optional: enforce limits const MAX_FILES = 10; const MAX_BYTES_EACH = 10 * 1024 * 1024; // 10MB if (files.length > MAX_FILES) { return NextResponse.json({ error: "Too many files" }, { status: 413 }); } for (const f of files) { if (f.size > MAX_BYTES_EACH) { return NextResponse.json({ error: `File too large: ${f.name}` }, { status: 413 }); } } // Capture basic metadata const ipAddress = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? null; const userAgent = request.headers.get("user-agent") ?? null; // 1) Create request first (DB only — keep it fast) const created = await prisma.commissionRequest.create({ data: { status: "NEW", customerName: payload.data.customerName, customerEmail: payload.data.customerEmail, customerSocials: payload.data.customerSocials ?? null, message: payload.data.message, typeId: payload.data.typeId ?? null, optionId: payload.data.optionId ?? null, ipAddress, userAgent, extras: payload.data.extraIds?.length ? { connect: payload.data.extraIds.map((id) => ({ id })) } : undefined, }, select: { id: true, createdAt: true }, }); // 2) Upload to S3 outside any Prisma transaction const uploadedKeys: string[] = []; const fileRows: { requestId: string; fileKey: string; originalFile: string; fileType: string; fileSize: number; uploadDate: Date; }[] = []; try { for (const f of files) { const fileKey = uuidv4(); const mime = f.type || "application/octet-stream"; // Do NOT trust client mime too much; but ok for extension here const ext = mime.includes("/") ? mime.split("/")[1] : "bin"; // Note: keep your existing shape: commissions/. const originalKey = `commissions/${fileKey}.${ext}`; const arrayBuffer = await f.arrayBuffer(); const buffer = Buffer.from(arrayBuffer); await s3.send( new PutObjectCommand({ Bucket: bucket, Key: originalKey, Body: buffer, ContentType: mime, }) ); uploadedKeys.push(originalKey); fileRows.push({ requestId: created.id, fileKey: originalKey, originalFile: f.name, fileType: mime, fileSize: f.size, // Use the request creation time (matches your prior logic) uploadDate: created.createdAt, }); } } catch (uploadErr) { // Best-effort cleanup to avoid orphaned objects if (uploadedKeys.length) { try { await s3.send( new DeleteObjectsCommand({ Bucket: bucket, Delete: { Objects: uploadedKeys.map((Key) => ({ Key })), Quiet: true, }, }) ); } catch (cleanupErr) { console.error("[POST /api/v1/commissions] S3 cleanup failed", cleanupErr); } } // Keep request for audit OR delete it. Pick one: // Option A: delete it (strict consistency) await prisma.commissionRequest.delete({ where: { id: created.id } }); // Option B (alternative): keep but mark a status like FAILED_UPLOAD if you add it. // await prisma.commissionRequest.update({ where: { id: created.id }, data: { status: "FAILED_UPLOAD" } }); throw uploadErr; } // 3) Insert file rows (DB only — keep it fast) if (fileRows.length) { await prisma.commissionRequestFile.createMany({ data: fileRows, }); } return NextResponse.json({ id: created.id, createdAt: created.createdAt }, { status: 201 }); } catch (err) { console.error("[POST /api/v1/commissions] failed", err); const message = err instanceof Error ? err.message : "Unknown server error"; return NextResponse.json( { error: "Commission submission failed", message, }, { status: 500 } ); } }