Files
EpicNext-Cms/src/app/api/admin/import/furni/route.ts
T
openhands 984b5bf793
CI / check (push) Successful in 28s
CI / release (push) Skipped
CI / deploy (push) Successful in 54s
feat: auto-repair sprite_id drift after every import
The emulator sends items_base.sprite_id to the client and Nitro resolves
the furniture by looking up that id in FurnitureData.json. Legacy rows
where sprite_id drifted from the id made the client render a completely
different item (e.g. a wired or custom asset instead of the purchased
furniture).

Add verifyAndFixSpriteIds() which repairs any row whose sprite_id no
longer matches its id while the local furnidata carries the item under
that id, and run it after every import path (single, batch, batch-regen,
clone) next to the offer_id rebuild.
2026-08-21 14:02:42 +02:00

1035 lines
31 KiB
TypeScript

import { existsSync, promises as fs } from "node:fs";
import path from "node:path";
import { and, eq, inArray, sql } from "drizzle-orm";
import { withAdmin } from "@/lib/api-handler";
import { apiError, apiOk } from "@/lib/api-response";
import { CatalogPages, db, ItemsBase } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { logAudit } from "@/lib/services/audit";
import { getCloneList } from "@/lib/services/clone-import";
import { getSource } from "@/lib/services/clone-sources";
import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs";
import {
rebuildCatalogOfferIds,
reconcileFurniDataWithItemsBase,
verifyAndFixSpriteIds,
} from "@/lib/services/furni-data";
import {
autoPriceFurni,
CATEGORY_PAGE,
classifyFurni,
cleanupConvertedSwfs,
deleteImportedItem,
downloadFile,
ensureDirectories,
ensureFurniOwnership,
IMPORTED_PAGE_CAPTION_SAVE,
importSingleFurni,
reconcileImportedOfferIds,
resetCatalogPageCache,
syncAssetsToGamedataBundle,
verifyAndFixInteractionModesCount,
} from "@/lib/services/furni-import";
import { getOfficialHabboFurnidata } from "@/lib/services/habbo-furnidata-cache";
import { listFurniture } from "@/lib/services/habbofurni";
import { rcon } from "@/lib/services/rcon";
import { convertSwfToNitro } from "@/lib/services/swf-to-nitro";
// Re-export for batch route backward compatibility
export {
ensureDirectories,
importSingleFurni,
} from "@/lib/services/furni-import";
export type { ImportSingleResult } from "@/types/furni";
// ── Delete imported item or clean up SWF files ──────────────────────
export const DELETE = withAdmin(
{ permission: PERMS.ASSETS_IMPORT },
async (request, ctx) => {
const classname = request.nextUrl.searchParams.get("classname");
// ── Delete specific imported item ─────────────────────────────────
if (classname) {
try {
const { spriteId, deletedFiles, errors } =
await deleteImportedItem(classname);
// Refresh game server caches
try {
await rcon.updateCatalog();
await rcon.updateItems();
} catch (err) {
errors.push(`RCON: ${(err as Error).message}`);
}
logAudit({
userId: ctx.session.user.id,
action: "furni_delete",
target: "ItemsBase",
targetId: spriteId,
after: {
classname,
deletedFiles,
errors: errors.length > 0 ? errors : undefined,
},
});
return apiOk({
classname,
deletedFiles,
errors: errors.length > 0 ? errors : undefined,
});
} catch (err) {
return apiError((err as Error).message, 404);
}
}
// ── Clean up SWF files that have been converted to Nitro ──────────
const result = await cleanupConvertedSwfs();
if (result.total === 0 && result.deleted === 0) {
return apiOk({ deleted: 0, message: "SWF directory does not exist" });
}
return apiOk({
deleted: result.deleted,
total: result.total,
remaining: result.remaining,
errors: result.errors.length > 0 ? result.errors : undefined,
});
},
);
// ── Search furni via official Habbo furnidata ───────────────────────
export const GET = withAdmin(
{ permission: PERMS.ASSETS_IMPORT },
async (request) => {
const { nitroDir } = await getFurniAssetDirs();
// Stats endpoint
const action = request.nextUrl.searchParams.get("action");
if (action === "stats") {
const [totalResult, catalogResult, allIdsResult, referencedResult] =
await Promise.all([
db.execute(sql`SELECT COUNT(*) as cnt FROM items_base`),
db.execute(
sql`SELECT COUNT(DISTINCT item_ids) as cnt FROM catalog_items`,
),
db.execute(sql`SELECT id FROM items_base`),
db.execute(sql`SELECT item_ids FROM catalog_items`),
]);
const totalInDb = Number(
(totalResult as unknown as [[{ cnt: bigint }], unknown])[0][0].cnt,
);
const inCatalog = Number(
(catalogResult as unknown as [[{ cnt: bigint }], unknown])[0][0].cnt,
);
// An item is "in the catalog" when its items_base id is referenced by
// any catalog_items.item_ids (the canonical link — catalog_name can be
// a translated display name, so matching on it is unreliable).
const allIds = (
allIdsResult as unknown as [Array<{ id: number }>, unknown]
)[0].map((r) => Number(r.id));
const referenced = new Set<number>();
for (const row of (
referencedResult as unknown as [
Array<{ item_ids: string | null }>,
unknown,
]
)[0]) {
for (const part of String(row.item_ids ?? "").split(/[;,]/)) {
const n = Number(part.trim());
if (Number.isFinite(n) && n > 0) referenced.add(n);
}
}
const notInCatalog = allIds.filter((id) => !referenced.has(id)).length;
// Count missing .nitro files by checking filesystem
const [importedItems] = (await db.execute(sql`
SELECT ib.item_name FROM items_base ib
WHERE EXISTS (
SELECT 1 FROM catalog_items ci
WHERE FIND_IN_SET(ib.id, REPLACE(ci.item_ids, ';', ',')) > 0
)
LIMIT 10000
`)) as unknown as [Array<{ item_name: string }>, unknown];
let missingNitro = 0;
for (const item of importedItems) {
const starIdx = item.item_name.indexOf("*");
const base =
starIdx !== -1
? item.item_name.substring(0, starIdx)
: item.item_name;
const nitroPath = path.join(
/*turbopackIgnore: true*/ nitroDir,
`${base}.nitro`,
);
if (!existsSync(/*turbopackIgnore: true*/ nitroPath)) missingNitro++;
}
return apiOk({ totalInDb, inCatalog, notInCatalog, missingNitro });
}
// Live furnidata reconcile (without hotel RCON reload)
if (action === "live-reconcile") {
const furniReconcile = await reconcileFurniDataWithItemsBase();
const offerRebuild = await rebuildCatalogOfferIds();
const spriteVerify = await verifyAndFixSpriteIds();
return apiOk({
furniDataFixedIds: furniReconcile.fixedIds,
furniDataFixedOfferIds: furniReconcile.fixedOfferIds,
furniDataMissing: furniReconcile.missing,
furniDataConflicts: furniReconcile.conflicts,
offerRebuildChecked: offerRebuild.checked,
offerRebuildFixed: offerRebuild.fixed,
spriteChecked: spriteVerify.checked,
spriteFixed: spriteVerify.fixed,
});
}
// Missing nitro endpoint: returns only imported items missing .nitro files
if (action === "missing-nitro") {
const pageNum = parseInt(
request.nextUrl.searchParams.get("page") || "1",
10,
);
const perPage = 50;
const [importedItems] = (await db.execute(sql`
SELECT ib.item_name, ib.public_name, ib.sprite_id, ib.type FROM items_base ib
WHERE EXISTS (
SELECT 1 FROM catalog_items ci
WHERE FIND_IN_SET(ib.id, REPLACE(ci.item_ids, ';', ',')) > 0
)
`)) as unknown as [
Array<{
item_name: string;
public_name: string;
sprite_id: number;
type: string;
}>,
unknown,
];
// Filter to only those missing .nitro on filesystem
const missing = importedItems.filter((item) => {
const starIdx = item.item_name.indexOf("*");
const base =
starIdx !== -1
? item.item_name.substring(0, starIdx)
: item.item_name;
const p = path.join(
/*turbopackIgnore: true*/ nitroDir,
`${base}.nitro`,
);
return !existsSync(/*turbopackIgnore: true*/ p);
});
const total = missing.length;
const lastPage = Math.max(Math.ceil(total / perPage), 1);
const start = (pageNum - 1) * perPage;
const pageItems = missing.slice(start, start + perPage);
// Enrich with revision from official furnidata
const furnidata = await getOfficialHabboFurnidata();
const items = pageItems.map((item) => {
const fd =
furnidata.get(item.item_name) ||
furnidata.get(item.item_name.split("*")[0]) ||
furnidata.get(`${item.item_name}*0`);
return {
id: item.sprite_id,
classname: item.item_name,
name: item.public_name || item.item_name,
description: fd?.description || "",
type: item.type === "i" ? "wallitem" : "flooritem",
revision: fd?.revision || 0,
category: fd?.category || fd?.furniline || "",
alreadyImported: true,
nitroExists: false,
};
});
return apiOk({
items,
meta: { currentPage: pageNum, lastPage, total, perPage },
});
}
const search = request.nextUrl.searchParams.get("search") || "";
const page = parseInt(request.nextUrl.searchParams.get("page") || "1", 10);
// ── Browse a custom clone source (retro hotel furnidata) ────────
const sourceId = request.nextUrl.searchParams.get("source");
if (sourceId) {
const source = await getSource(sourceId);
if (!source) return apiError("Source not found", 404);
const { items, meta } = await getCloneList({
source,
search,
page,
perPage: 50,
filter: "all",
});
const sourceIconBase = source.iconBaseUrl?.trim().replace(/\/+$/, "");
const enriched = items.map((item) => {
const imported = item.present;
let nitroExists = false;
if (imported) {
const starIdx = item.classname.indexOf("*");
const base =
starIdx !== -1
? item.classname.substring(0, starIdx)
: item.classname;
const p = path.join(
/*turbopackIgnore: true*/ nitroDir,
`${base}.nitro`,
);
nitroExists = existsSync(/*turbopackIgnore: true*/ p);
}
return {
id: item.id,
classname: item.classname,
type: item.itemType === "i" ? "wallitem" : "flooritem",
name: item.name,
description: item.description,
revision: 0,
category: String(item.category ?? ""),
alreadyImported: imported,
nitroExists,
iconUrl: sourceIconBase
? `${sourceIconBase}/${encodeURIComponent(item.classname)}_icon.png`
: undefined,
};
});
return apiOk({
items: enriched,
source: { id: source.id, name: source.name },
meta: {
currentPage: meta.page,
lastPage: Math.max(Math.ceil(meta.total / meta.perPage), 1),
total: meta.total,
perPage: meta.perPage,
},
});
}
// Pull every imported classname once so we can both (a) sort non-imported
// items to the top *before* pagination and (b) skip the per-page lookup
// below. Single-column indexed query — fast even at 10k+ rows.
const allImported = await db
.select({ itemName: ItemsBase.itemName })
.from(ItemsBase);
const importedSet = new Set(
allImported.map((e) => e.itemName).filter((n): n is string => !!n),
);
const result = await listFurniture({
search: search || undefined,
page,
perPage: 50,
importedSet,
});
const enriched = result.items.map((item) => {
const imported = importedSet.has(item.classname);
let nitroExists = false;
if (imported) {
const starIdx = item.classname.indexOf("*");
const base =
starIdx !== -1
? item.classname.substring(0, starIdx)
: item.classname;
const p = path.join(
/*turbopackIgnore: true*/ nitroDir,
`${base}.nitro`,
);
nitroExists = existsSync(/*turbopackIgnore: true*/ p);
}
return { ...item, alreadyImported: imported, nitroExists };
});
return apiOk({ items: enriched, meta: result.meta });
},
);
// ── Import furni (single or batch) ──────────────────────────────────
export const POST = withAdmin(
{ permission: PERMS.ASSETS_IMPORT },
async (request, ctx) => {
const body = await request.json();
await ensureDirectories();
// ── Batch import ─────────────────────────────────────────────────
if (Array.isArray(body.items)) {
const { sourceId } = body as { sourceId?: string };
let source: Awaited<ReturnType<typeof getSource>> | null = null;
if (sourceId) source = await getSource(sourceId);
const results: Array<{
classname: string;
ok: boolean;
itemId?: number;
catalogItemId?: number | null;
dimensions?: { x: number; y: number; z: number };
spriteCount?: number;
warnings: string[];
error?: string;
}> = [];
for (const item of body.items) {
if (!item.classname || !item.name) {
results.push({
classname: item.classname || "unknown",
ok: false,
warnings: [],
error: "Missing classname or name",
});
continue;
}
try {
const result = await importSingleFurni({
id: item.id ?? 0,
classname: item.classname,
name: item.name,
description: item.description ?? "",
type: item.type ?? "flooritem",
revision: item.revision ?? 0,
category: item.category ?? "unknown",
updateExisting: body.updateExisting === true,
sourceSwfBaseUrl: source?.sourceSwfBaseUrl,
nitroBaseUrl: source?.nitroBaseUrl || undefined,
iconBaseUrl: source?.iconBaseUrl || undefined,
});
results.push({ classname: item.classname, ...result });
if (result.ok) {
logAudit({
userId: ctx.session.user.id,
action: "furni_import",
target: "ItemsBase",
targetId: result.itemId ?? 0,
after: {
classname: item.classname,
name: item.name,
type: item.type === "wallitem" ? "i" : "s",
catalogItemId: result.catalogItemId,
dimensions: result.dimensions,
batch: true,
warnings:
result.warnings.length > 0 ? result.warnings : undefined,
},
});
}
} catch (err) {
results.push({
classname: item.classname,
ok: false,
warnings: [],
error: (err as Error).message,
});
}
}
// Refresh caches once after batch
try {
await rcon.updateCatalog();
await rcon.updateItems();
} catch (err) {
console.warn(
"[import-furni] RCON update failed after batch:",
(err as Error).message,
);
}
// Reconcile offer_id with the furnidata sprite id across the tree
const reconciled = await reconcileImportedOfferIds();
// Keep asset + furnidata folders readable by the web server
const ownership = await ensureFurniOwnership();
// Backfill any missing nitros into the emulator's Gamedata bundle
const assetsSynced = await syncAssetsToGamedataBundle();
// Verify the furnidata matches the DB ids
const furniReconcile = await reconcileFurniDataWithItemsBase();
// Rebuild every catalog offer_id from the furnidata
const offerRebuild = await rebuildCatalogOfferIds();
// Repair any sprite_id drift so the client renders the right item
const spriteVerify = await verifyAndFixSpriteIds();
// Verify + fix interaction_modes_count once for the whole batch
const verification = await verifyAndFixInteractionModesCount();
const succeeded = results.filter((r) => r.ok).length;
const failed = results.filter((r) => !r.ok).length;
return apiOk({
batch: true,
succeeded,
failed,
results,
offerIdsFixed: reconciled.fixed,
ownershipFixed: ownership.fixed,
nitrosSynced: assetsSynced.copiedNitros,
iconsSynced: assetsSynced.copiedIcons,
furniDataFixedIds: furniReconcile.fixedIds,
furniDataFixedOfferIds: furniReconcile.fixedOfferIds,
furniDataMissing: furniReconcile.missing,
furniDataConflicts: furniReconcile.conflicts,
offerRebuildChecked: offerRebuild.checked,
offerRebuildFixed: offerRebuild.fixed,
spriteChecked: spriteVerify.checked,
spriteFixed: spriteVerify.fixed,
interactionFixed: verification.fixed,
});
}
// ── Single import ────────────────────────────────────────────────
const {
id,
classname,
name,
description,
type,
revision,
category,
sourceId,
updateExisting,
} = body;
if (!classname || !name) return apiError("Missing classname or name", 400);
let source: Awaited<ReturnType<typeof getSource>> | null = null;
if (sourceId) source = await getSource(sourceId);
const result = await importSingleFurni({
id: id ?? 0,
classname,
name,
description: description ?? "",
type: type ?? "flooritem",
revision: revision ?? 0,
category: category ?? "unknown",
updateExisting: updateExisting === true,
sourceSwfBaseUrl: source?.sourceSwfBaseUrl,
nitroBaseUrl: source?.nitroBaseUrl || undefined,
iconBaseUrl: source?.iconBaseUrl || undefined,
});
if (!result.ok) return apiError(result.error ?? "Import failed", 409);
// Refresh game server caches
try {
await rcon.updateCatalog();
await rcon.updateItems();
} catch (err) {
console.warn(
"[import-furni] RCON update failed:",
(err as Error).message,
);
result.warnings.push("RCON cache refresh failed");
}
// Reconcile offer_id with the furnidata sprite id across the tree
const reconciled = await reconcileImportedOfferIds();
// Keep asset + furnidata folders readable by the web server
const ownership = await ensureFurniOwnership();
// Backfill any missing nitros and icons into the emulator's Gamedata bundle
const assetsSynced = await syncAssetsToGamedataBundle();
// Verify the furnidata matches the DB ids
const furniReconcile = await reconcileFurniDataWithItemsBase();
// Rebuild every catalog offer_id from the furnidata
const offerRebuild = await rebuildCatalogOfferIds();
// Repair any sprite_id drift so the client renders the right item
const spriteVerify = await verifyAndFixSpriteIds();
// Verify + fix interaction_modes_count (single consolidated pass)
const verification = await verifyAndFixInteractionModesCount();
logAudit({
userId: ctx.session.user.id,
action: "furni_import",
target: "ItemsBase",
targetId: result.itemId ?? 0,
after: {
classname,
name,
spriteId: id ?? 0,
type: type === "wallitem" ? "i" : "s",
catalogItemId: result.catalogItemId,
dimensions: result.dimensions,
spriteCount: result.spriteCount,
warnings: result.warnings.length > 0 ? result.warnings : undefined,
},
});
return apiOk({
id: result.itemId,
catalogItemId: result.catalogItemId,
offerIdsFixed: reconciled.fixed,
ownershipFixed: ownership.fixed,
nitrosSynced: assetsSynced.copiedNitros,
iconsSynced: assetsSynced.copiedIcons,
furniDataFixedIds: furniReconcile.fixedIds,
furniDataFixedOfferIds: furniReconcile.fixedOfferIds,
furniDataMissing: furniReconcile.missing,
furniDataConflicts: furniReconcile.conflicts,
offerRebuildChecked: offerRebuild.checked,
offerRebuildFixed: offerRebuild.fixed,
spriteChecked: spriteVerify.checked,
spriteFixed: spriteVerify.fixed,
interactionFixed: verification.fixed,
spriteCount: result.spriteCount,
warnings: result.warnings,
});
},
);
// ── Re-generate missing .nitro files (PATCH) ─────────────────────────
export const PATCH = withAdmin(
{ permission: PERMS.ASSETS_IMPORT },
async (request) => {
const body = await request.json();
const items: Array<{ classname: string; revision: number }> =
body.items || [];
if (items.length === 0) return apiError("No items provided", 400);
await ensureDirectories();
const { swfDir, nitroDir } = await getFurniAssetDirs();
const results: Array<{ classname: string; ok: boolean; warning?: string }> =
[];
for (const item of items) {
const starIdx = item.classname.indexOf("*");
const baseClassname =
starIdx !== -1 ? item.classname.substring(0, starIdx) : item.classname;
const nitroPath = path.join(
/*turbopackIgnore: true*/ nitroDir,
`${baseClassname}.nitro`,
);
// Skip if .nitro already exists
if (existsSync(/*turbopackIgnore: true*/ nitroPath)) {
results.push({
classname: item.classname,
ok: true,
warning: "Already exists",
});
continue;
}
// Resolve revision: try furnidata first, then spriteId as fallback
let rev = item.revision || 0;
if (rev === 0) {
const furnidata = await getOfficialHabboFurnidata();
const fd =
furnidata.get(item.classname) ||
furnidata.get(baseClassname) ||
furnidata.get(`${baseClassname}*0`);
if (fd) rev = fd.revision;
}
const swfPath = path.join(swfDir, `${baseClassname}.swf`);
// Download SWF if not present — try multiple revision candidates
if (!existsSync(swfPath)) {
// Build list of revisions to try: known rev, spriteId, common fallbacks
const [spriteId] = await db
.select({ spriteId: ItemsBase.spriteId })
.from(ItemsBase)
.where(eq(ItemsBase.itemName, item.classname))
.limit(1);
const revsToTry = [
...new Set([rev, spriteId?.spriteId || 0].filter((r) => r > 0)),
];
let downloaded = false;
for (const tryRev of revsToTry) {
const dl = await downloadFile(
`https://images.habbo.com/dcr/hof_furni/${tryRev}/${encodeURIComponent(baseClassname)}.swf`,
swfPath,
{ validate: "swf" },
);
if (dl.ok) {
rev = tryRev;
downloaded = true;
break;
}
}
if (!downloaded) {
results.push({
classname: item.classname,
ok: false,
warning: `SWF not found on Habbo CDN (tried revisions: ${revsToTry.join(", ") || "none"})`,
});
continue;
}
}
// Convert SWF → Nitro + update FurnitureData.json
try {
const swfBuffer = await fs.readFile(swfPath);
const conversion = convertSwfToNitro(swfBuffer, baseClassname);
await fs.writeFile(nitroPath, conversion.bundle);
// Add to FurnitureData.json if not already present
try {
const { buildFurniEntry, appendFurniEntry, readFurniData } =
await import("@/lib/services/furni-data");
const furniData = (await readFurniData()) as {
roomitemtypes?: { furnitype?: Array<{ classname?: string }> };
wallitemtypes?: { furnitype?: Array<{ classname?: string }> };
};
// Check if already in FurnitureData
const allEntries = [
...(furniData.roomitemtypes?.furnitype || []),
...(furniData.wallitemtypes?.furnitype || []),
];
const alreadyInFD = allEntries.some(
(e) =>
e.classname === baseClassname || e.classname === item.classname,
);
if (!alreadyInFD) {
const [dbItem] = await db
.select({
id: ItemsBase.id,
spriteId: ItemsBase.spriteId,
publicName: ItemsBase.publicName,
type: ItemsBase.type,
})
.from(ItemsBase)
.where(eq(ItemsBase.itemName, item.classname))
.limit(1);
const furnidata = await getOfficialHabboFurnidata();
const fd =
furnidata.get(item.classname) || furnidata.get(baseClassname);
const itemType = dbItem?.type === "i" ? "i" : "s";
const entry = buildFurniEntry({
id: dbItem?.spriteId || 0,
classname: baseClassname,
revision: rev,
category: fd?.category || "",
name: dbItem?.publicName || baseClassname,
description: fd?.description || "",
spriteId: dbItem?.spriteId || 0,
dims: conversion.dimensions || { x: 1, y: 1, z: 1 },
metadata: conversion.metadata || null,
officialHabbo: fd || null,
});
await appendFurniEntry(entry, itemType);
}
} catch (fdErr) {
console.warn(
`[regen] FurnitureData update skipped for ${item.classname}:`,
(fdErr as Error).message,
);
}
results.push({ classname: item.classname, ok: true });
} catch (err) {
results.push({
classname: item.classname,
ok: false,
warning: (err as Error).message,
});
}
}
const succeeded = results.filter((r) => r.ok).length;
const failed = results.filter((r) => !r.ok).length;
return apiOk({ succeeded, failed, results });
},
);
// ── Re-classify ALL imported items across all sub-pages + update prices (PUT) ──
// Supports ?dryrun=1 to preview changes without applying them.
export const PUT = withAdmin(
{ permission: PERMS.ASSETS_IMPORT },
async (request, ctx) => {
const isDryRun = request.nextUrl.searchParams.get("dryrun") === "1";
// 1. Find the root "imported_furni" page
const [parentPage] = await db
.select({ id: CatalogPages.id })
.from(CatalogPages)
.where(eq(CatalogPages.captionSave, IMPORTED_PAGE_CAPTION_SAVE))
.limit(1);
if (!parentPage) {
return apiOk({
dryRun: isDryRun,
moved: 0,
repriced: 0,
message: "No imported_furni page found — nothing to reorganize",
});
}
// 2. Collect ALL imported page IDs (parent + every imp_* sub-page)
const subPages = await db
.select({
id: CatalogPages.id,
captionSave: CatalogPages.captionSave,
})
.from(CatalogPages)
.where(eq(CatalogPages.parentId, parentPage.id));
const allImportedPageIds = [
String(parentPage.id),
...subPages.map((p) => String(p.id)),
];
// 3. Find ALL catalog_items across all imported pages
// page_id is VARCHAR in DB so we use string placeholders
const [allItems] = (await db.execute(sql`
SELECT id, item_ids, catalog_name, page_id, cost_credits
FROM catalog_items
WHERE CAST(page_id AS CHAR) IN (${sql.join(
allImportedPageIds.map((id) => sql`${id}`),
sql`, `,
)})
`)) as unknown as [
Array<{
id: number;
item_ids: string;
catalog_name: string;
page_id: string;
cost_credits: number;
}>,
unknown,
];
if (allItems.length === 0) {
return apiOk({
dryRun: isDryRun,
moved: 0,
repriced: 0,
message: "No imported items found — nothing to reorganize",
});
}
// 4. Build classname → type map from items_base
const uniqueItemIds = [
...new Set(allItems.map((i) => Number(i.item_ids))),
].filter((n) => !Number.isNaN(n));
const dbItems =
uniqueItemIds.length > 0
? await db
.select({
id: ItemsBase.id,
itemName: ItemsBase.itemName,
type: ItemsBase.type,
})
.from(ItemsBase)
.where(inArray(ItemsBase.id, uniqueItemIds))
: [];
const typeMap = new Map<string, string>();
for (const di of dbItems) {
if (di.itemName) typeMap.set(di.itemName, di.type || "s");
}
// 5. Pre-load category page map (captionSave → pageId) to avoid N+1 queries
const categoryPageMap = new Map<string, number | undefined>();
for (const sp of subPages) {
if (sp.captionSave) categoryPageMap.set(sp.captionSave, sp.id);
}
// Helper: resolve or create category page using the cached map
async function resolvePageId(catKey: string): Promise<string> {
const captionSave = `imp_${catKey}`;
const cached = categoryPageMap.get(captionSave);
if (cached !== undefined) return String(cached);
if (isDryRun) return "new";
// Create the page (only happens once per new category)
const catInfo = CATEGORY_PAGE[catKey] || CATEGORY_PAGE.other;
const parentId = parentPage.id;
await db.execute(sql`
INSERT INTO catalog_pages (caption_save, caption, page_layout, parent_id, min_rank, order_num, icon_image, enabled, visible, includes, page_headline, page_teaser, page_special, page_text1, page_text2, page_text_details, page_text_teaser)
VALUES (${captionSave}, ${catInfo.label}, 'default_3x3', ${parentId}, '1', ${catInfo.order}, ${catInfo.icon}, '1', '1', '', '', '', '', '', '', '', '')
`);
const [created] = await db
.select({ id: CatalogPages.id })
.from(CatalogPages)
.where(
and(
eq(CatalogPages.captionSave, captionSave),
eq(CatalogPages.parentId, parentId),
),
)
.limit(1);
categoryPageMap.set(captionSave, created?.id);
return String(created?.id);
}
// 6. Classify all items (pure computation + cached lookups, no per-item DB queries)
let moved = 0;
let repriced = 0;
let skipped = 0;
const errors: string[] = [];
const categoryCounts: Record<string, number> = {};
const pagesWithItems = new Set<string>();
// Collect batched updates: key = "pageId|credits|points|pointsType" → item ids
const updateBatches = new Map<string, number[]>();
// First pass: resolve any new category pages (max ~14 DB inserts)
const neededCategories = new Set<string>();
for (const item of allItems) {
const itemType = typeMap.get(item.catalog_name) || "s";
neededCategories.add(classifyFurni(item.catalog_name, itemType));
}
for (const catKey of neededCategories) {
try {
await resolvePageId(catKey);
} catch (err) {
errors.push(`create page ${catKey}: ${(err as Error).message}`);
}
}
// Second pass: classify items (no DB calls)
for (const item of allItems) {
const itemType = typeMap.get(item.catalog_name) || "s";
const catKey = classifyFurni(item.catalog_name, itemType);
categoryCounts[catKey] = (categoryCounts[catKey] || 0) + 1;
const captionSave = `imp_${catKey}`;
const cachedPageId = categoryPageMap.get(captionSave);
const correctPageIdStr =
cachedPageId !== undefined ? String(cachedPageId) : "new";
const price = autoPriceFurni(item.catalog_name);
const needsMove = item.page_id !== correctPageIdStr;
const needsReprice = item.cost_credits !== price.credits;
pagesWithItems.add(correctPageIdStr);
if (!needsMove && !needsReprice) {
skipped++;
continue;
}
if (needsMove) moved++;
if (needsReprice) repriced++;
if (!isDryRun) {
const batchKey = `${correctPageIdStr}|${price.credits}|${price.points}|${price.pointsType}`;
const batch = updateBatches.get(batchKey);
if (batch) {
batch.push(item.id);
} else {
updateBatches.set(batchKey, [item.id]);
}
}
}
// 7. Apply batched updates (one UPDATE per category/price combo instead of per item)
if (!isDryRun) {
for (const [batchKey, ids] of updateBatches) {
const [pageId, credits, points, pointsType] = batchKey.split("|");
try {
await db.execute(sql`
UPDATE catalog_items
SET page_id = ${pageId},
cost_credits = ${Number(credits)},
cost_points = ${Number(points)},
points_type = ${Number(pointsType)}
WHERE id IN (${sql.join(
ids.map((id) => sql`${id}`),
sql`, `,
)})
`);
} catch (err) {
errors.push(`batch update: ${(err as Error).message}`);
}
}
}
// 8. Cleanup empty sub-pages (only if NOT dry-run)
let pagesDeleted = 0;
if (!isDryRun) {
for (const subPage of subPages) {
const subPageIdStr = String(subPage.id);
if (!pagesWithItems.has(subPageIdStr)) {
try {
const [remaining] = (await db.execute(
sql`SELECT COUNT(*) as cnt FROM catalog_items WHERE CAST(page_id AS CHAR) = ${subPageIdStr}`,
)) as unknown as [Array<{ cnt: bigint }>, unknown];
const count = Number(remaining[0]?.cnt ?? 0);
if (count === 0) {
await db.execute(
sql`DELETE FROM catalog_pages WHERE id = ${subPage.id}`,
);
pagesDeleted++;
}
} catch (err) {
errors.push(
`cleanup ${subPage.captionSave}: ${(err as Error).message}`,
);
}
}
}
}
// 9. Refresh game server caches (only if NOT dry-run and changes were made)
if (!isDryRun && (moved > 0 || repriced > 0 || pagesDeleted > 0)) {
try {
await rcon.updateCatalog();
} catch (err) {
errors.push(`RCON: ${(err as Error).message}`);
}
}
if (!isDryRun) {
// Clear in-memory catalog page cache since we may have created/deleted pages
resetCatalogPageCache();
logAudit({
userId: ctx.session.user.id,
action: "furni_reorganize",
target: "CatalogItems",
targetId: parentPage.id,
after: {
moved,
repriced,
skipped,
pagesDeleted,
categoryCounts,
errors: errors.length > 0 ? errors : undefined,
},
});
}
return apiOk({
dryRun: isDryRun,
moved,
repriced,
skipped,
pagesDeleted,
total: allItems.length,
categoryCounts,
errors: errors.length > 0 ? errors : undefined,
});
},
);