- add repairOrphanedCatalog: remove catalog_items rows whose item_ids only reference missing items_base entries, strip orphaned ids from mixed rows - add repairDuplicateClassnames: merge items_base duplicates into one canonical row per classname, remap references, delete duplicate rows - add 'repair structural issues' checkbox + result display in audit UI
409 lines
12 KiB
TypeScript
409 lines
12 KiB
TypeScript
import { sql } from "drizzle-orm";
|
|
import { db } from "@/lib/db";
|
|
import {
|
|
readFurniData,
|
|
withFurniDataLock,
|
|
writeFurniData,
|
|
} from "@/lib/services/furni-data";
|
|
import {
|
|
autoPriceFurni,
|
|
getOrCreateCategoryPage,
|
|
} from "@/lib/services/furni-import";
|
|
|
|
export interface MissingCatalogEntry {
|
|
classname: string;
|
|
itemId: number;
|
|
itemType: string;
|
|
}
|
|
|
|
export interface CatalogSqlResult {
|
|
statements: string[];
|
|
sql: string;
|
|
count: number;
|
|
}
|
|
|
|
function escSqlLiteral(value: string): string {
|
|
return value.replace(/'/g, "''");
|
|
}
|
|
|
|
/**
|
|
* Build standalone `catalog_items` INSERT statements for items_base entries
|
|
* that exist (with assets) but have no catalog entry yet. Ids and prices are
|
|
* resolved the same way the app's own imports do it (autoPriceFurni + category
|
|
* page + next catalog id), so running the SQL matches an app-managed import.
|
|
*/
|
|
export async function generateCatalogSql(
|
|
entries: MissingCatalogEntry[],
|
|
): Promise<CatalogSqlResult> {
|
|
const statements: string[] = [
|
|
"-- AtomCMS catalog repair",
|
|
`-- Generated ${new Date().toISOString()}`,
|
|
`-- ${entries.length} item(s) to add`,
|
|
"START TRANSACTION;",
|
|
];
|
|
|
|
// The generation pass does not INSERT, so MAX(id) would never advance and
|
|
// per-entry allocateCatalogItemId would hand out the same id every time.
|
|
// Read the current max once and hand out sequential ids from there.
|
|
const [maxIdResult] = (await db.execute(sql`
|
|
SELECT MAX(id) as maxId FROM catalog_items
|
|
`)) as unknown as [Array<{ maxId: number | bigint | null }>, unknown];
|
|
let nextId = Number(maxIdResult[0]?.maxId ?? 0) + 1;
|
|
|
|
let generated = 0;
|
|
for (const entry of entries) {
|
|
let pageId: number;
|
|
try {
|
|
pageId = await getOrCreateCategoryPage(entry.classname, entry.itemType);
|
|
} catch {
|
|
continue;
|
|
}
|
|
const price = autoPriceFurni(entry.classname);
|
|
statements.push(
|
|
`INSERT INTO catalog_items (id, page_id, item_ids, catalog_name, cost_credits, cost_points, points_type, amount, order_number, offer_id, extradata) VALUES (${nextId}, ${pageId}, ${entry.itemId}, '${escSqlLiteral(entry.classname)}', ${price.credits}, ${price.points}, ${price.pointsType}, 1, 1, '-1', '');`,
|
|
);
|
|
nextId++;
|
|
generated++;
|
|
}
|
|
|
|
statements.push("COMMIT;");
|
|
return { statements, sql: statements.join("\n"), count: generated };
|
|
}
|
|
|
|
/** Execute generated INSERT statements directly against the database. */
|
|
export async function applyCatalogSql(
|
|
statements: string[],
|
|
): Promise<{ applied: number; failed: number }> {
|
|
let applied = 0;
|
|
let failed = 0;
|
|
for (const statement of statements) {
|
|
const trimmed = statement.trim();
|
|
if (
|
|
!trimmed ||
|
|
trimmed.startsWith("--") ||
|
|
trimmed === "START TRANSACTION;" ||
|
|
trimmed === "COMMIT;"
|
|
) {
|
|
continue;
|
|
}
|
|
try {
|
|
await db.execute(sql.raw(trimmed));
|
|
applied++;
|
|
} catch {
|
|
failed++;
|
|
}
|
|
}
|
|
return { applied, failed };
|
|
}
|
|
|
|
export interface OrphanedCatalogResult {
|
|
removed: number;
|
|
cleaned: number;
|
|
}
|
|
|
|
export interface DuplicateClassnameResult {
|
|
merged: number;
|
|
rowsRemoved: number;
|
|
remapped: number;
|
|
}
|
|
|
|
/**
|
|
* Remove catalog_items rows whose item_ids only reference non-existent
|
|
* items_base entries, and strip orphaned ids from mixed rows. Also deletes
|
|
* catalog_items_limited rows that pointed at the removed catalog entries so no
|
|
* new orphans are left behind.
|
|
*/
|
|
export async function repairOrphanedCatalog(): Promise<OrphanedCatalogResult> {
|
|
const [itemRows] = (await db.execute(sql`
|
|
SELECT id FROM items_base
|
|
`)) as unknown as [Array<{ id: number }>, unknown];
|
|
const valid = new Set<number>();
|
|
for (const row of itemRows) valid.add(Number(row.id));
|
|
|
|
const [catalogRows] = (await db.execute(sql`
|
|
SELECT id, item_ids FROM catalog_items
|
|
`)) as unknown as [Array<{ id: number; item_ids: string }>, unknown];
|
|
|
|
let removed = 0;
|
|
let cleaned = 0;
|
|
|
|
for (const row of catalogRows) {
|
|
const ids = (row.item_ids ?? "")
|
|
.split(";")
|
|
.map((s) => Number(s.trim()))
|
|
.filter(Boolean);
|
|
if (ids.length === 0) continue;
|
|
const kept = ids.filter((id) => valid.has(id));
|
|
if (kept.length === ids.length) continue;
|
|
|
|
if (kept.length === 0) {
|
|
await db.execute(sql`
|
|
DELETE FROM catalog_items_limited WHERE catalog_item_id = ${row.id}
|
|
`);
|
|
await db.execute(sql`DELETE FROM catalog_items WHERE id = ${row.id}`);
|
|
removed++;
|
|
} else {
|
|
await db.execute(sql`
|
|
UPDATE catalog_items SET item_ids = ${kept.join(";")} WHERE id = ${row.id}
|
|
`);
|
|
cleaned++;
|
|
}
|
|
}
|
|
|
|
return { removed, cleaned };
|
|
}
|
|
|
|
/**
|
|
* Merge duplicate classnames in items_base into a single canonical row per
|
|
* classname, remapping every reference (catalog item id lists, player items,
|
|
* presents, marketplace, etc.) to the canonical id, then delete the duplicate
|
|
* rows. The canonical id is the one players actually purchase through
|
|
* catalog_items when present, otherwise the lowest id.
|
|
*/
|
|
export async function repairDuplicateClassnames(): Promise<DuplicateClassnameResult> {
|
|
const [itemRows] = (await db.execute(sql`
|
|
SELECT id, item_name FROM items_base
|
|
`)) as unknown as [Array<{ id: number; item_name: string }>, unknown];
|
|
|
|
const byName = new Map<string, number[]>();
|
|
for (const row of itemRows) {
|
|
const arr = byName.get(row.item_name) ?? [];
|
|
arr.push(Number(row.id));
|
|
byName.set(row.item_name, arr);
|
|
}
|
|
|
|
const [catalogRows] = (await db.execute(sql`
|
|
SELECT item_ids FROM catalog_items
|
|
`)) as unknown as [Array<{ item_ids: string }>, unknown];
|
|
const referenced = new Set<number>();
|
|
for (const row of catalogRows) {
|
|
for (const part of (row.item_ids ?? "").split(";")) {
|
|
const id = Number(part.trim());
|
|
if (id) referenced.add(id);
|
|
}
|
|
}
|
|
|
|
const remap = new Map<number, number>();
|
|
let merged = 0;
|
|
for (const ids of byName.values()) {
|
|
if (ids.length <= 1) continue;
|
|
merged++;
|
|
const sorted = [...ids].sort((a, b) => a - b);
|
|
const canonical = sorted.find((id) => referenced.has(id)) ?? sorted[0];
|
|
for (const id of ids) {
|
|
if (id !== canonical) remap.set(id, canonical);
|
|
}
|
|
}
|
|
|
|
if (remap.size === 0) return { merged: 0, rowsRemoved: 0, remapped: 0 };
|
|
|
|
let remapped = 0;
|
|
|
|
// Semicolon-separated item_ids columns.
|
|
const listTables: Array<{ table: string; idCol: string }> = [
|
|
{ table: "catalog_items", idCol: "id" },
|
|
{ table: "catalog_items_bc", idCol: "id" },
|
|
{ table: "logs_shop_purchases", idCol: "id" },
|
|
];
|
|
for (const { table, idCol } of listTables) {
|
|
const [rows] = (await db.execute(
|
|
sql.raw(`SELECT ${idCol} AS pk, item_ids AS v FROM ${table}`),
|
|
)) as unknown as [Array<{ pk: number; v: string | null }>, unknown];
|
|
for (const row of rows) {
|
|
if (!row.v) continue;
|
|
const parts = row.v
|
|
.split(";")
|
|
.map((s) => s.trim())
|
|
.filter(Boolean);
|
|
let changed = false;
|
|
const out: number[] = [];
|
|
const seen = new Set<number>();
|
|
for (const part of parts) {
|
|
const id = Number(part);
|
|
const mapped = remap.get(id) ?? id;
|
|
if (mapped !== id) changed = true;
|
|
if (seen.has(mapped)) continue;
|
|
seen.add(mapped);
|
|
out.push(mapped);
|
|
}
|
|
if (!changed) continue;
|
|
await db.execute(
|
|
sql.raw(
|
|
`UPDATE ${table} SET item_ids = '${out.join(";")}' WHERE ${idCol} = ${row.pk}`,
|
|
),
|
|
);
|
|
remapped++;
|
|
}
|
|
}
|
|
|
|
// Single item_id columns that reference items_base.id. Remap in chunks via
|
|
// a single CASE statement per table to keep the query count low even with
|
|
// thousands of duplicate rows.
|
|
const singleTables = [
|
|
"catalog_items_limited",
|
|
"items",
|
|
"items_presents",
|
|
"pet_items",
|
|
"pet_foods",
|
|
"pet_drinks",
|
|
"items_hoppers",
|
|
"marketplace_items",
|
|
"items_highscore_data",
|
|
"items_crackable",
|
|
"builders_club_items",
|
|
"recycler_prizes",
|
|
"gift_wrappers",
|
|
"youtube_playlists",
|
|
"room_trade_log_items",
|
|
"calendar_rewards",
|
|
"room_trax_playlist",
|
|
"trax_playlist",
|
|
"website_event_prizes",
|
|
"room_templates_items",
|
|
"crafting_recipes_ingredients",
|
|
"website_rare_values",
|
|
"logs_economy",
|
|
];
|
|
|
|
const entries = [...remap.entries()];
|
|
const CHUNK = 400;
|
|
for (const table of singleTables) {
|
|
for (let i = 0; i < entries.length; i += CHUNK) {
|
|
const chunk = entries.slice(i, i + CHUNK);
|
|
const cases = chunk
|
|
.map(([dup, canonical]) => `WHEN ${dup} THEN ${canonical}`)
|
|
.join(" ");
|
|
const dupList = chunk.map(([dup]) => `${dup}`).join(",");
|
|
try {
|
|
const [result] = (await db.execute(
|
|
sql.raw(
|
|
`UPDATE ${table} SET item_id = CASE item_id ${cases} ELSE item_id END WHERE item_id IN (${dupList})`,
|
|
),
|
|
)) as unknown as [Record<string, unknown>, unknown];
|
|
remapped += Number(result.affectedRows ?? 0);
|
|
} catch {
|
|
// Table may not exist on some hotel schemas — skip it.
|
|
}
|
|
}
|
|
}
|
|
|
|
const dupIds = entries.map(([dup]) => dup);
|
|
await db.execute(
|
|
sql.raw(`DELETE FROM items_base WHERE id IN (${dupIds.join(",")})`),
|
|
);
|
|
|
|
return { merged, rowsRemoved: dupIds.length, remapped };
|
|
}
|
|
|
|
export interface FurniDataRepairResult {
|
|
added: number;
|
|
removedDuplicates: number;
|
|
}
|
|
|
|
function buildMinimalFurniEntry(item: {
|
|
id: number;
|
|
item_name: string;
|
|
public_name: string;
|
|
}): Record<string, unknown> {
|
|
return {
|
|
id: item.id,
|
|
classname: item.item_name,
|
|
revision: 0,
|
|
category: "unknown",
|
|
defaultdir: 0,
|
|
xdim: 1,
|
|
ydim: 1,
|
|
partcolors: { color: [] },
|
|
name: item.public_name || item.item_name,
|
|
description: "",
|
|
adurl: "",
|
|
offerid: item.id,
|
|
buyout: false,
|
|
rentofferid: -1,
|
|
rentbuyout: false,
|
|
bc: false,
|
|
excludeddynamic: false,
|
|
customparams: "",
|
|
specialtype: 0,
|
|
canstandon: false,
|
|
cansiton: false,
|
|
canlayon: false,
|
|
furniline: "",
|
|
environment: "",
|
|
rare: false,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Add FurnitureData.json entries for items_base rows that are missing from the
|
|
* file, and remove duplicate classnames (keeping the first occurrence).
|
|
*/
|
|
export async function repairFurniData(
|
|
items: Array<{
|
|
id: number;
|
|
item_name: string;
|
|
public_name: string;
|
|
type: string;
|
|
}>,
|
|
): Promise<FurniDataRepairResult> {
|
|
if (items.length === 0) return { added: 0, removedDuplicates: 0 };
|
|
|
|
return withFurniDataLock(async () => {
|
|
const furniData = (await readFurniData()) as Record<
|
|
string,
|
|
{ furnitype: Array<Record<string, unknown>> }
|
|
>;
|
|
|
|
// 1) Deduplicate classnames per section (keep the first occurrence).
|
|
let removedDuplicates = 0;
|
|
for (const section of ["roomitemtypes", "wallitemtypes"] as const) {
|
|
const sec = furniData[section];
|
|
if (!sec?.furnitype) continue;
|
|
const seen = new Set<string>();
|
|
const deduped = sec.furnitype.filter((e) => {
|
|
const classname = typeof e.classname === "string" ? e.classname : "";
|
|
if (!classname) return true;
|
|
if (seen.has(classname)) {
|
|
removedDuplicates++;
|
|
return false;
|
|
}
|
|
seen.add(classname);
|
|
return true;
|
|
});
|
|
if (deduped.length !== sec.furnitype.length) {
|
|
furniData[section].furnitype = deduped;
|
|
}
|
|
}
|
|
|
|
// 2) Add entries for items_base rows missing from the file.
|
|
const existing = new Set<string>();
|
|
for (const section of ["roomitemtypes", "wallitemtypes"] as const) {
|
|
for (const e of furniData[section]?.furnitype ?? []) {
|
|
if (typeof e.classname === "string") existing.add(e.classname);
|
|
}
|
|
}
|
|
const added: Array<{ entry: Record<string, unknown>; itemType: string }> =
|
|
[];
|
|
for (const item of items) {
|
|
if (existing.has(item.item_name)) continue;
|
|
added.push({
|
|
entry: buildMinimalFurniEntry(item),
|
|
itemType: item.type,
|
|
});
|
|
}
|
|
|
|
// 3) Write once if anything changed.
|
|
if (removedDuplicates > 0 || added.length > 0) {
|
|
for (const { entry, itemType } of added) {
|
|
const sectionKey = itemType === "i" ? "wallitemtypes" : "roomitemtypes";
|
|
if (!furniData[sectionKey]) {
|
|
furniData[sectionKey] = { furnitype: [] };
|
|
}
|
|
furniData[sectionKey].furnitype.push(entry);
|
|
}
|
|
await writeFurniData(furniData as Record<string, unknown>);
|
|
}
|
|
return { added: added.length, removedDuplicates };
|
|
});
|
|
}
|