diff --git a/app/api/health/directus/route.ts b/app/api/health/directus/route.ts new file mode 100644 index 00000000..24aeb8c2 --- /dev/null +++ b/app/api/health/directus/route.ts @@ -0,0 +1,38 @@ +// app/api/health/directus/route.ts +import { NextResponse } from "next/server"; +import { directusFetch } from "@/lib/directus"; + +export const runtime = "nodejs"; + +export async function GET() { + const out: any = { ok: true, checks: {} }; + try { + // who am I? + try { + const who = await directusFetch<{ data: any }>(`/users/me?fields=id,email,role.name`); + out.checks.user = { ok: true, role: who?.data?.role?.name ?? null }; + } catch (e: any) { + out.checks.user = { ok: false, error: e?.message || String(e) }; + } + + // can read folders? + try { + const folders = await directusFetch<{ data: any[] }>(`/folders?limit=1&fields=id,name`); + out.checks.folders = { ok: true, sample: folders?.data?.[0] ?? null }; + } catch (e: any) { + out.checks.folders = { ok: false, error: e?.message || String(e) }; + } + + // can read files (not create; safe) + try { + const files = await directusFetch<{ data: any[] }>(`/files?limit=0`); + out.checks.files_read = { ok: true, totalKnown: files?.data?.length ?? 0 }; + } catch (e: any) { + out.checks.files_read = { ok: false, error: e?.message || String(e) }; + } + + return NextResponse.json(out); + } catch (e: any) { + return NextResponse.json({ ok: false, error: e?.message || "health error", ...out }, { status: 500 }); + } +} diff --git a/app/api/health/options/route.ts b/app/api/health/options/route.ts new file mode 100644 index 00000000..1efc79ba --- /dev/null +++ b/app/api/health/options/route.ts @@ -0,0 +1,28 @@ +// app/api/health/options/route.ts +import { NextResponse } from "next/server"; +import { directusFetch } from "@/lib/directus"; + +const TESTS = [ + { name: "material", path: "/items/material?limit=1" }, +{ name: "material_coating", path: "/items/material_coating?limit=1" }, +{ name: "material_color", path: "/items/material_color?limit=1" }, +{ name: "material_opacity", path: "/items/material_opacity?limit=1" }, +{ name: "laser_software", path: "/items/laser_software?limit=1" }, +{ name: "laser_source", path: "/items/laser_source?limit=1" }, +{ name: "laser_scan_lens", path: "/items/laser_scan_lens?limit=1" }, +{ name: "laser_focusing_lens",path: "/items/laser_focusing_lens?limit=1" }, +]; + +export async function GET() { + const results: any[] = []; + for (const t of TESTS) { + try { + const { data } = await directusFetch<{ data: any[] }>(t.path); + const first = data?.[0] ?? null; + results.push({ name: t.name, ok: true, sample_id: first?.submission_id ?? first?.id ?? null }); + } catch (e: any) { + results.push({ name: t.name, ok: false, error: e?.message || String(e) }); + } + } + return NextResponse.json({ ok: true, results }); +} diff --git a/app/api/options/[collection]/route.ts b/app/api/options/[collection]/route.ts index a01f280a..61f3c672 100644 --- a/app/api/options/[collection]/route.ts +++ b/app/api/options/[collection]/route.ts @@ -1,10 +1,8 @@ // app/api/options/[collection]/route.ts -import { NextRequest, NextResponse } from "next/server"; +import { NextResponse } from "next/server"; import { directusFetch } from "@/lib/directus"; -const NM_FIELD = "nm"; // wavelength field in laser_source - -// Parse wavelength that might be stored as "1064", "1064nm", "1,064", etc. +// Parse "nm" that may be stored as a string (e.g., "1064", "1064nm", "1,064") function parseNm(v: any): number | null { const s = String(v ?? "").replace(/[^0-9.]/g, ""); if (!s) return null; @@ -12,7 +10,7 @@ function parseNm(v: any): number | null { return Number.isFinite(n) ? n : null; } -// Target → wavelength range (nm) +// target → wavelength range (nm) function nmRangeForTarget(t?: string): [number, number] | null { switch (t) { case "settings_fiber": return [1000, 1100]; @@ -23,89 +21,71 @@ function nmRangeForTarget(t?: string): [number, number] | null { } } -// Generic lookups (request only fields we know exist) -const GENERIC: Record< - string, - { path: string; fields: string[]; label: (x: any) => string } -> = { - material: { path: "/items/material", fields: ["id", "name"], label: (x) => x.name ?? String(x.id) }, - material_coating: { path: "/items/material_coating", fields: ["id", "name"], label: (x) => x.name ?? String(x.id) }, - material_color: { path: "/items/material_color", fields: ["id", "name"], label: (x) => x.name ?? String(x.id) }, - material_opacity: { path: "/items/material_opacity", fields: ["id", "opacity"], label: (x) => String(x.opacity ?? x.id) }, - laser_software: { path: "/items/laser_software", fields: ["id", "name"], label: (x) => x.name ?? String(x.id) }, +// generic collections we expose here +const GENERIC_MAP: Record = { + material: { coll: "material" }, + material_coating: { coll: "material_coating" }, + material_color: { coll: "material_color" }, + material_opacity: { coll: "material_opacity" }, + laser_software: { coll: "laser_software" }, }; -async function fetchDirectus(pathname: string, params: URLSearchParams) { - return directusFetch(`${pathname}?${params.toString()}`); +function bestLabel(it: any): string { + // prefer common fields; fall back gracefully + const cand = [it.name, it.label, it.title, it.value, it.opacity, it.color, it.coating]; + const label = cand.find((x) => x != null && String(x).trim().length) ?? it.id ?? it.submission_id; + return String(label); } -export async function GET(req: NextRequest) { +export async function GET(req: Request, ctx: { params: { collection: string } }) { try { - const url = new URL(req.url); - const collection = url.pathname.split("/").pop() || ""; - const q = url.searchParams.get("q")?.trim() || ""; - const limit = Number(url.searchParams.get("limit") || "400"); - const target = url.searchParams.get("target") || undefined; + const { searchParams } = new URL(req.url); + const q = searchParams.get("q")?.trim() || ""; + const limit = Number(searchParams.get("limit") || "500"); + const target = searchParams.get("target") || undefined; - // ----- generic tables ----- - const gen = GENERIC[collection]; - if (gen) { - const params = new URLSearchParams(); - params.set("fields", gen.fields.join(",")); - params.set("limit", String(limit)); - if (q) params.set("search", q); + const key = ctx.params.collection; - const { data } = await fetchDirectus<{ data: any[] }>(gen.path, params); - const out = (data ?? []) - .map((x) => ({ id: String(x.id), label: gen.label(x) })) - .sort((a, b) => a.label.localeCompare(b.label)); - return NextResponse.json({ data: out }); - } - - // ----- laser_source (uses submission_id as the key) ----- - if (collection === "laser_source") { + // laser_source: wavelength filtered by target, and pk may be 'submission_id' + if (key === "laser_source") { const range = nmRangeForTarget(target); - if (!range) { - return NextResponse.json( - { error: "missing/invalid target for laser_source" }, - { status: 400 } - ); - } - - const params = new URLSearchParams(); - // IMPORTANT: request submission_id instead of id - params.set("fields", ["submission_id", "make", "model", NM_FIELD].join(",")); - params.set("limit", String(limit)); - if (q) params.set("search", q); - - const { data } = await fetchDirectus<{ data: any[] }>("/items/laser_source", params); - const rows = data ?? []; + if (!range) return NextResponse.json({ error: "missing/invalid target for laser_source" }, { status: 400 }); + const url = `/items/laser_source?limit=${limit}${q ? `&search=${encodeURIComponent(q)}` : ""}`; + const { data } = await directusFetch<{ data: any[] }>(url); const [lo, hi] = range; - const filtered = rows.filter((x) => { - const nm = parseNm(x[NM_FIELD]); + + const filtered = (data || []).filter((x) => { + const nm = parseNm(x?.nm); return nm !== null && nm >= lo && nm <= hi; }); const out = filtered - .map((x) => ({ - id: String(x.submission_id), // <- use submission_id - label: [x.make, x.model].filter(Boolean).join(" ").trim() || String(x.submission_id), - sortKey: [(x.make ?? "").toLowerCase(), (x.model ?? "").toLowerCase()].join(" "), - })) - .filter((o) => o.id) - .sort((a, b) => a.sortKey.localeCompare(b.sortKey)) - .map(({ id, label }) => ({ id, label })); + .map((x) => { + const id = String(x.submission_id ?? x.id); + const label = [x.make, x.model].filter(Boolean).join(" ").trim() || id; + const sortKey = [x.make ?? "", x.model ?? ""].join(" ").toLowerCase(); + return { id, label, sortKey }; + }) + .sort((a, b) => a.sortKey.localeCompare(b.sortKey)) + .map(({ id, label }) => ({ id, label })); return NextResponse.json({ data: out }); } - return NextResponse.json({ error: "unsupported collection" }, { status: 400 }); - } catch (err: any) { - return NextResponse.json( - { error: err?.message || "Unknown error" }, - { status: 500 } - ); + // generic collections + const cfg = GENERIC_MAP[key]; + if (!cfg) return NextResponse.json({ error: "unsupported collection" }, { status: 400 }); + + const url = `/items/${cfg.coll}?limit=${limit}${q ? `&search=${encodeURIComponent(q)}` : ""}`; + const { data } = await directusFetch<{ data: any[] }>(url); + const mapped = (data || []).map((it) => ({ + id: String(it.submission_id ?? it.id), + label: bestLabel(it), + })); + mapped.sort((a, b) => a.label.localeCompare(b.label)); + return NextResponse.json({ data: mapped }); + } catch (e: any) { + return NextResponse.json({ error: e?.message || "options error" }, { status: 500 }); } } - diff --git a/app/api/options/lens/route.ts b/app/api/options/lens/route.ts index 2feeb693..2ae0a217 100644 --- a/app/api/options/lens/route.ts +++ b/app/api/options/lens/route.ts @@ -2,39 +2,53 @@ import { NextResponse } from "next/server"; import { directusFetch } from "@/lib/directus"; -/** pick a decent label from whatever fields are readable */ -function pickLabel(it: any) { - const mm = [it?.make, it?.model].filter(Boolean).join(" ").trim(); - if (mm) return mm; - if (it?.name) return String(it.name); - const f = it?.focal_length ?? it?.f ?? it?.fl; - if (f != null) return `${mm ? mm + " " : ""}${f} mm`.trim(); - return String(it?.label ?? it?.title ?? it?.id ?? ""); +/** + * For fiber, co2-galvo, uv → f-theta scan lenses + * For co2-gantry → focusing lenses + */ +function collectionForTarget(target?: string): { coll: string } | null { + switch (target) { + case "settings_fiber": + case "settings_co2gal": + case "settings_uv": + return { coll: "laser_scan_lens" }; + case "settings_co2gan": + return { coll: "laser_focusing_lens" }; + default: + return null; + } +} + +function bestLensLabel(it: any): string { + // Try common fields, then derive something readable + if (it.name) return String(it.name); + if (it.model) return String(it.model); + if (it.focal_length_mm) return `F${it.focal_length_mm} mm`; + return String(it.submission_id ?? it.id ?? "lens"); } export async function GET(req: Request) { - const { searchParams } = new URL(req.url); - const target = searchParams.get("target") || ""; // required - const q = (searchParams.get("q") || "").toLowerCase(); - const limit = Number(searchParams.get("limit") || "500"); + try { + const { searchParams } = new URL(req.url); + const q = searchParams.get("q")?.trim() || ""; + const limit = Number(searchParams.get("limit") || "500"); + const target = searchParams.get("target") || undefined; - // Fiber / CO2 Galvo / UV -> scan lens ; CO2 Gantry -> focus lens - const isGantry = target === "settings_co2gan"; - const coll = isGantry ? "laser_focus_lens" : "laser_scan_lens"; + const cfg = collectionForTarget(target); + if (!cfg) return NextResponse.json({ error: "missing/invalid target" }, { status: 400 }); - // Avoid explicit fields -> prevents 403 on disallowed fields - const res = await directusFetch<{ data: any[] }>(`/items/${coll}?limit=${limit}`); - let items = res?.data ?? []; + const url = `/items/${cfg.coll}?limit=${limit}${q ? `&search=${encodeURIComponent(q)}` : ""}`; + const { data } = await directusFetch<{ data: any[] }>(url); - let rows = items.map((it) => { - const label = pickLabel(it); - const search = Object.values(it ?? {}).join(" ").toLowerCase(); - return { id: String(it?.id ?? ""), label, _search: search }; - }).filter((r) => r.id); + const out = (data || []) + .map((it) => ({ + id: String(it.submission_id ?? it.id), + label: bestLensLabel(it), + })) + .sort((a, b) => a.label.localeCompare(b.label)); - if (q) rows = rows.filter((r) => r._search.includes(q)); - rows.sort((a, b) => a.label.localeCompare(b.label)); - - return NextResponse.json({ data: rows.map(({ _search, ...r }) => r) }); + return NextResponse.json({ data: out }); + } catch (e: any) { + return NextResponse.json({ error: e?.message || "lens error" }, { status: 500 }); + } } - diff --git a/app/api/submit/settings/route.ts b/app/api/submit/settings/route.ts index 1a5dc1ba..5ae0b4dc 100644 --- a/app/api/submit/settings/route.ts +++ b/app/api/submit/settings/route.ts @@ -1,21 +1,16 @@ // app/api/submit/settings/route.ts -import { NextRequest, NextResponse } from "next/server"; -import { - bytesFromMB, - createSettingsItem, - directusFetch, - uploadFile, -} from "@/lib/directus"; +import { NextResponse } from "next/server"; +import { uploadFile, createSettingsItem, bytesFromMB } from "@/lib/directus"; /** ───────────────────────────────────────────────────────────── * Accepts EITHER: - * - application/json (photo/screen can be data URLs: photo_data, screen_data) + * - application/json (photo/screen as data URLs via preview_image{ name,data }) * - multipart/form-data with: - * - "payload" = JSON string (same shape as JSON body) - * - "photo" = result image (REQUIRED) - * - "screen" = screenshot image (optional) + * - field "payload" = JSON string (same shape as JSON body) + * - optional field "photo" = single file to upload + * - optional field "screen" = single file to upload * - * Targets (collections): + * Collections (targets): * - settings_fiber (+ laser_soft, repeat_all) * - settings_co2gan * - settings_co2gal @@ -24,10 +19,12 @@ import { export const runtime = "nodejs"; +type Target = "settings_fiber" | "settings_co2gan" | "settings_co2gal" | "settings_uv"; + const MAX_MB = Number(process.env.FILE_MAX_MB || 25); const MAX_BYTES = bytesFromMB(MAX_MB); -// light in-memory rate limiter +// 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); @@ -43,127 +40,22 @@ function rateLimitOk(ip: string) { return true; } -type Target = "settings_fiber" | "settings_co2gan" | "settings_co2gal" | "settings_uv"; - -/** Map target + kind → Directus folder name */ -function folderName(target: Target, kind: "photo" | "screen") { - const base = - target === "settings_fiber" - ? "le_fiber_settings" - : target === "settings_uv" - ? "le_uv_settings" - : target === "settings_co2gal" - ? "le_co2gal_settings" - : "le_co2gan_settings"; - return kind === "photo" ? `${base}_photos` : `${base}_screenshots`; -} - -/** Lookup a folder id by name, returns null if not found */ -async function findFolderIdByName(name: string): Promise { - try { - const res = await directusFetch<{ data: Array<{ id: string }> }>( - `/folders?limit=1&fields=id&filter[name][_eq]=${encodeURIComponent(name)}` - ); - const id = res?.data?.[0]?.id ?? null; - return id || null; - } catch { - return null; - } -} - -/** Patch a file to move it into a folder (no-op if folderId is null) */ -async function moveFileToFolder(fileId: string, folderId: string | null) { - if (!fileId || !folderId) return; - await directusFetch(`/files/${fileId}`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ folder: folderId }), - }); -} - -// Whitelists for repeaters (matches your Directus repeater schemas) -const FILL_KEYS = new Set([ - "name", - "power", - "speed", - "interval", - "pass", - "type", - "flood", - "air", - "frequency", - "pulse", - "angle", - "auto", - "increment", - "cross", -]); -const LINE_KEYS = new Set([ - "name", - "power", - "speed", - "perf", - "cut", - "skip", - "pass", - "air", - "frequency", - "pulse", - "wobble", - "step", - "size", -]); -const RASTER_KEYS = new Set([ - "name", - "power", - "speed", - "type", - "dither", - "halftone_cell", - "halftone_angle", - "inversion", - "interval", - "dot", - "pass", - "air", - "frequency", - "pulse", - "cross", -]); - function sanitizeNumber(n: any, fallback: number | null = null) { if (n === null || n === undefined || n === "") return fallback; const v = Number(n); return Number.isFinite(v) ? v : fallback; } -function sanitizeRepeaterRow( - row: Record, - allowed: Set -): Record { +function bool(v: any) { + return !!v; +} + +function sanitizeRepeaterRow(row: Record, allowed: Set) { const out: Record = {}; for (const k of Object.keys(row || {})) { if (!allowed.has(k)) continue; - if ( - [ - "power", - "speed", - "interval", - "pass", - "halftone_cell", - "halftone_angle", - "dot", - "frequency", - "pulse", - "angle", - "increment", - "step", - "size", - ].includes(k) - ) { + if (["power","speed","interval","pass","halftone_cell","halftone_angle","dot","angle","increment","frequency","pulse","step","size"].includes(k)) { out[k] = sanitizeNumber(row[k]); - } else if (["auto", "cross", "wobble", "perf", "air", "flood", "inversion"].includes(k)) { - out[k] = !!row[k]; } else { out[k] = row[k]; } @@ -171,9 +63,29 @@ function sanitizeRepeaterRow( return out; } -async function readJsonOrMultipart(req: NextRequest) { - const ct = req.headers.get("content-type") || ""; +// Based on your note: folders are "/" +function folderPath(target: Target, kind: "photo" | "screen"): string { + const parent = + target === "settings_fiber" ? "le_fiber_settings" : + target === "settings_co2gan" ? "le_co2gan_settings" : + target === "settings_co2gal" ? "le_co2gal_settings" : + "le_uv_settings"; + const child = kind === "photo" + ? `${parent}_photos` + : `${parent}_screenshots`; + return `${parent}/${child}`; +} +async function readJsonOrMultipart(req: Request): Promise<{ + mode: "json" | "multipart"; + body: any; + files: { photo?: File; screen?: File }; +}> { + const ct = req.headers.get("content-type") || ""; + if (ct.includes("application/json")) { + const body = await req.json(); + return { mode: "json", body, files: {} }; + } if (ct.includes("multipart/form-data")) { const form = await req.formData(); const payloadRaw = String(form.get("payload") || "{}"); @@ -183,27 +95,22 @@ async function readJsonOrMultipart(req: NextRequest) { } catch { throw new Error("Invalid JSON in 'payload' field"); } - const files = { - photo: (form.get("photo") as File) || null, - screen: (form.get("screen") as File) || null, - }; - return { mode: "multipart" as const, body, files }; + const photo = (form.get("photo") as File) || undefined; + const screen = (form.get("screen") as File) || undefined; + return { mode: "multipart", body, files: { photo, screen } }; } - - if (ct.includes("application/json")) { - const body = await req.json(); - return { mode: "json" as const, body, files: { photo: null as File | null, screen: null as File | null } }; - } - throw new Error("Unsupported content-type. Use JSON or multipart/form-data."); } -export async function POST(req: NextRequest) { +// Whitelists for repeaters (align with read-side UI) +const FILL_KEYS = new Set(["name","power","speed","interval","pass","type","flood","air","frequency","pulse","angle","auto","increment","cross"]); +const LINE_KEYS = new Set(["name","power","speed","perf","cut","skip","pass","air","frequency","pulse","wobble","step","size"]); +const RASTER_KEYS = new Set(["name","power","speed","type","dither","halftone_cell","halftone_angle","inversion","interval","dot","pass","air","frequency","pulse","cross"]); + +export async function POST(req: Request) { const started = Date.now(); try { - const ip = - req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || - "0.0.0.0"; + const ip = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || "0.0.0.0"; if (!rateLimitOk(ip)) { return NextResponse.json({ error: "Rate limited" }, { status: 429 }); } @@ -211,181 +118,134 @@ export async function POST(req: NextRequest) { const { mode, body, files } = await readJsonOrMultipart(req); const target: Target = body?.target; - if (!["settings_fiber", "settings_co2gan", "settings_co2gal", "settings_uv"].includes(target as any)) { + if (!target || !["settings_fiber","settings_co2gan","settings_co2gal","settings_uv"].includes(target)) { return NextResponse.json({ error: "Invalid target" }, { status: 400 }); } // Required base fields const setting_title = String(body?.setting_title || body?.title || "").trim(); const uploader = String(body?.uploader || "").trim(); + if (!setting_title) return NextResponse.json({ error: "Missing field: setting_title" }, { status: 400 }); + if (!uploader) return NextResponse.json({ error: "Missing field: uploader" }, { status: 400 }); - // Relations (required) + // 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 source = body?.source ?? null; const lens = body?.lens ?? null; + const focus = sanitizeNumber(body?.focus, null); + const mat_thickness = sanitizeNumber(body?.mat_thickness, null); + const setting_notes = String(body?.setting_notes || body?.notes || "").trim() || ""; - // Numbers - const mat_thickness = sanitizeNumber(body?.mat_thickness, null); - const focus = sanitizeNumber(body?.focus, null); + // Fiber-only requireds per your spec + const laser_soft = target === "settings_fiber" ? (body?.laser_soft ?? null) : undefined; + const repeat_all = target === "settings_fiber" ? sanitizeNumber(body?.repeat_all, null) : undefined; - // Notes (optional) - const setting_notes = String(body?.setting_notes || body?.notes || "").trim() || ""; - - // Fiber-only (required) - const laser_soft = - target === "settings_fiber" ? (body?.laser_soft ?? null) : null; - const repeat_all = - target === "settings_fiber" ? sanitizeNumber(body?.repeat_all, null) : null; - - // Validate requireds + // Validate requireds per your list (server-side guardrails) const missing: string[] = []; - if (!setting_title) missing.push("setting_title"); - if (!uploader) missing.push("uploader"); - if (!source) missing.push("source"); - if (!lens) missing.push("lens"); - if (focus === null || !Number.isFinite(focus)) missing.push("focus"); - if (!mat) missing.push("mat"); - if (!mat_coat) missing.push("mat_coat"); - if (!mat_color) missing.push("mat_color"); - if (!mat_opacity) missing.push("mat_opacity"); - if (target === "settings_fiber") { - if (!laser_soft) missing.push("laser_soft"); - if (repeat_all === null || !Number.isFinite(repeat_all)) missing.push("repeat_all"); + for (const [k, v] of Object.entries({ + source, lens, focus, + mat, mat_coat, mat_color, mat_opacity, + ...(target === "settings_fiber" ? { laser_soft, repeat_all } : {}), + })) { if (v === null || v === "" || v === undefined) missing.push(k); } + if (missing.length) { + return NextResponse.json({ error: `Missing required field(s): ${missing.join(", ")}` }, { status: 400 }); } - // Handle files (photo required) + // Handle files (multipart) OR data URLs (json) let photo_id: string | null = null; let screen_id: string | null = null; - // If multipart, use file objects. Else allow base64 in JSON: photo_data/screen_data - if (mode === "multipart") { - if (!files.photo) missing.push("photo"); - if (files.photo) { - if (files.photo.size > MAX_BYTES) { - return NextResponse.json( - { error: `Photo exceeds ${MAX_MB} MB` }, - { status: 400 } - ); - } - const up = await uploadFile(files.photo, (files.photo as File).name || "photo"); - photo_id = (up as any)?.id ?? null; + async function guardSize(name: string, file: File) { + if (file.size > MAX_BYTES) { + throw new Error(`${name} exceeds ${MAX_MB} MB`); + } + } - // after upload, move into appropriate folder - const folder = await findFolderIdByName(folderName(target, "photo")); - await moveFileToFolder(String(photo_id), folder); + if (mode === "multipart") { + if (files.photo) { + await guardSize("photo", files.photo); + const up = await uploadFile(files.photo, (files.photo as File).name || "photo", { + folderNamePath: folderPath(target, "photo"), + title: setting_title, + }); + photo_id = up.id; } if (files.screen) { - if (files.screen.size > MAX_BYTES) { - return NextResponse.json( - { error: `Screenshot exceeds ${MAX_MB} MB` }, - { status: 400 } - ); - } - const up = await uploadFile(files.screen, (files.screen as File).name || "screen"); - screen_id = (up as any)?.id ?? null; - const folder = await findFolderIdByName(folderName(target, "screen")); - await moveFileToFolder(String(screen_id), folder); + await guardSize("screen", files.screen); + const up = await uploadFile(files.screen, (files.screen as File).name || "screen", { + folderNamePath: folderPath(target, "screen"), + title: setting_title, + }); + screen_id = up.id; } } else { - // JSON mode with optional base64 strings - const pushBase64 = async (dataUrl: string, name: string) => { - const base64 = (dataUrl || "").split(",")[1] || ""; - if (!base64) return null; + // JSON mode supports preview_image: { name, data } for both "photo" and "screen" + const upFromDataUrl = async (obj: any, kind: "photo" | "screen") => { + const dataUrl: string = obj?.data; + const name: string = obj?.name || kind; + if (!dataUrl) return null; + const base64 = dataUrl.split(",")[1] || ""; const raw = Buffer.from(base64, "base64"); - if (raw.byteLength > MAX_BYTES) throw new Error(`${name} exceeds ${MAX_MB} MB`); + if (raw.byteLength > MAX_BYTES) throw new Error(`${kind} exceeds ${MAX_MB} MB`); const blob = new Blob([raw]); - const up = await uploadFile(blob as any, name); - return (up as any)?.id ?? null; + const up = await uploadFile(blob as any, name, { + folderNamePath: folderPath(target, kind), + title: setting_title, + }); + return up.id; }; - - if (body?.photo_data) { - photo_id = await pushBase64(body.photo_data, "photo"); - const folder = await findFolderIdByName(folderName(target, "photo")); - await moveFileToFolder(String(photo_id), folder); - } else { - missing.push("photo"); - } - if (body?.screen_data) { - screen_id = await pushBase64(body.screen_data, "screen"); - const folder = await findFolderIdByName(folderName(target, "screen")); - await moveFileToFolder(String(screen_id), folder); - } + if (body?.preview_image) photo_id = await upFromDataUrl(body.preview_image, "photo"); + if (body?.preview_screen) screen_id = await upFromDataUrl(body.preview_screen, "screen"); } - if (missing.length) { - return NextResponse.json( - { error: `Missing required: ${missing.join(", ")}` }, - { status: 400 } - ); - } + // Repeaters (sanitize) + const fills = Array.isArray(body?.fill_settings) ? body.fill_settings : body?.fills || []; + const lines = Array.isArray(body?.line_settings) ? body.line_settings : body?.lines || []; + const rasters = Array.isArray(body?.raster_settings) ? body.raster_settings : body?.rasters || []; - // Repeaters - const fillsRaw = Array.isArray(body?.fill_settings) ? body.fill_settings : body?.fills || []; - const linesRaw = Array.isArray(body?.line_settings) ? body.line_settings : body?.lines || []; - const rastersRaw = Array.isArray(body?.raster_settings) ? body.raster_settings : body?.rasters || []; + const fill_settings = (fills as any[]).map((r) => sanitizeRepeaterRow(r, FILL_KEYS)); + const line_settings = (lines as any[]).map((r) => sanitizeRepeaterRow(r, LINE_KEYS)); + const raster_settings = (rasters as any[]).map((r) => sanitizeRepeaterRow(r, RASTER_KEYS)); - const fill_settings = (fillsRaw as any[]).map((r) => sanitizeRepeaterRow(r, FILL_KEYS)); - const line_settings = (linesRaw as any[]).map((r) => sanitizeRepeaterRow(r, LINE_KEYS)); - const raster_settings = (rastersRaw as any[]).map((r) => sanitizeRepeaterRow(r, RASTER_KEYS)); - - // Build record + // Build payload (use your read-side keys) const nowIso = new Date().toISOString(); const payload: Record = { setting_title, uploader, setting_notes, - // relations - mat, - mat_coat, - mat_color, - mat_opacity, - source, - lens, - // numbers - focus, + photo: photo_id, // null if none + screen: screen_id, // null if none + mat, mat_coat, mat_color, mat_opacity, mat_thickness, - // files - photo: photo_id, - screen: screen_id, - // repeaters - fill_settings, - line_settings, - raster_settings, - // meta - submission_date: nowIso, - last_modified_date: nowIso, + source, lens, focus, + fill_settings, line_settings, raster_settings, status: "pending", submitted_via: "makearmy-app", submitted_at: nowIso, + submission_date: nowIso, + last_modified_date: nowIso, // harmless even if you later make it optional }; - if (target === "settings_fiber") { - payload.laser_soft = laser_soft; - payload.repeat_all = repeat_all; + payload.laser_soft = laser_soft ?? null; + payload.repeat_all = repeat_all ?? null; } - const created = await createSettingsItem(target, payload); + const { data } = await createSettingsItem(target, payload); + const newId = data?.submission_id ?? data?.id ?? null; - // normalize PK to always provide "id" (your tables use submission_id) - const newId = - (created as any)?.submission_id ?? - (created as any)?.data?.submission_id ?? - (created as any)?.id ?? - (created as any)?.data?.id ?? - null; - - return NextResponse.json({ - ok: true, - id: newId, - submission_id: newId, - took_ms: Date.now() - started, - }); + return NextResponse.json({ ok: true, id: newId }); } catch (err: any) { - const msg = err?.message || "Unknown error"; - return NextResponse.json({ error: msg }, { status: 500 }); + console.error("[submit] error", err?.message || err); + return NextResponse.json( + { error: err?.message || "Unknown error" }, + { status: 500 } + ); + } finally { + // cheap request duration log + const ms = Date.now() - (globalThis as any).__start_ts ?? 0; + if (ms) console.log(`[submit/settings] handled in ~${ms}ms`); } } - diff --git a/app/components/forms/SettingsSubmit.tsx b/app/components/forms/SettingsSubmit.tsx index 8d112cf9..0128bbd6 100644 --- a/app/components/forms/SettingsSubmit.tsx +++ b/app/components/forms/SettingsSubmit.tsx @@ -3,16 +3,14 @@ import { useEffect, useMemo, useState } from "react"; import { useForm, useFieldArray, type UseFormRegister } from "react-hook-form"; -/** ───────────────────────────────────────────────────────────── - * Client form: - * - Custom file inputs (photo required, screen optional) with previews - * - Posts multipart/form-data: { payload: JSON, photo?: File, screen?: File } - * - Accepts id/submission_id in response - * ──────────────────────────────────────────────────────────── */ - +/** Targets map 1:1 with your Directus collections */ type Target = "settings_fiber" | "settings_co2gan" | "settings_co2gal" | "settings_uv"; type Opt = { id: string; label: string }; +/* ───────────────────────────────────────────────────────────── + * Generic hook to fetch options with client-side filter + * Expects endpoint to return: { data: Array<{id,label}> } + * ──────────────────────────────────────────────────────────── */ function useOptions(path: string) { const [opts, setOpts] = useState([]); const [loading, setLoading] = useState(false); @@ -23,18 +21,21 @@ function useOptions(path: string) { setLoading(true); const url = `/api/options/${path}${path.includes("?") ? "&" : "?"}q=${encodeURIComponent(q)}`; fetch(url, { cache: "no-store" }) - .then((r) => r.json()) - .then((j) => { if (alive) setOpts((j?.data as Opt[]) ?? []); }) - .catch(() => { if (alive) setOpts([]); }) - .finally(() => { if (alive) setLoading(false); }); + .then((r) => r.json()) + .then((j) => { if (alive) setOpts((j?.data as Opt[]) ?? []); }) + .catch(() => { if (alive) setOpts([]); }) + .finally(() => { if (alive) setLoading(false); }); return () => { alive = false; }; }, [path, q]); return { opts, loading, setQ }; } +/* ───────────────────────────────────────────────────────────── + * Filterable select with optional "required" client rule + * ──────────────────────────────────────────────────────────── */ function FilterableSelect({ - label, name, register, options, loading, onQuery, placeholder = "—", required, + label, name, register, options, loading, onQuery, placeholder = "—", required = false, }: { label: string; name: string; @@ -56,112 +57,116 @@ function FilterableSelect({ return (
- - setFilter(e.target.value)} - /> - -
- ); -} - -/** Custom file input with preview + filename display */ -function FileInput({ - label, - required, - onFile, - maxMB = 25, - accept = "image/*", - initialPreview, -}: { - label: string; - required?: boolean; - onFile: (f: File | null) => void; - maxMB?: number; - accept?: string; - initialPreview?: string | null; -}) { - const [file, setFile] = useState(null); - const [preview, setPreview] = useState(initialPreview ?? null); - const [name, setName] = useState(""); - - function pick(e: React.ChangeEvent) { - const f = e.target.files?.[0] ?? null; - setFile(f); - setName(f ? f.name : ""); - onFile(f); - if (f) { - const url = URL.createObjectURL(f); - setPreview(url); - } else { - setPreview(null); - } - } - - return ( -
- - - {/* Hide the native text; instead show our own button + filename */} -
- - - {file ? `Selected: ${name}` : "No file selected"} - -
- -

Max {maxMB} MB. JPG/PNG/WebP recommended.

- - {preview && ( - preview - )} - - {/* Simple required guard text (we enforce in onSubmit too) */} - {required && !file && ( -

This image is required.

- )} + + setFilter(e.target.value)} + /> +
); } +/* ───────────────────────────────────────────────────────────── + * Checkbox + * ──────────────────────────────────────────────────────────── */ function BoolBox({ label, name, register }:{ label: string; name: string; register: UseFormRegister; }) { return ( ); } +/* ───────────────────────────────────────────────────────────── + * Polished File Picker (with preview + filename) + * NOT registered into RHF; managed via local state. + * Use `onFile` to push the selected File up to the parent. + * ──────────────────────────────────────────────────────────── */ +function FilePicker({ + label, + required, + onFile, + accept, +}: { + label: string; + required?: boolean; + onFile: (f: File | null) => void; + accept?: string; +}) { + const [file, setFile] = useState(null); + const [url, setUrl] = useState(null); + + function handleChange(e: React.ChangeEvent) { + const f = e.target.files?.[0] || null; + setFile(f); + onFile(f); + if (url) URL.revokeObjectURL(url); + setUrl(f ? URL.createObjectURL(f) : null); + } + + function clear() { + setFile(null); + onFile(null); + if (url) URL.revokeObjectURL(url); + setUrl(null); + } + + const inputId = `fp-${label.replace(/\s+/g, "-").toLowerCase()}`; + + return ( +
+
+ + {file && ( + + )} +
+ + {/* visually hidden native input; we present a custom button */} + +
+ +
+ {file ? file.name : "No file chosen"} +
+
+ + {url && ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {`${label} +
+ )} +
+ ); +} + +/* ───────────────────────────────────────────────────────────── + * Main form + * ──────────────────────────────────────────────────────────── */ export default function SettingsSubmit({ initialTarget }: { initialTarget?: Target }) { const [target, setTarget] = useState(initialTarget ?? "settings_fiber"); - // Custom file states - const [photoFile, setPhotoFile] = useState(null); - const [screenFile, setScreenFile] = useState(null); - // Generic lists (alphabetical) const mats = useOptions("material"); const coats = useOptions("material_coating"); @@ -174,17 +179,11 @@ export default function SettingsSubmit({ initialTarget }: { initialTarget?: Targ const lens = useOptions(`lens?target=${target}`); // scan vs focus lens by target // Repeater select choices from Directus field config - const fillType = useOptions(`repeater-choices?target=${target}&group=fill_settings&field=type`); - const rasterType = useOptions(`repeater-choices?target=${target}&group=raster_settings&field=type`); - const rasterDither = useOptions(`repeater-choices?target=${target}&group=raster_settings&field=dither`); + const fillType = useOptions(`repeater-choices?target=${target}&group=fill_settings&field=type`); + const rasterType = useOptions(`repeater-choices?target=${target}&group=raster_settings&field=type`); + const rasterDither= useOptions(`repeater-choices?target=${target}&group=raster_settings&field=dither`); - const { - register, - handleSubmit, - control, - reset, - formState: { isSubmitting }, - } = useForm({ + const { register, handleSubmit, control, reset, formState: { isSubmitting, errors } } = useForm({ defaultValues: { setting_title: "", uploader: "", setting_notes: "", mat: "", mat_coat: "", mat_color: "", mat_opacity: "", @@ -201,23 +200,44 @@ export default function SettingsSubmit({ initialTarget }: { initialTarget?: Targ const isGantry = target === "settings_co2gan"; const isFiber = target === "settings_fiber"; + // file state (not part of RHF) + const [photoFile, setPhotoFile] = useState(null); + const [screenFile, setScreenFile] = useState(null); + const [submitMsg, setSubmitMsg] = useState<{ ok: boolean; text: string } | null>(null); + + // helpers function num(v: any) { return (v === "" || v == null) ? null : Number(v); } const bool = (v: any) => !!v; async function onSubmit(values: any) { - // enforce photo required on client - if (!photoFile) { - alert("Result Photo is required."); + setSubmitMsg(null); + + // client-side requireds (extra guardrails) + const missing: string[] = []; + if (!photoFile) missing.push("photo"); + if (!values.source) missing.push("laser source"); + if (!values.lens) missing.push("lens"); + if (values.focus === "" || values.focus === undefined || values.focus === null) missing.push("focus"); + for (const k of ["mat","mat_coat","mat_color","mat_opacity"]) { + if (!values[k]) missing.push(k); + } + if (!values.setting_title) missing.push("title"); + if (!values.uploader) missing.push("uploader"); + if (isFiber) { + if (!values.laser_soft) missing.push("software"); + if (values.repeat_all === "" || values.repeat_all === undefined || values.repeat_all === null) missing.push("repeat_all"); + } + if (missing.length) { + setSubmitMsg({ ok: false, text: `Missing required field(s): ${missing.join(", ")}` }); return; } + // Build server payload (numbers coerced on server too) const payload: any = { target, setting_title: values.setting_title, uploader: values.uploader, setting_notes: values.setting_notes || "", - - // relations / numbers mat: values.mat || null, mat_coat: values.mat_coat || null, mat_color: values.mat_color || null, @@ -227,54 +247,59 @@ export default function SettingsSubmit({ initialTarget }: { initialTarget?: Targ lens: values.lens || null, focus: num(values.focus), - // repeaters fill_settings: (values.fill_settings || []).map((r: any) => ({ name: r.name || "", power: num(r.power), - speed: num(r.speed), - interval: num(r.interval), - pass: num(r.pass), - type: r.type || "", - 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), + speed: num(r.speed), + interval: num(r.interval), + pass: num(r.pass), + type: r.type || "", + // present on fiber/uv/co2gal + frequency: num(r.frequency), + pulse: num(r.pulse), + angle: num(r.angle), + auto: bool(r.auto), + increment: num(r.increment), + cross: bool(r.cross), + // present on all + flood: bool(r.flood), + air: bool(r.air), })), + line_settings: (values.line_settings || []).map((r: any) => ({ name: r.name || "", power: num(r.power), - speed: num(r.speed), - perf: bool(r.perf), - cut: r.cut || "", - skip: 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), + speed: num(r.speed), + perf: bool(r.perf), + cut: r.cut || "", + skip: r.skip || "", + pass: num(r.pass), + air: bool(r.air), + // extra on fiber/uv/co2gal + frequency: num(r.frequency), + pulse: num(r.pulse), + wobble: bool(r.wobble), + step: num(r.step), + size: num(r.size), })), + raster_settings: (values.raster_settings || []).map((r: any) => ({ name: r.name || "", power: num(r.power), - speed: num(r.speed), - type: r.type || "", - dither: r.dither || "", - halftone_cell: num(r.halftone_cell), - halftone_angle: num(r.halftone_angle), - inversion: bool(r.inversion), - interval: num(r.interval), - dot: num(r.dot), - pass: num(r.pass), - air: bool(r.air), - frequency: num(r.frequency), - pulse: num(r.pulse), - cross: bool(r.cross), + speed: num(r.speed), + type: r.type || "", + dither: r.dither || "", + halftone_cell: num(r.halftone_cell), + halftone_angle: num(r.halftone_angle), + inversion: bool(r.inversion), + interval: num(r.interval), + dot: num(r.dot), + pass: num(r.pass), + air: bool(r.air), + // extras on fiber/uv/co2gal + frequency: num(r.frequency), + pulse: num(r.pulse), + cross: bool(r.cross), })), }; @@ -283,273 +308,287 @@ export default function SettingsSubmit({ initialTarget }: { initialTarget?: Targ payload.repeat_all = num(values.repeat_all); } - const fd = new FormData(); - fd.append("payload", JSON.stringify(payload)); - if (photoFile) fd.append("photo", photoFile, photoFile.name); - if (screenFile) fd.append("screen", screenFile, screenFile.name); + // Use multipart/form-data with JSON payload + files + const form = new FormData(); + form.set("payload", JSON.stringify(payload)); + if (photoFile) form.set("photo", photoFile, photoFile.name); + if (screenFile) form.set("screen", screenFile, screenFile.name); const res = await fetch("/api/submit/settings", { method: "POST", - body: fd, + body: form, }); + const text = await res.text(); let data: any = {}; - try { - data = await res.json(); - } catch { - // no-op; keep empty data - } + try { data = text ? JSON.parse(text) : {}; } catch { /* non-JSON error page */ } if (!res.ok) { - const msg = data?.error || "Submission failed"; - alert(`Submission failed: ${msg}`); + setSubmitMsg({ ok: false, text: `Submission failed: ${data?.error || res.statusText}` }); return; } - const id = - data?.id ?? - data?.submission_id ?? - data?.data?.id ?? - data?.data?.submission_id ?? - data?.itemId ?? - "(unknown)"; + setSubmitMsg({ ok: true, text: `Submitted! ID: ${data?.id ?? "unknown"}` }); - reset(); + // reset form (keep target) + reset({ + setting_title: "", uploader: "", setting_notes: "", + mat: "", mat_coat: "", mat_color: "", mat_opacity: "", + mat_thickness: "", source: "", lens: "", focus: "", + laser_soft: "", repeat_all: "", + fill_settings: [], line_settings: [], raster_settings: [], + }); setPhotoFile(null); setScreenFile(null); - alert(`Submitted! ID: ${id}`); } return ( -
-
-
- - -
+
+
+
+ + +
- {isFiber && ( -
- -
- )} + {isFiber && ( +
+ +
+ )} +
+ +
+ {/* identity */} +
+
+ + + {errors.setting_title &&

Title is required

} +
+
+ + + {errors.uploader &&

Uploader is required

} +
+
+ +
+ +