feat(catalog): queue furniture imports with history and matching file attachments
This commit is contained in:
1 parent
775d14f861
commit
2619bec165
16 files changed
+1016
-232
No files matched your search
@@ -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,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -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<typeof import("next/server")>()),
|
||||
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 }]);
|
||||
});
|
||||
@@ -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 });
|
||||
},
|
||||
);
|
||||
Reference in new issue
Block a user