From 2619bec165b03fb5f72377d18935b99d2b743604 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sat, 5 Sep 2026 17:02:26 +0200 Subject: [PATCH] feat(catalog): queue furniture imports with history and matching file attachments --- .../admin/studio/import-attachment/route.ts | 37 +++ .../admin/studio/import-jobs/route.test.ts | 116 +++++++++ src/app/api/admin/studio/import-jobs/route.ts | 83 ++++++ .../admin/studio/furniture-jobs.tsx | 161 ++++++++++++ src/components/admin/studio/import-review.tsx | 67 ++++- src/components/admin/studio/studio-client.tsx | 242 ++---------------- src/components/admin/studio/studio-types.ts | 1 + src/instrumentation.ts | 15 ++ src/lib/furni/import-job.ts | 28 ++ src/lib/services/furni-attachment.test.ts | 33 +++ src/lib/services/furni-attachment.ts | 56 ++++ src/lib/services/furni-import.ts | 34 ++- src/lib/services/furni-job-store.test.ts | 61 +++++ src/lib/services/furni-job-store.ts | 58 +++++ src/lib/services/furni-job-worker.test.ts | 114 +++++++++ src/lib/services/furni-job-worker.ts | 142 ++++++++++ 16 files changed, 1016 insertions(+), 232 deletions(-) create mode 100644 src/app/api/admin/studio/import-attachment/route.ts create mode 100644 src/app/api/admin/studio/import-jobs/route.test.ts create mode 100644 src/app/api/admin/studio/import-jobs/route.ts create mode 100644 src/components/admin/studio/furniture-jobs.tsx create mode 100644 src/lib/furni/import-job.ts create mode 100644 src/lib/services/furni-attachment.test.ts create mode 100644 src/lib/services/furni-attachment.ts create mode 100644 src/lib/services/furni-job-store.test.ts create mode 100644 src/lib/services/furni-job-store.ts create mode 100644 src/lib/services/furni-job-worker.test.ts create mode 100644 src/lib/services/furni-job-worker.ts diff --git a/src/app/api/admin/studio/import-attachment/route.ts b/src/app/api/admin/studio/import-attachment/route.ts new file mode 100644 index 00000000..ff36971b --- /dev/null +++ b/src/app/api/admin/studio/import-attachment/route.ts @@ -0,0 +1,37 @@ +import { withAdmin } from "@/lib/api-handler"; +import { apiError, apiOk } from "@/lib/api-response"; +import { validateClassnames } from "@/lib/furni/studio-inspection"; +import { PERMS } from "@/lib/permission-slugs"; +import { stageFurnitureAttachment } from "@/lib/services/furni-attachment"; +export const POST = withAdmin( + { permission: PERMS.ASSETS_IMPORT, maxBodyBytes: 52 * 1024 * 1024 }, + async (request, ctx) => { + const form = await request.formData(), + classname = form.get("classname"), + file = form.get("file"); + if ( + typeof classname !== "string" || + !validateClassnames([classname]) || + !(file instanceof File) || + file.size === 0 || + file.size > 50 * 1024 * 1024 + ) + return apiError( + "Select a .nitro file for this furniture (maximum 50 MB)", + 400, + ); + try { + const attachmentId = await stageFurnitureAttachment( + Buffer.from(await file.arrayBuffer()), + classname, + ctx.session.user.id, + ); + return apiOk({ attachmentId }); + } catch (error) { + return apiError( + error instanceof Error ? error.message : "Invalid .nitro file", + 400, + ); + } + }, +); diff --git a/src/app/api/admin/studio/import-jobs/route.test.ts b/src/app/api/admin/studio/import-jobs/route.test.ts new file mode 100644 index 00000000..f18a0dfc --- /dev/null +++ b/src/app/api/admin/studio/import-jobs/route.test.ts @@ -0,0 +1,116 @@ +import { randomUUID } from "node:crypto"; +import { NextRequest } from "next/server"; +import { beforeEach, expect, it, vi } from "vitest"; +import { PERMS } from "@/lib/permission-slugs"; + +const mocks = vi.hoisted(() => ({ + guard: vi.fn(), + create: vi.fn(), + list: vi.fn(), + after: vi.fn(), + ping: vi.fn(), + source: vi.fn(), +})); +vi.mock("@/lib/api-handler", () => ({ + withAdmin: (options: unknown, handler: unknown) => { + mocks.guard(options); + return handler; + }, +})); +vi.mock("next/server", async (original) => ({ + ...(await original()), + after: mocks.after, +})); +vi.mock("@/lib/redis", () => ({ redis: { ping: mocks.ping } })); +vi.mock("@/lib/services/furni-job-worker", () => ({ + drainFurnitureImports: vi.fn(), +})); +vi.mock("@/lib/services/furni-job-store", () => ({ + ImportJobStore: class { + create = mocks.create; + list = mocks.list; + }, +})); +vi.mock("@/lib/services/clone-sources", () => ({ getSource: mocks.source })); + +import { GET, POST } from "./route"; + +const item = { + id: 1, + classname: "nft_china_light", + name: "Lamp", + description: "", + type: "wallitem", + revision: 70317, + category: "other", +}; +const ctx = { session: { user: { id: 7 } } } as never; +const request = (body: unknown) => + new NextRequest("http://localhost/api/admin/studio/import-jobs", { + method: "POST", + body: JSON.stringify(body), + }); +beforeEach(() => { + mocks.create.mockClear(); + mocks.list.mockClear(); + mocks.after.mockClear(); + mocks.ping.mockResolvedValue("PONG"); + mocks.source.mockResolvedValue(null); + mocks.create.mockImplementation((job) => job); + mocks.list.mockResolvedValue([]); +}); +it("requires asset import permission", () => { + expect(mocks.guard).toHaveBeenCalledWith({ permission: PERMS.ASSETS_IMPORT }); +}); +it("queues a durable owner-scoped job and schedules execution after responding", async () => { + const response = await POST( + request({ id: randomUUID(), items: [item, item] }), + ctx, + ); + expect(response.status).toBe(200); + expect(mocks.create.mock.calls[0][0]).toMatchObject({ + userId: 7, + state: "queued", + items: [{ ...item, state: "pending" }], + }); + expect(mocks.after).toHaveBeenCalledOnce(); +}); +it("rejects traversal before saving work", async () => { + expect( + ( + await POST( + request({ + id: randomUUID(), + items: [{ ...item, classname: "../bad" }], + }), + ctx, + ) + ).status, + ).toBe(400); + expect(mocks.create).not.toHaveBeenCalled(); +}); +it("does not accept work when the worker lease service is unavailable", async () => { + mocks.ping.mockRejectedValue(Error("offline")); + expect( + (await POST(request({ id: randomUUID(), items: [item] }), ctx)).status, + ).toBe(503); + expect(mocks.create).not.toHaveBeenCalled(); +}); +it("rejects a missing configured source", async () => { + expect( + ( + await POST( + request({ id: randomUUID(), sourceId: "gone", items: [item] }), + ctx, + ) + ).status, + ).toBe(404); +}); +it("only returns the current operators history", async () => { + mocks.list.mockResolvedValue([ + { id: "a", userId: 7 }, + { id: "b", userId: 9 }, + ]); + const response = await GET(request({}), ctx); + expect((await response.json()).jobs).toEqual([{ id: "a", userId: 7 }]); +}); diff --git a/src/app/api/admin/studio/import-jobs/route.ts b/src/app/api/admin/studio/import-jobs/route.ts new file mode 100644 index 00000000..b91128b1 --- /dev/null +++ b/src/app/api/admin/studio/import-jobs/route.ts @@ -0,0 +1,83 @@ +import { after } from "next/server"; +import { z } from "zod"; +import { withAdmin } from "@/lib/api-handler"; +import { apiError, apiOk } from "@/lib/api-response"; +import { validateClassnames } from "@/lib/furni/studio-inspection"; +import { PERMS } from "@/lib/permission-slugs"; +import { redis } from "@/lib/redis"; +import { getSource } from "@/lib/services/clone-sources"; +import { ImportJobStore } from "@/lib/services/furni-job-store"; +import { drainFurnitureImports } from "@/lib/services/furni-job-worker"; + +const schema = z.object({ + id: z.uuid().refine((value) => value[14] === "4"), + sourceId: z.string().max(100).optional(), + translate: z.boolean().default(false), + langs: z + .array(z.string().regex(/^[a-z]{2,3}$/)) + .max(25) + .optional(), + items: z + .array( + z.object({ + id: z.number().int().nonnegative(), + classname: z.string(), + name: z.string().min(1).max(500), + description: z.string().max(5000), + type: z.enum(["flooritem", "wallitem"]), + revision: z.number().int().nonnegative(), + category: z.string().max(200), + attachmentId: z.uuid().optional(), + }), + ) + .min(1) + .max(500), +}); +export const GET = withAdmin( + { permission: PERMS.ASSETS_IMPORT }, + async (_request, ctx) => { + after(drainFurnitureImports); + const jobs = (await new ImportJobStore().list()) + .filter((job) => job.userId === ctx.session.user.id) + .reverse() + .slice(0, 30); + return apiOk({ jobs }); + }, +); +export const POST = withAdmin( + { permission: PERMS.ASSETS_IMPORT }, + async (request, ctx) => { + const parsed = schema.safeParse(await request.json().catch(() => null)); + if ( + !parsed.success || + !validateClassnames(parsed.data.items.map((item) => item.classname)) + ) + return apiError( + "Invalid furniture import request (maximum 500 items)", + 400, + ); + if (!redis || (await redis.ping().catch(() => null)) !== "PONG") + return apiError( + "Background import queue is temporarily unavailable. Please retry shortly.", + 503, + ); + if (parsed.data.sourceId && !(await getSource(parsed.data.sourceId))) + return apiError("Source not found", 404); + const store = new ImportJobStore(), + body = parsed.data; + const createdAt = new Date().toISOString(); + const unique = [ + ...new Map(body.items.map((item) => [item.classname, item])).values(), + ]; + const job = await store.create({ + ...body, + userId: ctx.session.user.id, + createdAt, + updatedAt: createdAt, + state: "queued", + items: unique.map((item) => ({ ...item, state: "pending" })), + }); + after(drainFurnitureImports); + return apiOk({ job }); + }, +); diff --git a/src/components/admin/studio/furniture-jobs.tsx b/src/components/admin/studio/furniture-jobs.tsx new file mode 100644 index 00000000..7d3e19e9 --- /dev/null +++ b/src/components/admin/studio/furniture-jobs.tsx @@ -0,0 +1,161 @@ +"use client"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { adminFetch } from "@/lib/admin-fetch"; +import type { ImportJob, ImportJobItem } from "@/lib/furni/import-job"; +export function useFurnitureJobs(onComplete: () => void) { + const [jobs, setJobs] = useState([]), + [busy, setBusy] = useState(false), + [error, setError] = useState(""); + const completed = useRef(new Set()), + callback = useRef(onComplete); + callback.current = onComplete; + const request = useRef<{ payload: string; id: string } | null>(null); + const submitting = useRef(false); + const refresh = useCallback(async () => { + try { + const response = await adminFetch("/api/admin/studio/import-jobs"); + const data = await response.json(); + if (!response.ok) + throw Error( + data.error || `Import history unavailable (${response.status})`, + ); + setJobs(data.jobs); + setError(""); + let changed = false; + for (const job of data.jobs as ImportJob[]) + if ( + (job.state === "completed" || job.state === "interrupted") && + !completed.current.has(job.id) + ) { + completed.current.add(job.id); + changed = true; + } + if (changed) callback.current(); + } catch (error) { + setError( + error instanceof Error ? error.message : "Cannot load import history", + ); + } + }, []); + useEffect(() => { + void refresh(); + const timer = setInterval(() => void refresh(), 5000); + return () => clearInterval(timer); + }, [refresh]); + const submit = async ( + items: ImportJobItem[], + options: { sourceId?: string; translate: boolean; langs?: string[] }, + ) => { + if (submitting.current) return false; + submitting.current = true; + setBusy(true); + const body = { items, ...options }, + payload = JSON.stringify(body); + if (request.current?.payload !== payload) + request.current = { payload, id: crypto.randomUUID() }; + try { + const response = await adminFetch("/api/admin/studio/import-jobs", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ...body, id: request.current.id }), + }); + const data = await response.json(); + if (!response.ok) + throw Error( + data.error || `Could not queue import (${response.status})`, + ); + request.current = null; + await refresh(); + toast.success( + "Import queued. You can leave this page and return to its history.", + ); + return true; + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Could not queue import", + ); + return false; + } finally { + submitting.current = false; + setBusy(false); + } + }; + return { jobs, busy, error, submit, refresh }; +} +export function FurnitureJobHistory({ + state, +}: { + state: ReturnType; +}) { + return ( +
+ + Import history ·{" "} + { + state.jobs.filter( + (j) => j.state === "queued" || j.state === "running", + ).length + }{" "} + active + +
+ {state.error &&

{state.error}

} + {!state.jobs.length &&

No background imports yet.

} + {state.jobs.map((job) => { + const done = job.items.filter((i) => i.state === "done"), + failed = job.items.filter((i) => i.state === "failed"); + return ( +
+ + {new Date(job.createdAt).toLocaleString()} · {job.state} ·{" "} + {done.length}/{job.items.length} imported · {failed.length}{" "} + failed + +
    + {job.items.map((item) => ( +
  • + {item.classname} · {item.state} + {item.itemId ? ` · ID ${item.itemId}` : ""} + {item.error && ( +

    + {item.error} +

    + )} + {[...new Set(item.warnings ?? [])].map((warning) => ( +

    + {warning} +

    + ))} +
  • + ))} +
+ {failed.length > 0 && job.state !== "running" && ( + + )} +
+ ); + })} +
+
+ ); +} diff --git a/src/components/admin/studio/import-review.tsx b/src/components/admin/studio/import-review.tsx index b727af81..90d9b1c8 100644 --- a/src/components/admin/studio/import-review.tsx +++ b/src/components/admin/studio/import-review.tsx @@ -1,5 +1,6 @@ "use client"; import { useState } from "react"; +import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -9,6 +10,7 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; +import { adminFetch } from "@/lib/admin-fetch"; import { previewAutoCatalog } from "@/lib/furni/auto-catalog"; import { compareFurniture } from "@/lib/furni/studio-inspection"; import { @@ -24,6 +26,7 @@ export function ImportReview({ source, translation, onCancel, + busy = false, onConfirm, }: { items: FurniItem[]; @@ -31,13 +34,41 @@ export function ImportReview({ source: string; translation: string; onCancel: () => void; + busy?: boolean; onConfirm: (items: FurniItem[]) => void; }) { + const [attachments, setAttachments] = useState>({}); + const [uploading, setUploading] = useState(null); + async function attach(item: FurniItem, file: File) { + setUploading(item.classname); + try { + const form = new FormData(); + form.set("classname", item.classname); + form.set("file", file); + const response = await adminFetch("/api/admin/studio/import-attachment", { + method: "POST", + body: form, + }); + const data = await response.json(); + if (!response.ok) throw Error(data.error || "Upload failed"); + setAttachments((current) => ({ + ...current, + [item.classname]: data.attachmentId, + })); + toast.success("Matching .nitro attached to this import"); + } catch (error) { + toast.error(error instanceof Error ? error.message : "Upload failed"); + } finally { + setUploading(null); + } + } const inspection = useFurnitureInspection( items.map((item) => item.classname), ); const assets = useSourceAssetChecks(items, sourceId, inspection); - const unavailable = assets.items.filter((item) => item.state === "missing"); + const unavailable = assets.items.filter( + (item) => item.state === "missing" && !attachments[item.classname], + ); const [expanded, setExpanded] = useState(items[0]?.classname ?? ""); const [step, setStep] = useState<"review" | "confirm">("review"); const [page, setPage] = useState(0); @@ -73,7 +104,7 @@ export function ImportReview({ { - if (!open) onCancel(); + if (!open && !busy && !uploading) onCancel(); }} > @@ -208,6 +239,27 @@ export function ImportReview({ {expanded === item.classname && (
+ {local.nitro.exists !== true && ( + + )}
)} @@ -278,6 +330,7 @@ export function ImportReview({