// app/api/submit/settings/route.ts import { NextResponse } from "next/server"; import { uploadFile, createSettingsItem, bytesFromMB, dxGET, dxPATCH } from "@/lib/directus"; import { requireBearer } from "@/app/api/_lib/auth"; /** ───────────────────────────────────────────────────────────── * Accepts EITHER: * - application/json * (photo/screen can be existing file ids on the body) * - multipart/form-data with: * - payload = JSON string (same shape as JSON body) * - photo = File (required if no photo id present) ← create only * - screen = File (optional) * * Targets (collections): * - settings_fiber (+ laser_soft, repeat_all) * - settings_co2gan * - settings_co2gal * - settings_uv * * Also supports editing: * Body must include { mode: "edit", submission_id: string|number } * We PATCH via filter[submission_id][_eq] and owner = current user. * ──────────────────────────────────────────────────────────── */ export const runtime = "nodejs"; const MAX_MB = Number(process.env.FILE_MAX_MB || 25); const MAX_BYTES = bytesFromMB(MAX_MB); // simple in-memory rate limiter const BUCKET = new Map(); const WINDOW_MS = Number(process.env.RATE_LIMIT_WINDOW || 60) * 1000; const MAX_REQ = Number(process.env.RATE_LIMIT_MAX || 15); function rateLimitOk(ip: string) { const now = Date.now(); const rec = BUCKET.get(ip); if (!rec || now > rec.resetAt) { BUCKET.set(ip, { c: 1, resetAt: now + WINDOW_MS }); return true; } if (rec.c >= MAX_REQ) return false; rec.c += 1; return true; } type Target = "settings_fiber" | "settings_co2gan" | "settings_co2gal" | "settings_uv"; function num(v: any, fallback: number | null = null) { if (v === "" || v == null) return fallback; const n = Number(v); return Number.isFinite(n) ? n : fallback; } type ReadResult = { mode: "json" | "multipart"; // transport mode, not create/edit body: any; photoFile: File | null; screenFile: File | null; }; async function readJsonOrMultipart(req: Request): Promise { const ct = (req.headers.get("content-type") || "").toLowerCase(); if (ct.includes("multipart/form-data")) { const form = await (req as any).formData(); const payloadRaw = String(form.get("payload") ?? "{}"); let body: any = {}; try { body = JSON.parse(payloadRaw); } catch { throw new Error("Invalid JSON in 'payload'"); } const p = form.get("photo"); const s = form.get("screen"); const photoFile = p instanceof File && p.size > 0 ? (p as File) : null; const screenFile = s instanceof File && s.size > 0 ? (s as File) : null; return { mode: "multipart", body, photoFile, screenFile }; } const body = await (req as any).json().catch(() => ({})); return { mode: "json", body, photoFile: null, screenFile: null }; } /** Env-based folder IDs (no folder browsing) */ function folderIdFor( target: Target, kind: "photo" | "screen" | "notes" ): string | undefined { const E = process.env; const map: Record = { settings_co2gal: { photo: E.DX_FOLDER_GALVO_PHOTOS, screen: E.DX_FOLDER_GALVO_SCREENS, notes: E.DX_FOLDER_GALVO_NOTES, }, settings_co2gan: { photo: E.DX_FOLDER_GANTRY_PHOTOS, screen: E.DX_FOLDER_GANTRY_SCREENS, notes: E.DX_FOLDER_GANTRY_NOTES, }, settings_fiber: { photo: E.DX_FOLDER_FIBER_PHOTOS, screen: E.DX_FOLDER_FIBER_SCREENS, notes: E.DX_FOLDER_FIBER_NOTES, }, settings_uv: { photo: E.DX_FOLDER_UV_PHOTOS, screen: E.DX_FOLDER_UV_SCREENS, notes: E.DX_FOLDER_UV_NOTES, }, }; return map[target]?.[kind]; } export async function POST(req: Request) { const started = Date.now(); try { const ip = (req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() as string) || "0.0.0.0"; if (!rateLimitOk(ip)) { return NextResponse.json({ error: "Rate limited" }, { status: 429 }); } // Enforce user auth (everything uses the user's token) const bearer = requireBearer(req); const { body, photoFile, screenFile } = await readJsonOrMultipart(req); const target: Target = body?.target; if ( !target || !["settings_fiber", "settings_co2gan", "settings_co2gal", "settings_uv"].includes(target) ) { return NextResponse.json({ error: "Invalid target" }, { status: 400 }); } // Create vs Edit const op: "create" | "edit" = body?.mode === "edit" ? "edit" : "create"; // Required basics const setting_title = String(body?.setting_title || "").trim(); if (!setting_title) { return NextResponse.json( { error: "Missing required: setting_title" }, { status: 400 } ); } // Current user (handle both {data:{...}} and {...} shapes) const meRes = await dxGET("/users/me?fields=id,username", bearer); const meId = meRes?.data?.id ?? meRes?.id ?? null; const meUsername = meRes?.data?.username ?? meRes?.username ?? null; if (!meId) { return NextResponse.json( { error: "Unable to resolve current user." }, { status: 401 } ); } // Attribution const uploader = meUsername || "user"; // string field mirrors owner.username // Relations & numerics const mat = body?.mat ?? null; const mat_coat = body?.mat_coat ?? null; const mat_color = body?.mat_color ?? null; const mat_opacity = body?.mat_opacity ?? null; const mat_thickness = num(body?.mat_thickness, null); const source = body?.source ?? null; const lens = body?.lens ?? null; const focus = num(body?.focus, null); const setting_notes = String(body?.setting_notes || "").trim(); // Shared string fields const laser_soft = body?.laser_soft ?? null; // exact key: 'laser_soft' const repeat_all = num(body?.repeat_all, null); // universally applicable // Upload / accept existing file ids let photo_id: string | null = body?.photo ?? null; let screen_id: string | null = body?.screen ?? null; // In CREATE mode: require a photo (either an id or a file upload) if (op === "create" && !photo_id && photoFile) { if (photoFile.size > MAX_BYTES) { return NextResponse.json( { error: `Photo exceeds ${MAX_MB} MB` }, { status: 400 } ); } const up = await uploadFile(photoFile, (photoFile as File).name, bearer, { folderId: folderIdFor(target, "photo"), title: setting_title, }); photo_id = up.id; } if (op === "create" && !photo_id) { return NextResponse.json( { error: "Missing required: photo" }, { status: 400 } ); } // Optional screen (both modes) if (!screen_id && screenFile) { if (screenFile.size > MAX_BYTES) { return NextResponse.json( { error: `Screenshot exceeds ${MAX_MB} MB` }, { status: 400 } ); } const up = await uploadFile(screenFile, (screenFile as File).name, bearer, { folderId: folderIdFor(target, "screen"), title: `${setting_title} (screen)`, }); screen_id = up.id; } // Repeaters (pass-through; UI coerces numbers/bools) const fills = Array.isArray(body?.fill_settings) ? body.fill_settings : []; const lines = Array.isArray(body?.line_settings) ? body.line_settings : []; const rasters = Array.isArray(body?.raster_settings) ? body.raster_settings : []; // timestamps const nowIso = new Date().toISOString(); // Build payload common to both modes const basePayload: Record = { setting_title, setting_notes, // Ownership & attribution owner: meId || null, // M2O to directus_users uploader, // string mirror of username // exact keys laser_soft, repeat_all, mat, mat_coat, mat_color, mat_opacity, mat_thickness, source, lens, focus, fill_settings: fills, line_settings: lines, raster_settings: rasters, status: "pending", last_modified_date: nowIso, }; if (op === "create") { // Create-only fields basePayload.photo = photo_id; basePayload.screen = screen_id ?? null; basePayload.submission_date = nowIso; basePayload.submitted_via = "makearmy-app"; basePayload.submitted_at = nowIso; const { data } = await createSettingsItem(target, basePayload, bearer); return NextResponse.json({ ok: true, id: data.id }); } // EDIT mode const submission_id = body?.submission_id ?? null; if (!submission_id) { return NextResponse.json( { error: "Edit mode requires submission_id" }, { status: 400 } ); } // Only include photo/screen if provided; otherwise leave untouched const editPayload: Record = { ...basePayload }; if (photo_id) editPayload.photo = photo_id; if (screen_id) editPayload.screen = screen_id; // Patch by filter to avoid needing internal item id, and restrict to your own record const qs = new URLSearchParams(); qs.set("filter[_and][0][submission_id][_eq]", String(submission_id)); // enforce owner matches current user (works whether owner is id or M2O) qs.set("filter[_and][1][owner][_eq]", String(meId)); const res = await dxPATCH<{ data: any[] }>( `/items/${target}?${qs.toString()}`, bearer, editPayload ); const updatedCount = Array.isArray(res?.data) ? res.data.length : 0; if (updatedCount < 1) { return NextResponse.json( { error: "Nothing updated (not found or not owned by you)" }, { status: 404 } ); } return NextResponse.json({ ok: true, updated: updatedCount, submission_id }); } catch (err: any) { console.error("[submit/settings] error", err?.message || err); return NextResponse.json( { error: err?.message || "Unknown error" }, { status: err?.status ?? 500 } ); } finally { const ms = Date.now() - started; if (ms) console.log(`[submit/settings] handled in ~${ms}ms`); } }