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.
235 lines
6.3 KiB
TypeScript
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);
|
|
}
|