From a38aa4c2f9bcef77802f8300b5cdb3876287a186 Mon Sep 17 00:00:00 2001 From: makearmy Date: Wed, 1 Oct 2025 20:00:26 -0400 Subject: [PATCH] co2 galvo owner test --- .env.local | 40 ++- app/api/submit/settings/route.ts | 285 +++++++++++----------- app/settings/co2-galvo/[id]/co2-galvo.tsx | 6 +- app/settings/co2-galvo/page.tsx | 24 +- lib/directus.ts | 135 +++++----- 5 files changed, 254 insertions(+), 236 deletions(-) diff --git a/.env.local b/.env.local index d28b125a..5ce1b864 100644 --- a/.env.local +++ b/.env.local @@ -1,9 +1,43 @@ -# Client-side (used by the dropdown fetches) +# ───────────────────────────────────────────── +# Public (used by client-side dropdown fetches) +# ───────────────────────────────────────────── NEXT_PUBLIC_API_BASE_URL=https://forms.lasereverything.net -# Server-side (used by API routes) +# ───────────────────────────────────────────── +# Server-side Directus +# ───────────────────────────────────────────── DIRECTUS_URL=https://forms.lasereverything.net DIRECTUS_TOKEN_ADMIN_REGISTER=l_QqNXKpi--Dt-hHDncHyBX0eiHNYZr7 -# Image Folders +# Optional (default "Users") +DIRECTUS_ROLE_MEMBER_NAME=Users + +# ───────────────────────────────────────────── +# Files / Folders (IDs only; no folder browsing) +# ───────────────────────────────────────────── DIRECTUS_AVATAR_FOLDER_ID=b8ddddf8-3ee3-4380-b27e-c7a5f01deef1 + +# Settings — CO₂ Galvo +DX_FOLDER_GALVO_NOTES=7b04a706-754d-4302-a9a0-6c88cd8faddf +DX_FOLDER_GALVO_PHOTOS=e5535371-828a-498b-80fc-3891b6220fd4 +DX_FOLDER_GALVO_SCREENS=8201e4c0-c39c-456a-bd55-1beb96642bcb + +# Settings — CO₂ Gantry +DX_FOLDER_GANTRY_NOTES=926e2c1a-7907-4ef2-b778-859c6f40ba82 +DX_FOLDER_GANTRY_PHOTOS=d19c4f8d-a42f-422d-b113-b89b736c34e6 +DX_FOLDER_GANTRY_SCREENS=9b7d0b47-c1f4-4749-8876-2e4b52ccded0 + +# Settings — Fiber +DX_FOLDER_FIBER_NOTES=00eed759-480e-43cc-9de3-854dc59cca79 +DX_FOLDER_FIBER_PHOTOS=54f6a9d2-bc57-41fc-8c7d-7c7d7cb9cadc +DX_FOLDER_FIBER_SCREENS=5c830975-7926-4e01-911c-2443b62d7f88 + +# Settings — UV +DX_FOLDER_UV_NOTES=8ca37379-7178-48b2-8670-6b8d8a880677 +DX_FOLDER_UV_PHOTOS=c639360b-3116-4b5d-98da-f8b502089486 +DX_FOLDER_UV_SCREENS=a84f54b1-0e92-4ea6-8fbe-37a3a74bd49c + +# Projects +DX_FOLDER_PROJECTS_FILES=f264f066-5b38-4335-bb10-5b014bfa62cb +DX_FOLDER_PROJECTS_IMAGES=da11b876-2ede-4e19-ad3a-76fc9db449a8 +DX_FOLDER_PROJECTS_INSTRUCTIONS=905a4259-0c8e-489b-b810-c27186a2f266 diff --git a/app/api/submit/settings/route.ts b/app/api/submit/settings/route.ts index c419084a..296df9b9 100644 --- a/app/api/submit/settings/route.ts +++ b/app/api/submit/settings/route.ts @@ -81,26 +81,36 @@ async function readJsonOrMultipart(req: Request): Promise { return { mode: "json", body, photoFile: null, screenFile: null }; } -// map to your Directus folder paths -function folderPathFor(target: Target, kind: "photo" | "screen") { - switch (target) { - case "settings_fiber": - return kind === "photo" - ? "le_fiber_settings/le_fiber_settings_photos" - : "le_fiber_settings/le_fiber_settings_screenshots"; - case "settings_co2gan": - return kind === "photo" - ? "le_co2gan_settings/le_co2gan_settings_photos" - : "le_co2gan_settings/le_co2gan_settings_screenshots"; - case "settings_co2gal": - return kind === "photo" - ? "le_co2gal_settings/le_co2gal_settings_photos" - : "le_co2gal_settings/le_co2gal_settings_screenshots"; - case "settings_uv": - return kind === "photo" - ? "le_uv_settings/le_uv_settings_photos" - : "le_uv_settings/le_uv_settings_screenshots"; - } +/** Env-based folder IDs (no folder browsing) */ +function folderIdFor( + target: Target, + kind: "photo" | "screen" | "notes" +): string | undefined { + const E = process.env; + const map = { + 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, + }, + } as const; + // @ts-expect-error narrow map index + return map[target]?.[kind]; } export async function POST(req: Request) { @@ -109,152 +119,141 @@ export async function POST(req: Request) { 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 }); - } + if (!rateLimitOk(ip)) { + return NextResponse.json({ error: "Rate limited" }, { status: 429 }); + } - // Enforce user auth - const bearer = requireBearer(req); + // Enforce user auth (everything uses the user's token) + const bearer = requireBearer(req); - const { mode, body, photoFile, screenFile } = await readJsonOrMultipart(req); + const { mode, 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 }); - } + 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 }); + } - // Required basics - const setting_title = String(body?.setting_title || "").trim(); - if (!setting_title) - return NextResponse.json( - { error: "Missing required: setting_title" }, - { status: 400 } - ); + // 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 shapes from dxGET) ───────────── - 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; + // 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; - // Derive uploader from the authenticated user (server-trusted) - const uploader = meUsername || "user"; + // 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(); + // 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(); - // Fiber-only / shared string - const laser_soft = body?.laser_soft ?? null; // string - const repeat_all = - target === "settings_fiber" ? num(body?.repeat_all, null) : undefined; + // Shared string fields + const laser_soft = body?.laser_soft ?? null; // exact key: 'laser_soft' + const repeat_all = num(body?.repeat_all, null); // ← universally applicable now - // Upload / accept existing file ids - let photo_id: string | null = body?.photo ?? null; - let screen_id: string | null = body?.screen ?? null; + // Upload / accept existing file ids + let photo_id: string | null = body?.photo ?? null; + let screen_id: string | null = body?.screen ?? null; - // photo is required: if no id on body, require file - if (!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, - { - folderNamePath: folderPathFor(target, "photo"), - title: setting_title, - // NOTE: do NOT include `owner` here; uploadFile options don't accept it. - } - ); - photo_id = up.id; - } - if (!photo_id) { - return NextResponse.json( - { error: "Missing required: photo" }, - { status: 400 } - ); - } + // photo is required: if no id on body, require file + if (!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 (!photo_id) { + return NextResponse.json( + { error: "Missing required: photo" }, + { status: 400 } + ); + } - 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, - { - folderNamePath: folderPathFor(target, "screen"), - title: `${setting_title} (screen)`, - } - ); - screen_id = up.id; - } + 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 already 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 : []; + // 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 for required fields - const nowIso = new Date().toISOString(); + // timestamps + const nowIso = new Date().toISOString(); - const payload: Record = { - setting_title, - // Ownership & attribution - owner: meId || null, // ✅ M2O to directus_users - uploader, // ✅ mirror username into string field + const payload: Record = { + setting_title, - setting_notes, + // Ownership & attribution + owner: meId || null, // M2O to directus_users + uploader, // string mirror of username - submission_date: nowIso, // if required by schema - last_modified_date: nowIso, // keep in sync + setting_notes, - photo: photo_id, - screen: screen_id ?? null, + submission_date: nowIso, + last_modified_date: nowIso, - // ✅ exact key (string) - laser_soft, + photo: photo_id, + screen: screen_id ?? null, - mat, - mat_coat, - mat_color, - mat_opacity, - mat_thickness, - source, - lens, - focus, + // exact keys + laser_soft, + repeat_all, // ← always included - fill_settings: fills, - line_settings: lines, - raster_settings: rasters, + mat, + mat_coat, + mat_color, + mat_opacity, + mat_thickness, + source, + lens, + focus, - status: "pending", - submitted_via: "makearmy-app", - submitted_at: nowIso, - }; + fill_settings: fills, + line_settings: lines, + raster_settings: rasters, - if (target === "settings_fiber") { - payload.repeat_all = repeat_all ?? null; - } + status: "pending", + submitted_via: "makearmy-app", + submitted_at: nowIso, + }; - const { data } = await createSettingsItem(target, payload, bearer); - return NextResponse.json({ ok: true, id: data.id }); + const { data } = await createSettingsItem(target, payload, bearer); + return NextResponse.json({ ok: true, id: data.id }); } catch (err: any) { console.error("[submit/settings] error", err?.message || err); return NextResponse.json( diff --git a/app/settings/co2-galvo/[id]/co2-galvo.tsx b/app/settings/co2-galvo/[id]/co2-galvo.tsx index e63b686a..40933e12 100644 --- a/app/settings/co2-galvo/[id]/co2-galvo.tsx +++ b/app/settings/co2-galvo/[id]/co2-galvo.tsx @@ -20,7 +20,6 @@ export default function CO2GalvoSettingDetailPage() { "submission_id", "setting_title", "uploader", - // make sure owner expands "owner.id", "owner.username", "setting_notes", @@ -41,7 +40,6 @@ export default function CO2GalvoSettingDetailPage() { "lens_apt.name", "lens_exp.name", "focus", - // string-or-relation "laser_soft", "laser_soft.name", "repeat_all", @@ -50,7 +48,8 @@ export default function CO2GalvoSettingDetailPage() { "raster_settings", ].join(","); - const url = `/api/dx/items/settings_co2gal/${encodeURIComponent(String(id))}` + + const url = + `/api/dx/items/settings_co2gal/${encodeURIComponent(String(id))}` + `?fields=${encodeURIComponent(fields)}`; setLoading(true); @@ -73,7 +72,6 @@ export default function CO2GalvoSettingDetailPage() { if (loading) return

Loading setting...

; if (!setting) return

Setting not found.

; - // ---- display helpers ---- const ownerDisplay: string = typeof setting?.owner === "object" ? (setting.owner?.username ?? setting.owner?.id ?? "—") diff --git a/app/settings/co2-galvo/page.tsx b/app/settings/co2-galvo/page.tsx index adb3cd9f..2051fbc5 100644 --- a/app/settings/co2-galvo/page.tsx +++ b/app/settings/co2-galvo/page.tsx @@ -8,7 +8,6 @@ import Image from "next/image"; type Owner = { id?: string | number; username?: string | null; - // keep extras harmlessly if API returns them first_name?: string | null; last_name?: string | null; email?: string | null; @@ -31,32 +30,31 @@ export default function CO2GalvoSettingsPage() { }, [query]); useEffect(() => { - const url = - `${process.env.NEXT_PUBLIC_API_BASE_URL}/items/settings_co2gal` + - `?fields=` + - [ + const fields = [ "submission_id", "setting_title", "uploader", - // owner (M2O) – ensure username is requested "owner.id", "owner.username", - // assets / denorms "photo.id", "photo.title", "mat.name", "mat_coat.name", "source.model", "lens.field_size", - ].join(",") + - `&limit=-1`; + ].join(","); - fetch(url, { cache: "no-store" }) - .then((res) => { - if (!res.ok) throw new Error(`HTTP ${res.status}`); + const url = `/api/dx/items/settings_co2gal?fields=${encodeURIComponent(fields)}&limit=-1`; + + fetch(url, { cache: "no-store", credentials: "include" }) + .then(async (res) => { + if (!res.ok) { + const j = await res.json().catch(() => ({})); + throw new Error(j?.errors?.[0]?.message || `HTTP ${res.status}`); + } return res.json(); }) - .then((data) => setSettings(data?.data || [])) + .then((json) => setSettings(json?.data || [])) .catch((e) => { console.error("CO2 Galvo settings fetch failed:", e); setSettings([]); diff --git a/lib/directus.ts b/lib/directus.ts index 5f69b056..5c2e6842 100644 --- a/lib/directus.ts +++ b/lib/directus.ts @@ -1,10 +1,11 @@ // lib/directus.ts // Central Directus helpers used by API routes. (user bearer only) +import { cookies, headers } from "next/headers"; + const BASE = (process.env.DIRECTUS_URL || "").replace(/\/$/, ""); const TOKEN_ADMIN_REGISTER = process.env.DIRECTUS_TOKEN_ADMIN_REGISTER || ""; // server-only const ROLE_MEMBER_NAME = process.env.DIRECTUS_ROLE_MEMBER_NAME || "Users"; - const PROJECTS_COLLECTION = process.env.DIRECTUS_PROJECTS_COLLECTION || "projects"; if (!BASE) console.warn("[directus] Missing DIRECTUS_URL"); @@ -15,11 +16,45 @@ export function bytesFromMB(mb: number) { return Math.round(mb * 1024 * 1024); } -// Extract a user's bearer (ma_at) from a Next.js Request (server routes) -export function getUserBearerFromRequest(req: Request): string | null { - const cookieHeader = req.headers.get("cookie") ?? ""; - const m = cookieHeader.match(/(?:^|;\s*)ma_at=([^;]+)/); - return m?.[1] ?? null; +/** + * Return the user's Directus bearer token from the request or server context. + * Looks at: + * 1) Authorization: Bearer ... + * 2) Next.js server cookies (ma_at, ma_at_beta, ma_session) + * 3) Raw Cookie header (fallback) + */ +export function getUserBearerFromRequest(req?: Request): string | null { + // 1) Authorization header (supports server-to-server/proxy calls) + const hAuth = req?.headers?.get("authorization") ?? headers().get("authorization"); + if (hAuth?.startsWith("Bearer ")) return hAuth.slice(7); + + // 2) Next.js server cookies (App Router) + try { + const c = cookies(); + const t = + c.get("ma_at")?.value || + c.get("ma_at_beta")?.value || + c.get("ma_session")?.value || + null; + if (t) return t; + } catch { + // Not in a server context that supports cookies() + } + + // 3) Raw Cookie header (plain Request fallback) + const raw = req?.headers?.get("cookie") ?? ""; + if (raw) { + const map = Object.fromEntries( + raw.split(/;\s*/).map((p) => { + const [k, ...v] = p.split("="); + return [decodeURIComponent(k), decodeURIComponent(v.join("=") || "")]; + }) + ); + const t = map["ma_at"] || map["ma_at_beta"] || map["ma_session"]; + if (t) return String(t); + } + + return null; } // ───────────────────────────────────────────────────────────── @@ -33,7 +68,9 @@ function authHeaders(bearer: string, extra?: HeadersInit): HeadersInit { async function parseJsonSafe(res: Response) { const text = await res.text(); let json: any = null; - try { json = text ? JSON.parse(text) : null; } catch {} + try { + json = text ? JSON.parse(text) : null; + } catch {} return { json, text }; } @@ -49,7 +86,10 @@ async function throwIfNotOk(res: Response) { } export async function dxGET(path: string, bearer: string): Promise { - const res = await fetch(`${BASE}${path}`, { headers: authHeaders(bearer), cache: "no-store" }); + const res = await fetch(`${BASE}${path}`, { + headers: authHeaders(bearer), + cache: "no-store", + }); return (await throwIfNotOk(res)) as T; } @@ -83,89 +123,38 @@ export async function dxDELETE(path: string, bearer: string): Promise(path: string, init?: RequestInit): Promise { if (!TOKEN_ADMIN_REGISTER) throw new Error("Missing DIRECTUS_TOKEN_ADMIN_REGISTER"); const res = await fetch(`${BASE}${path}`, { ...init, - headers: { Accept: "application/json", Authorization: `Bearer ${TOKEN_ADMIN_REGISTER}`, ...(init?.headers || {}) }, - cache: "no-store", + headers: { + Accept: "application/json", + Authorization: `Bearer ${TOKEN_ADMIN_REGISTER}`, + ...(init?.headers || {}), + }, + cache: "no-store", }); return (await throwIfNotOk(res)) as T; } // ───────────────────────────────────────────────────────────── -// Optional folder lookup (server-only if using admin token) -// ───────────────────────────────────────────────────────────── - -type FolderItem = { id: string; name: string; parent?: { id?: string; name?: string } | null }; -const folderCache = new Map(); -let folderListCache: FolderItem[] | null = null; -let folderListCacheAt = 0; - -async function fetchAllFolders(): Promise { - try { - const q = `/folders?fields=id,name,parent.id,parent.name&limit=500`; - const res = await directusAdminFetch<{ data: FolderItem[] }>(q); - return res?.data ?? []; - } catch (e: any) { - console.warn("[directus] fetchAllFolders failed:", e?.message || e); - return null; - } -} - -async function getFolderIdByPath(path: string): Promise { - if (!path) return undefined; - if (folderCache.has(path)) return folderCache.get(path); - - const now = Date.now(); - const freshForMs = 60_000; - if (!folderListCache || now - folderListCacheAt > freshForMs) { - folderListCache = await fetchAllFolders(); - folderListCacheAt = now; - } - const list = folderListCache; - if (!list) { - folderCache.set(path, undefined); - return undefined; - } - - const parts = path.split("/").map((s) => s.trim()).filter(Boolean); - const [parentName, childName] = parts; - const eq = (a?: string | null, b?: string | null) => String(a ?? "").toLowerCase() === String(b ?? "").toLowerCase(); - - let match: FolderItem | undefined; - if (parts.length >= 2) { - match = list.find((f) => eq(f.name, childName) && eq(f.parent?.name ?? "", parentName)); - } else { - match = list.find((f) => eq(f.name, parts[0])); - } - - const id = match?.id ? String(match.id) : undefined; - folderCache.set(path, id); - return id; -} - -// ───────────────────────────────────────────────────────────── -// Files & items — user bearer ONLY +// Files & items — user bearer ONLY (no folder listing/browsing) // ───────────────────────────────────────────────────────────── export async function uploadFile( file: Blob | File, filename: string, bearer: string, - options?: { folderId?: string; folderNamePath?: string; title?: string } + options?: { folderId?: string; title?: string } // folderNamePath removed ): Promise<{ id: string }> { const form = new FormData(); form.set("file", file, filename); form.set("filename_download", filename); if (options?.title) form.set("title", options.title); - - let folderId = options?.folderId; - if (!folderId && options?.folderNamePath) { - try { folderId = await getFolderIdByPath(options.folderNamePath); } catch {} - } - if (folderId) form.set("folder", folderId); + if (options?.folderId) form.set("folder", options.folderId); // user-scoped const res = await fetch(`${BASE}/files`, { method: "POST", @@ -204,7 +193,7 @@ export async function patchProject( } // ───────────────────────────────────────────────────────────── -// Auth helpers (registration / login support) +/** Auth helpers (registration / login support) */ // ───────────────────────────────────────────────────────────── export async function resolveMemberRoleId(): Promise { @@ -221,7 +210,7 @@ export async function createDirectusUser(input: { username: string; password: string; email?: string; -}): Promise<{ id: string }> { +}: PromiseLike extends never ? never : any): Promise<{ id: string }> { const role = await resolveMemberRoleId(); // If email is omitted, create a stable placeholder so login can still work.