Files
EpicNext-Cms/src/lib/services/catalog-repair.ts
T
openhands 44c34dbae6
CI / check (push) Successful in 45s
CI / release (push) Skipped
CI / deploy (push) Successful in 1m13s
fix: catalog-repair - allocate sequential ids in generateCatalogSql
Previously allocateCatalogItemId was called per entry, but since generation
does not INSERT, MAX(id) never advanced and every entry got the same id.
Now MAX(id) is read once and a local counter hands out sequential ids.
2026-08-02 19:57:21 +02:00

210 lines
5.7 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 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 };
});
}