Files
EpicNext-Cms/src/lib/services/furni-sync-queue.ts
T

115 lines
3.9 KiB
TypeScript

import { after } from "next/server";
import { apiError, apiOk } from "@/lib/api";
import { db, ItemsBase } from "@/lib/db";
import { getRequestId } from "@/lib/foundation/request-context";
import type { ImportJob, ImportJobItem } from "@/lib/furni/import-job";
import { validateClassnames } from "@/lib/furni/studio-inspection";
import { redis } from "@/lib/redis";
import { fetchSourceFurnidata } from "./clone-import";
import { listSources } from "./clone-sources";
import { ImportJobStore, validJobId } from "./furni-job-store";
import { drainFurnitureImports } from "./furni-job-worker";
import { getOfficialHabboFurnidata } from "./habbo-furnidata-cache";
export async function enqueueFurnitureSync(
request: Request,
userId: number,
kind: "official" | "clone",
) {
const body = await request.json().catch(() => null);
if (
!validJobId(body?.id) ||
(kind === "official" && body.sourceId !== undefined) ||
(body.sourceId !== undefined &&
(typeof body.sourceId !== "string" || body.sourceId.length > 100))
)
return apiError("Invalid synchronization request", 400);
const store = new ImportJobStore();
const existing = await store.read(body.id).catch((error) => {
if (error.code === "ENOENT") return null;
throw error;
});
if (existing) {
if (
existing.userId !== userId ||
existing.syncKind !== kind ||
existing.sourceId !== body.sourceId
)
return apiError("Request ID already used", 409);
after(drainFurnitureImports);
return apiOk({ job: existing });
}
if (!redis || (await redis.ping().catch(() => null)) !== "PONG")
return apiError("Background import queue is temporarily unavailable", 503);
const have = new Set(
(await db.select({ name: ItemsBase.itemName }).from(ItemsBase)).map(
(row) => row.name,
),
);
const items = new Map<string, ImportJobItem>();
if (kind === "official") {
for (const entry of (await getOfficialHabboFurnidata()).values()) {
if (have.has(entry.classname)) continue;
items.set(entry.classname, {
id: 0,
classname: entry.classname,
name: entry.name,
description: entry.description ?? "",
type: entry.category === "wallitem" ? "wallitem" : "flooritem",
revision: entry.revision ?? 0,
category: entry.category ?? "unknown",
});
}
} else {
const sources = (await listSources()).filter(
(source) => !body.sourceId || source.id === body.sourceId,
);
if (!sources.length) return apiError("No sources configured", 400);
// Discovery is read-only. Persist one complete snapshot, never a partial request.
for (const source of sources)
for (const entry of await fetchSourceFurnidata(source.furnidataUrl)) {
if (have.has(entry.classname) || items.has(entry.classname)) continue;
items.set(entry.classname, {
id: entry.id,
classname: entry.classname,
name: entry.name,
description: entry.description,
type: entry.itemType === "i" ? "wallitem" : "flooritem",
revision: Number(entry.revision) || 0,
category: String(entry.category ?? "unknown"),
cloneSourceId: source.id,
cloneEntry: entry,
});
}
}
if (items.size > (kind === "official" ? 500 : 2000))
return apiError(
"Too many missing items. Import a smaller selection from Studio.",
400,
);
if ([...items.keys()].some((classname) => !validateClassnames([classname])))
return apiError("Source contains invalid furniture identifiers", 400);
const now = new Date().toISOString();
const job: ImportJob = {
id: body.id,
userId,
syncKind: kind,
operationId: getRequestId(),
sourceId: body.sourceId,
translate: false,
createdAt: now,
updatedAt: now,
state: items.size ? "queued" : "completed",
items: [...items.values()].map((item) => ({ ...item, state: "pending" })),
};
const saved = await store.create(job);
if (
saved.userId !== userId ||
saved.syncKind !== kind ||
saved.sourceId !== body.sourceId
)
return apiError("Request ID already used", 409);
after(drainFurnitureImports);
return apiOk({ job: saved });
}