Files
EpicNext-Cms/src/app/api/admin/import/furni/resync/route.ts
T
openhands 8638e81444
CI / check (push) Successful in 4m10s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 2m4s
refactor(db): typed query helpers, shared test FormData helper
Replace raw db.execute tuple casts with queryRows/rowsFrom/execResult/
affectedRows helpers from lib/db, drop redundant mysql2 casts on typed
query builders, and centralize per-test fakeForm into test/fake-form.
Update db mocks in tests so helpers resolve against mocked execute.
2026-09-17 21:02:57 +02:00

235 lines
6.3 KiB
TypeScript

import { existsSync } from "node:fs";
import { asc, inArray, sql } from "drizzle-orm";
import { apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
import { db, ItemsBase, queryRows } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import {
appendFurniEntriesBatch,
buildFurniEntry,
getFurnitureDataPath,
readFurniData,
} from "@/lib/services/furni-data";
import { lookupOfficialHabboFurni } from "@/lib/services/habbo-furnidata-cache";
import { rcon } from "@/lib/services/rcon";
/**
* POST /api/admin/import/furni/resync
*
* Modes (query params, pick one):
* ?days=N — items imported in the last N days (from admin_audit_log).
* ?missing=1 — every items_base row whose classname is absent from
* FurnitureData.json (authoritative for "what's broken").
* ?all=1 — every items_base row (rebuilds entries for all imports).
*
* Default: ?days=7
*
* Entries are upserted by classname so running this repeatedly is safe.
* Triggers RCON updateCatalog + updateItems at the end.
*/
export const POST = withAdmin(
{ permission: PERMS.ASSETS_IMPORT },
async (request) => {
const qp = request.nextUrl.searchParams;
const mode: "days" | "missing" | "broken" | "all" =
qp.get("all") === "1"
? "all"
: qp.get("broken") === "1"
? "broken"
: qp.get("missing") === "1"
? "missing"
: "days";
const days = Math.max(
1,
Math.min(parseInt(qp.get("days") || "7", 10) || 7, 365),
);
const targetIds = await resolveTargetIds(mode, days);
if (targetIds.length === 0) {
return apiOk({
mode,
days: mode === "days" ? days : undefined,
examined: 0,
resynced: 0,
failed: 0,
errors: [],
});
}
// Process in chunks to keep memory flat and make progress visible.
const CHUNK = 500;
let resynced = 0;
const errors: Array<{ classname: string; message: string }> = [];
for (let i = 0; i < targetIds.length; i += CHUNK) {
const slice = targetIds.slice(i, i + CHUNK);
const rows = await db
.select({
id: ItemsBase.id,
spriteId: ItemsBase.spriteId,
itemName: ItemsBase.itemName,
publicName: ItemsBase.publicName,
type: ItemsBase.type,
width: ItemsBase.width,
length: ItemsBase.length,
stackHeight: ItemsBase.stackHeight,
})
.from(ItemsBase)
.where(inArray(ItemsBase.id, slice));
const entries: Array<{
entry: Record<string, unknown>;
itemType: string;
}> = [];
for (const row of rows) {
try {
const classname = row.itemName;
let officialHabbo = null;
try {
officialHabbo = await lookupOfficialHabboFurni(classname);
} catch {
/* optional */
}
const entry = buildFurniEntry({
id: row.id,
classname,
revision: officialHabbo?.revision ?? 0,
category: officialHabbo?.category || "unknown",
name: row.publicName || officialHabbo?.name || classname,
description: officialHabbo?.description || "",
spriteId: row.spriteId,
dims: {
x: row.width,
y: row.length,
z: Math.round(Number(row.stackHeight)),
},
metadata: null,
officialHabbo,
});
entries.push({ entry, itemType: row.type === "i" ? "i" : "s" });
} catch (err) {
errors.push({
classname: row.itemName,
message: (err as Error).message,
});
}
}
if (entries.length > 0) {
try {
await appendFurniEntriesBatch(entries);
resynced += entries.length;
} catch (err) {
errors.push({
classname: `_chunk_${i}_${i + slice.length}_`,
message: `FurnitureData.json write failed: ${(err as Error).message}`,
});
break;
}
}
}
let rconOk = false;
try {
const a = await rcon.updateCatalog();
const b = await rcon.updateItems();
rconOk = a && b;
} catch {
/* logged in rcon service */
}
return apiOk({
mode,
days: mode === "days" ? days : undefined,
examined: targetIds.length,
resynced,
failed: errors.length,
rconOk,
errors: errors.slice(0, 50),
});
},
);
async function resolveTargetIds(
mode: "days" | "missing" | "broken" | "all",
days: number,
): Promise<number[]> {
if (mode === "all") {
const rows = await db
.select({ id: ItemsBase.id })
.from(ItemsBase)
.orderBy(asc(ItemsBase.id));
return rows.map((r) => r.id);
}
if (mode === "missing" || mode === "broken") {
const furniDataPath = await getFurnitureDataPath();
if (!existsSync(furniDataPath)) {
const rows = await db
.select({ id: ItemsBase.id })
.from(ItemsBase)
.orderBy(asc(ItemsBase.id));
return rows.map((r) => r.id);
}
const furniData = (await readFurniData()) as {
roomitemtypes?: { furnitype: Array<Record<string, unknown>> };
wallitemtypes?: { furnitype: Array<Record<string, unknown>> };
};
const byClassname = new Map<string, Record<string, unknown>>();
for (const section of [furniData.roomitemtypes, furniData.wallitemtypes]) {
for (const e of section?.furnitype ?? []) {
if (typeof e?.classname === "string") byClassname.set(e.classname, e);
}
}
const rows = await db
.select({
id: ItemsBase.id,
itemName: ItemsBase.itemName,
spriteId: ItemsBase.spriteId,
width: ItemsBase.width,
length: ItemsBase.length,
})
.from(ItemsBase)
.orderBy(asc(ItemsBase.id));
if (mode === "missing") {
return rows.filter((r) => !byClassname.has(r.itemName)).map((r) => r.id);
}
// broken: entry present but inconsistent with items_base
return rows
.filter((r) => {
const e = byClassname.get(r.itemName);
if (!e) return true;
const entryId = Number(e.id ?? -1);
if (entryId !== r.id && entryId !== r.spriteId) return true;
const xdim = Number(e.xdim ?? 0);
const ydim = Number(e.ydim ?? 0);
if (xdim <= 0 || ydim <= 0) return true;
if (xdim !== r.width || ydim !== r.length) return true;
return false;
})
.map((r) => r.id);
}
// mode === 'days'
const sinceIso = new Date(
Date.now() - days * 24 * 60 * 60 * 1000,
).toISOString();
const sinceMysql = sinceIso.slice(0, 19).replace("T", " ");
const auditRows = await queryRows<{ target_id: number }>(sql`
SELECT DISTINCT target_id
FROM admin_audit_log
WHERE action = 'furni_import'
AND target = 'ItemsBase'
AND target_id IS NOT NULL
AND created_at >= ${sinceMysql}
ORDER BY target_id ASC
`);
return auditRows.map((r) => r.target_id).filter(Boolean);
}