Files
EpicNext-Cms/src/app/api/admin/import/furni/route.ts
T
openhands 54f2bc5e0c
CI / check (push) Successful in 32s
CI / release (push) Skipped
CI / deploy (push) Successful in 1m16s
Add clone source selection to Studio furni browser
Allow the admin Studio to browse and import furniture from custom retro
hotel sources, not just official Habbo furnidata.

- GET /api/admin/import/furni?source=<id> lists a clone source's
  furnidata (via getCloneList) and returns a per-item iconUrl from the
  source's iconBaseUrl so previews render correctly
- Studio client gets a source selector dropdown (official Habbo plus all
  configured clone_sources) that resets the selection and reloads the
  list when switched
- Single and batch imports now send sourceId so SWF/nitro/icon assets
  are fetched from the selected hotel's CDN
- Preview images prefer the source iconUrl with the existing fallback
  chain; header shows the active source name
2026-08-15 15:43:51 +02:00

916 lines
27 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 {
autoPriceFurni,
CATEGORY_PAGE,
classifyFurni,
cleanupConvertedSwfs,
deleteImportedItem,
downloadFile,
ensureDirectories,
IMPORTED_PAGE_CAPTION_SAVE,
importSingleFurni,
resetCatalogPageCache,
} 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, nitroMissingResult] =
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 COUNT(*) as cnt FROM items_base WHERE item_name NOT IN (SELECT catalog_name 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,
);
const notInCatalog = Number(
(nitroMissingResult as unknown as [[{ cnt: bigint }], unknown])[0][0]
.cnt,
);
// Count missing .nitro files by checking filesystem
const [importedItems] = (await db.execute(sql`
SELECT ib.item_name FROM items_base ib
INNER JOIN catalog_items ci ON ci.catalog_name = ib.item_name
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 });
}
// 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
INNER JOIN catalog_items ci ON ci.catalog_name = ib.item_name
`)) 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,
);
}
const succeeded = results.filter((r) => r.ok).length;
const failed = results.filter((r) => !r.ok).length;
return apiOk({ batch: true, succeeded, failed, results });
}
// ── 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");
}
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,
dimensions: result.dimensions,
spriteCount: result.spriteCount,
warnings: result.warnings.length > 0 ? result.warnings : undefined,
});
},
);
// ── 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,
});
},
);