diff --git a/app/api/my/rigs/route.ts b/app/api/my/rigs/route.ts index ad496466..d0eca2e1 100644 --- a/app/api/my/rigs/route.ts +++ b/app/api/my/rigs/route.ts @@ -1,79 +1,95 @@ +// app/api/my/rigs/route.ts import { NextRequest, NextResponse } from "next/server"; import { cookies } from "next/headers"; -import { directusFetch } from "@/lib/directus"; -// Change these if your collection/owner field differ -const BASE_COLLECTION = process.env.RIGS_COLLECTION || "rigs"; -const OWNER_FIELD = process.env.RIGS_OWNER_FIELD || "owner"; +const BASE = process.env.DIRECTUS_URL!; -// Pull the user's Directus access token from cookies (await to satisfy Next 15 typings) -async function bearerFromCookies() { - const jar = await cookies(); - const at = jar.get("ma_at")?.value; +function bearerFromCookies() { + const store = cookies(); + const at = store.get("ma_at")?.value; if (!at) throw new Error("Not authenticated"); return `Bearer ${at}`; } -// Resolve current Directus user id using their access token -async function getMeId(auth: string): Promise { - const res = await directusFetch<{ data: { id: string } }>( - `/users/me?fields=id`, - { headers: { Authorization: auth } } - ); - const id = res?.data?.id; - if (!id) throw new Error("Unable to resolve current user id"); - return id; +async function fetchJSON(path: string, init: RequestInit = {}) { + const res = await fetch(`${BASE}${path}`, init); + const text = await res.text(); + let json: any = null; + try { json = text ? JSON.parse(text) : null; } catch {} + if (!res.ok) { + throw new Error( + `Directus error ${res.status}: ${text || res.statusText}` + ); + } + return json ?? {}; } -// List rigs that belong to the current user -export async function GET(_req: NextRequest) { +export async function GET() { try { - const auth = await bearerFromCookies(); - const meId = await getMeId(auth); - - const { data } = await directusFetch<{ data: any[] }>( - `/items/${BASE_COLLECTION}?filter[${OWNER_FIELD}][_eq]=${encodeURIComponent( - meId - )}&limit=200&sort=-date_created`, - { headers: { Authorization: auth } } - ); - - return NextResponse.json({ ok: true, data }); - } catch (err: any) { - return NextResponse.json( - { error: err?.message || "List failed" }, - { status: 401 } - ); + const auth = bearerFromCookies(); + // Your Users role already restricts READ to owner == $CURRENT_USER + const out = await fetchJSON(`/items/user_rigs?fields=*,owner.username`, { + headers: { Authorization: auth, Accept: "application/json" }, + }); + return NextResponse.json(out); + } catch (e: any) { + return NextResponse.json({ error: e.message || String(e) }, { status: 401 }); } } -// Create a new rig for the current user export async function POST(req: NextRequest) { + const started = Date.now(); try { - const auth = await bearerFromCookies(); - const meId = await getMeId(auth); + const auth = bearerFromCookies(); + const body = await req.json(); + // minimal validation – keep it light, let Directus enforce the rest + const name = String(body?.name ?? "").trim(); + const rig_type = String(body?.rig_type ?? "").trim(); - // Ensure ownership is set to the current user - const payload = { ...body, [OWNER_FIELD]: meId }; + if (!name) return NextResponse.json({ error: "name is required" }, { status: 400 }); + if (!rig_type) return NextResponse.json({ error: "rig_type is required" }, { status: 400 }); - const { data } = await directusFetch<{ data: any }>( - `/items/${BASE_COLLECTION}`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: auth, - }, - body: JSON.stringify(payload), - } - ); + // Get the current user's id so we can set owner explicitly + const me = await fetchJSON(`/users/me`, { + headers: { Authorization: auth, Accept: "application/json" }, + }); + const ownerId = me?.data?.id; + if (!ownerId) throw new Error("Could not resolve current user id"); - return NextResponse.json({ ok: true, data }); - } catch (err: any) { + const payload = { + name, + rig_type, + owner: ownerId, + // pass through optional relational fields only if present (prevents FK violations) + laser_source: body?.laser_source ?? null, + laser_focus_lens: body?.laser_focus_lens ?? null, + laser_scan_lens: body?.laser_scan_lens ?? null, + laser_scan_lens_apt: body?.laser_scan_lens_apt ?? null, + laser_scan_lens_exp: body?.laser_scan_lens_exp ?? null, + laser_software: body?.laser_software ?? null, + notes: body?.notes ?? null, + }; + + const created = await fetchJSON(`/items/user_rigs`, { + method: "POST", + headers: { + Authorization: auth, + Accept: "application/json", + "Content-Type": "application/json", + Prefer: "return=representation", + }, + body: JSON.stringify(payload), + }); + + return NextResponse.json(created); + } catch (e: any) { return NextResponse.json( - { error: err?.message || "Create failed" }, + { error: e?.message || "Failed to create rig" }, { status: 400 } ); + } finally { + const ms = Date.now() - started; + if (ms) console.log(`[my/rigs POST] in ~${ms}ms`); } } diff --git a/app/my/rigs/page.tsx b/app/my/rigs/page.tsx index c4c43380..f836329b 100644 --- a/app/my/rigs/page.tsx +++ b/app/my/rigs/page.tsx @@ -1,14 +1,8 @@ -"use client"; - -import * as React from "react"; -import { useEffect, useMemo, useState } from "react"; -import { useForm } from "react-hook-form"; -import { z } from "zod"; -import { zodResolver } from "@hookform/resolvers/zod"; - +// app/my/rigs/page.tsx +import { cookies } from "next/headers"; +import SignOutButton from "@/components/SignOutButton"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; -import { Label } from "@/components/ui/label"; import { Select, SelectTrigger, @@ -17,311 +11,395 @@ import { SelectValue, } from "@/components/ui/select"; import { Textarea } from "@/components/ui/textarea"; -import { useToast } from "@/hooks/use-toast"; -import SignOutButton from "@/components/SignOutButton"; +import { Badge } from "@/components/ui/badge"; +import { z } from "zod"; -type Option = { id: string; label: string }; +// Server-only: load rig types with the user's bearer +const API_BASE = process.env.DIRECTUS_URL!; -// ───────────────────────────────────────────────────────────── -// Rig Type → options mapping -// These mirror existing “settings_*” targets used by the options endpoints. -// ───────────────────────────────────────────────────────────── -const RIG_TYPES = [ - { value: "settings_fiber", label: "Fiber (Galvo)" }, - { value: "settings_uv", label: "UV (Galvo)" }, - { value: "settings_co2gal", label: "CO₂ Galvo" }, -{ value: "settings_co2gan", label: "CO₂ Gantry" }, -] as const; +type RigType = { id: number | string; name: "fiber" | "uv" | "co2_galvo" | "co2_gantry" | string }; -type RigType = (typeof RIG_TYPES)[number]["value"]; -const isGantry = (t: RigType) => t === "settings_co2gan"; +async function loadRigTypes(): Promise { + const ck = await cookies(); + const at = ck.get("ma_at")?.value; + const headers: Record = { Accept: "application/json" }; + if (at) headers.Authorization = `Bearer ${at}`; -// ───────────────────────────────────────────────────────────── -// Validation – conditionally require the right lens field -// ───────────────────────────────────────────────────────────── -const RigSchema = z -.object({ - name: z.string().min(1, "Name is required"), - rig_type: z.enum(RIG_TYPES.map((r) => r.value) as [RigType, ...RigType[]]), - laser_source: z.string().min(1, "Source is required"), - laser_software: z.string().min(1, "Software is required"), - // lens fields (one of these is required depending on rig_type) - laser_scan_lens: z.string().optional(), - laser_focus_lens: z.string().optional(), - // optional galvo bits - laser_scan_lens_apt: z.string().optional(), - laser_scan_lens_exp: z.string().optional(), - notes: z.string().optional(), -}) -.superRefine((val, ctx) => { - if (isGantry(val.rig_type)) { - if (!val.laser_focus_lens) { - ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["laser_focus_lens"], message: "Focus lens is required for CO₂ Gantry" }); - } - // clear any galvo-only extras client-side - val.laser_scan_lens = undefined; - val.laser_scan_lens_apt = undefined; - val.laser_scan_lens_exp = undefined; - } else { - if (!val.laser_scan_lens) { - ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["laser_scan_lens"], message: "Scan lens is required" }); - } - // clear the gantry-only field - val.laser_focus_lens = undefined; + const res = await fetch( + `${API_BASE}/items/user_rig_type?fields=id,name&sort=sort`, + { cache: "no-store", headers } + ); + if (!res.ok) { + console.warn("[my/rigs] failed to load rig types:", await res.text()); + return []; } + const json = await res.json(); + return (json?.data ?? []) as RigType[]; +} + +// (optional) helper while converting string -> number if numeric +function coerceId(v: unknown) { + if (typeof v === "number") return v; + if (typeof v === "string") { + const n = Number(v); + return Number.isFinite(n) ? n : v; + } + return v ?? null; +} + +export default async function MyRigsPage() { + const rigTypes = await loadRigTypes(); + + return ( +
+
+

My Rigs

+ +
+ + + +
+ ); +} + +/* ────────────────────────────────────────────────────────── + * Client: Form + interactions + * ────────────────────────────────────────────────────────── */ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useToast } from "@/hooks/use-toast"; + +const FormSchema = z.object({ + name: z.string().min(2, "Please enter a name"), + rig_type: z.string().min(1, "Choose a rig type"), // we’ll coerce to number/uuid on submit + laser_source: z.string().optional().nullable(), + laser_focus_lens: z.string().optional().nullable(), + laser_scan_lens: z.string().optional().nullable(), + laser_scan_lens_apt: z.string().optional().nullable(), + laser_scan_lens_exp: z.string().optional().nullable(), + laser_software: z.string().optional().nullable(), + notes: z.string().optional().nullable(), }); -type RigForm = z.infer; +type FormValues = z.infer; -// ───────────────────────────────────────────────────────────── -// Helpers -// ───────────────────────────────────────────────────────────── -async function fetchOptions(url: string): Promise { - const res = await fetch(url); - if (!res.ok) return []; - const json = await res.json().catch(() => null); - return json?.data || []; -} - -// Map rig_type → /api/options target -function targetFor(type: RigType): string { - return type; -} - -export default function MyRigsPage() { +function RigBuilder({ rigTypes }: { rigTypes: RigType[] }) { const { toast } = useToast(); - const form = useForm({ - resolver: zodResolver(RigSchema), - defaultValues: { - name: "", - rig_type: "settings_fiber", - laser_source: "", - laser_software: "", - laser_scan_lens: "", - laser_focus_lens: "", - laser_scan_lens_apt: "", - laser_scan_lens_exp: "", - notes: "", - }, + const form = useForm({ + resolver: zodResolver(FormSchema), + defaultValues: { + name: "", + rig_type: "", + notes: "", + }, }); - const rigType = form.watch("rig_type"); + const selectedType = useMemo(() => { + const v = form.watch("rig_type"); + return rigTypes.find((r) => String(r.id) === String(v))?.name ?? ""; + }, [form, rigTypes]); - const [sources, setSources] = useState([]); - const [software, setSoftware] = useState([]); - const [scanLens, setScanLens] = useState([]); - const [focusLens, setFocusLens] = useState([]); - const [apertures, setApertures] = useState([]); - const [expanders, setExpanders] = useState([]); + // Options (client-side) – you already have these /api/options endpoints + const [laserSources, setLaserSources] = useState<{ id: string; label: string }[]>([]); + const [scanLenses, setScanLenses] = useState<{ id: string; label: string }[]>([]); + const [focusLenses, setFocusLenses] = useState<{ id: string; label: string }[]>([]); + const [softwares, setSoftwares] = useState<{ id: string; label: string }[]>([]); - // Load static options that don’t depend on rig_type + // Fetch option lists (simple, debounced-less; tweak as desired) useEffect(() => { - fetchOptions("/api/options/laser_software").then(setSoftware); + (async () => { + try { + // laser sources – all + const ls = await fetch("/api/options/laser_source").then((r) => r.json()); + setLaserSources(ls?.data ?? []); + } catch {} + try { + // scan lenses – list for all scan types + const sl = await fetch("/api/options/lens?target=settings_fiber").then((r) => r.json()); + setScanLenses(sl?.data ?? []); + } catch {} + try { + // focus lenses – gantry focuses + const fl = await fetch("/api/options/repeater-choices?key=laser_focus_lens").then((r) => r.json()); + setFocusLenses(fl?.data ?? []); + } catch {} + try { + const sw = await fetch("/api/options/repeater-choices?key=laser_software").then((r) => r.json()); + setSoftwares(sw?.data ?? []); + } catch {} + })(); }, []); - // Load options that do depend on rig_type - useEffect(() => { - const target = targetFor(rigType); - // laser sources filtered by wavelength - fetchOptions(`/api/options/laser_source?target=${encodeURIComponent(target)}`).then(setSources); + async function onSubmit(values: FormValues) { + try { + const payload = { + name: values.name, + rig_type: coerceId(values.rig_type), + laser_source: values.laser_source ? coerceId(values.laser_source) : null, + laser_focus_lens: + selectedType === "co2_gantry" && values.laser_focus_lens + ? coerceId(values.laser_focus_lens) + : null, + laser_scan_lens: + selectedType !== "co2_gantry" && values.laser_scan_lens + ? coerceId(values.laser_scan_lens) + : null, + laser_scan_lens_apt: + selectedType !== "co2_gantry" && values.laser_scan_lens_apt + ? coerceId(values.laser_scan_lens_apt) + : null, + laser_scan_lens_exp: + selectedType !== "co2_gantry" && values.laser_scan_lens_exp + ? coerceId(values.laser_scan_lens_exp) + : null, + laser_software: values.laser_software ? coerceId(values.laser_software) : null, + notes: values.notes ?? null, + }; - // lens: our /api/options/lens endpoint already returns: - // - SCAN lenses for galvo targets (with mm + F-number label) - // - FOCUS lenses for CO₂ gantry target (label=name) - fetchOptions(`/api/options/lens?target=${encodeURIComponent(target)}`).then((data) => { - if (isGantry(rigType)) { - setFocusLens(data); - setScanLens([]); - } else { - setScanLens(data); - setFocusLens([]); + const res = await fetch("/api/my/rigs", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + + const json = await res.json().catch(() => ({})); + if (!res.ok) { + throw new Error(json?.error || json?.errors?.[0]?.message || res.statusText); } - }); - // Optional galvo-only option lists (safe to fetch; we’ll hide in UI for gantry) - fetchOptions("/api/options/laser_scan_lens_apt").then(setApertures); - fetchOptions("/api/options/laser_scan_lens_exp").then(setExpanders); - }, [rigType]); - - // When the rig type flips, wipe fields that no longer apply - useEffect(() => { - if (isGantry(rigType)) { - form.setValue("laser_scan_lens", ""); - form.setValue("laser_scan_lens_apt", ""); - form.setValue("laser_scan_lens_exp", ""); - } else { - form.setValue("laser_focus_lens", ""); + toast({ title: "Saved!", description: `Rig created (id: ${json?.data?.id ?? "?"}).` }); + form.reset({ name: "", rig_type: "", notes: "" }); + // You might also trigger a refetch on the RigList (simple hacky way below) + document.dispatchEvent(new CustomEvent("rigs:refresh")); + } catch (err: any) { + toast({ + title: "Failed to save rig", + description: String(err?.message || err), + variant: "destructive", + }); } - }, [rigType, form]); - - async function onSubmit(values: RigForm) { - const res = await fetch("/api/my/rigs", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(values), - }); - - if (!res.ok) { - const msg = (await res.json().catch(() => ({} as any)))?.error || res.statusText; - toast({ title: "Failed to save rig", description: msg }); - return; - } - toast({ title: "Rig saved", description: "Your rig has been created." }); - form.reset({ ...form.getValues(), name: "" }); } - // ───────────────────────────────────────────────────────── - // UI - // ───────────────────────────────────────────────────────── return ( -
-
-

My Rigs

- +
+
+

New Rig

+ {selectedType ? {selectedType} : null}
-
+ {/* Name */}
- - -

{form.formState.errors.name?.message}

+ + + {form.formState.errors.name ? ( +

{form.formState.errors.name.message}

+ ) : null}
- {/* Rig Type */} + {/* Rig Type (IDs) */}
- + + {form.formState.errors.rig_type ? ( +

{form.formState.errors.rig_type.message}

+ ) : null}
- {/* Source */} + {/* Laser Source */}
- + -

{form.formState.errors.laser_source?.message}

{/* Software */}
- + -

{form.formState.errors.laser_software?.message}

- {/* Lens fields (conditional) */} - {!isGantry(rigType) ? ( - <> - {/* SCAN lens (galvo) */} + {/* Focus lens – ONLY for co2_gantry */} + {selectedType === "co2_gantry" && (
- + -

{form.formState.errors.laser_scan_lens?.message}

-
- - {/* Optional galvo bits */} -
-
- - -
- -
- - -
-
- - ) : ( - // FOCUS lens (gantry) -
- - -

{form.formState.errors.laser_focus_lens?.message}

)} - {/* Notes */} -
- -