settings overhaul and reset

This commit is contained in:
makearmy 2025-10-05 17:45:09 -04:00
parent 4ef3160515
commit 3195ff5d74
6 changed files with 904 additions and 1651 deletions

306
app/api/settings/route.ts Normal file
View file

@ -0,0 +1,306 @@
// 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<ReadResult> {
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),
};
}

View file

@ -1,330 +0,0 @@
// 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<string, { c: number; resetAt: number }>();
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<ReadResult> {
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<Target, { photo?: string; screen?: string; notes?: string }> = {
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<any>("/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
// CO2 lens extras (may be null on non-co2)
const lens_conf = body?.lens_conf ?? null;
const lens_apt = body?.lens_apt ?? null;
const lens_exp = body?.lens_exp ?? null;
// 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<string, any> = {
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,
// CO2-specific lens extras
lens_conf,
lens_apt,
lens_exp,
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;
// Helper is expected to wrap as { data: … } internally
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<string, any> = { ...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));
// ⬇⬇⬇ Directus expects { data: {...} } here (this fixes the 400 "data is required")
const res = await dxPATCH<{ data: any[] }>(
`/items/${target}?${qs.toString()}`,
bearer,
{ data: 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`);
}
}