// app/api/settings/route.ts import { NextResponse } from "next/server"; /** * Fresh, minimal Directus client (no external helpers). * - Upload assets to /files with multipart/form-data. * - Create and update records via /items/{collection}. * - Auth is via user cookie (ma_at) or a submit token (DIRECTUS_TOKEN_SUBMIT). */ export const runtime = "nodejs"; // ───────────────────────────────────────────────────────────── // Env // ───────────────────────────────────────────────────────────── const DX = (process.env.NEXT_PUBLIC_API_BASE_URL || "").replace(/\/$/, ""); const SUBMIT_TOKEN = process.env.DIRECTUS_TOKEN_SUBMIT || ""; // Folder IDs from env (data sheet says fixed, not browsable) const FOLDERS = { settings_co2gal: { photo: process.env.DX_FOLDER_GALVO_PHOTOS || "", screen: process.env.DX_FOLDER_GALVO_SCREENS || "", }, settings_co2gan: { photo: process.env.DX_FOLDER_GANTRY_PHOTOS || "", screen: process.env.DX_FOLDER_GANTRY_SCREENS || "", }, settings_fiber: { photo: process.env.DX_FOLDER_FIBER_PHOTOS || "", screen: process.env.DX_FOLDER_FIBER_SCREENS || "", }, settings_uv: { photo: process.env.DX_FOLDER_UV_PHOTOS || "", screen: process.env.DX_FOLDER_UV_SCREENS || "", }, } as const; type Target = "settings_co2gal" | "settings_co2gan" | "settings_fiber" | "settings_uv"; function bearerFrom(req: Request) { // Prefer user cookie (session) else fall back to submit token for server ops. const cookie = req.headers.get("cookie") || ""; const m = cookie.match(/(?:^|;\s*)ma_at=([^;]+)/); const at = m?.[1]; return at ? `Bearer ${at}` : SUBMIT_TOKEN ? `Bearer ${SUBMIT_TOKEN}` : ""; } async function dxUpload(file: File, folderId: string, bearer: string) { const form = new FormData(); form.set("file", file, file.name || "upload"); if (folderId) form.set("folder", folderId); const res = await fetch(`${DX}/files`, { method: "POST", headers: bearer ? { authorization: bearer } : undefined, body: form, }); const j = await res.json().catch(() => ({})); if (!res.ok) { const msg = j?.errors?.[0]?.message || `Directus /files failed (HTTP ${res.status})`; throw new Error(msg); } return j?.data?.id as string; } async function dxCreate(target: Target, data: any, bearer: string) { const res = await fetch(`${DX}/items/${target}`, { method: "POST", headers: { "content-type": "application/json", ...(bearer ? { authorization: bearer } : {}), }, body: JSON.stringify(data), }); const j = await res.json().catch(() => ({})); if (!res.ok) { const msg = j?.errors?.[0]?.message || `Directus create failed (HTTP ${res.status})`; throw new Error(msg); } return j?.data; } async function dxUpdate(target: Target, pk: string | number, data: any, bearer: string) { const res = await fetch(`${DX}/items/${target}/${encodeURIComponent(String(pk))}`, { method: "PATCH", headers: { "content-type": "application/json", ...(bearer ? { authorization: bearer } : {}), }, body: JSON.stringify(data), }); const j = await res.json().catch(() => ({})); if (!res.ok) { const msg = j?.errors?.[0]?.message || `Directus update failed (HTTP ${res.status})`; throw new Error(msg); } return j?.data; } // Guard numeric const num = (v: any) => (v === "" || v == null || Number.isNaN(Number(v)) ? null : Number(v)); // Guard bool const bool = (v: any) => !!v; // Guard id string const idOrNull = (v: any) => (v === "" || v == null ? null : String(v)); type ReadResult = { mode: "json" | "multipart"; 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"); return { mode: "multipart", body, photoFile: p instanceof File && p.size > 0 ? (p as File) : null, screenFile: s instanceof File && s.size > 0 ? (s as File) : null, }; } const body = await (req as any).json().catch(() => ({})); return { mode: "json", body, photoFile: null, screenFile: null }; } /** * POST: create or update a settings_* record * Body (JSON or multipart with { payload }): * { * target: "settings_co2gal" | ..., * mode?: "edit", * submission_id?: string|number, * // fields per data sheet (CO2 Galvo shown) * setting_title: string (required), * setting_notes?: string, * photo?: string (asset id) // if not provided in create, require file in multipart * screen?: string (asset id) // optional * // Material & Rig / Optics * mat: string (id), * mat_coat: string (id), * mat_color: string (id), * mat_opacity: string (id), * mat_thickness?: number, * laser_soft: string (id), * source: string (submission_id of laser_source), * lens: string (id), * focus?: number, * // CO2 Galvo Options (part of Rig & Optics per sheet) * lens_conf: string (id), * lens_apt: string (id), * lens_exp: string (id), * repeat_all?: number, * // Repeaters * fill_settings?: Array<...>, * line_settings?: Array<...>, * raster_settings?: Array<...> * } */ export async function POST(req: Request) { try { const { body, photoFile, screenFile } = await readJsonOrMultipart(req); const target = String(body?.target || "") as Target; if (!target || !FOLDERS[target]) { return NextResponse.json({ error: "Invalid or missing target." }, { status: 400 }); } const isEdit = body?.mode === "edit"; const pk = isEdit ? body?.submission_id : null; // Upload assets if files are present const bearer = bearerFrom(req); const folderCfg = FOLDERS[target]; let photoId = idOrNull(body.photo); let screenId = idOrNull(body.screen); if (photoFile) { if (!folderCfg.photo) throw new Error("Photo folder not configured."); photoId = await dxUpload(photoFile, folderCfg.photo, bearer); } if (screenFile) { if (!folderCfg.screen) throw new Error("Screen folder not configured."); screenId = await dxUpload(screenFile, folderCfg.screen, bearer); } // Enforce requireds (data sheet: title + result photo on create) if (!body.setting_title || String(body.setting_title).trim() === "") { return NextResponse.json({ error: "Missing required: setting_title" }, { status: 400 }); } if (!isEdit && !photoId) { return NextResponse.json({ error: "Result photo is required." }, { status: 400 }); } // Build Directus payload strictly as the collection expects (ids + arrays) const payload: any = { setting_title: String(body.setting_title), setting_notes: String(body.setting_notes || ""), // Assets ...(photoId ? { photo: photoId } : {}), ...(screenId ? { screen: screenId } : {}), // Material/Rig & Optics (M2O ids) mat: idOrNull(body.mat), mat_coat: idOrNull(body.mat_coat), mat_color: idOrNull(body.mat_color), mat_opacity: idOrNull(body.mat_opacity), mat_thickness: num(body.mat_thickness), laser_soft: idOrNull(body.laser_soft), source: idOrNull(body.source), // note: this is submission_id for laser_source; schema should be configured accordingly lens: idOrNull(body.lens), focus: num(body.focus), // CO2 Galvo option triplet (Rig & Optics) lens_conf: idOrNull(body.lens_conf), lens_apt: idOrNull(body.lens_apt), lens_exp: idOrNull(body.lens_exp), repeat_all: num(body.repeat_all), // Repeaters (arrays of plain objects) fill_settings: Array.isArray(body.fill_settings) ? body.fill_settings.map(mapFill) : [], line_settings: Array.isArray(body.line_settings) ? body.line_settings.map(mapLine) : [], raster_settings: Array.isArray(body.raster_settings) ? body.raster_settings.map(mapRaster) : [], }; let saved; if (isEdit) { if (!pk) return NextResponse.json({ error: "Missing submission_id for edit mode." }, { status: 400 }); saved = await dxUpdate(target, pk, payload, bearer); } else { saved = await dxCreate(target, payload, bearer); } return NextResponse.json({ id: saved?.submission_id ?? saved?.id ?? null, data: saved }, { status: 200 }); } catch (e: any) { return NextResponse.json({ error: e?.message || "Failed" }, { status: 500 }); } } // ───────────────────────────────────────────────────────────── // Mappers (ensure numeric/bool normalization per sheet) // ───────────────────────────────────────────────────────────── function mapFill(r: any) { return { name: r?.name || "", type: (r?.type || "").toString(), // uni|bi|offset power: num(r?.power), speed: num(r?.speed), interval: num(r?.interval), pass: num(r?.pass), frequency: num(r?.frequency), pulse: num(r?.pulse), angle: num(r?.angle), auto: bool(r?.auto), increment: num(r?.increment), cross: bool(r?.cross), flood: bool(r?.flood), air: bool(r?.air), }; } function mapLine(r: any) { return { name: r?.name || "", power: num(r?.power), speed: num(r?.speed), perf: bool(r?.perf), cut: bool(r?.cut), skip: bool(r?.skip), pass: num(r?.pass), air: bool(r?.air), frequency: num(r?.frequency), pulse: num(r?.pulse), wobble: bool(r?.wobble), step: num(r?.step), size: num(r?.size), }; } function mapRaster(r: any) { return { name: r?.name || "", type: (r?.type || "").toString(), // uni|bi|offset dither: (r?.dither || "").toString(), // threshold|ordered|... halftone_cell: num(r?.halftone_cell), halftone_angle: num(r?.halftone_angle), inversion: bool(r?.inversion), interval: num(r?.interval), dot: num(r?.dot), power: num(r?.power), speed: num(r?.speed), pass: num(r?.pass), air: bool(r?.air), frequency: num(r?.frequency), pulse: num(r?.pulse), cross: bool(r?.cross), }; }