diff --git a/src/components/admin/catalog/catalog-maintenance-panel.tsx b/src/components/admin/catalog/catalog-maintenance-panel.tsx
index abc210910b..34dacbd5da 100644
--- a/src/components/admin/catalog/catalog-maintenance-panel.tsx
+++ b/src/components/admin/catalog/catalog-maintenance-panel.tsx
@@ -31,6 +31,7 @@ interface Health {
idMismatchVsFurnidata: number;
catalogDuplicates: number;
catalogBcDuplicates: number;
+ catalogPageDuplicates: number;
}
export function CatalogMaintenancePanel() {
@@ -167,6 +168,11 @@ export function CatalogMaintenancePanel() {
value={health.catalogBcDuplicates}
hint="same product listed twice in builder club"
/>
+
>
)}
diff --git a/src/lib/services/furni-maintenance.ts b/src/lib/services/furni-maintenance.ts
index 18421243ab..c876d9692c 100644
--- a/src/lib/services/furni-maintenance.ts
+++ b/src/lib/services/furni-maintenance.ts
@@ -112,6 +112,7 @@ export interface FurniHealth {
idMismatchVsFurnidata: number;
catalogDuplicates: number;
catalogBcDuplicates: number;
+ catalogPageDuplicates: number;
}
/** Read-only snapshot of how far the furniture tables have drifted. */
@@ -145,6 +146,13 @@ export async function getFurniHealth(): Promise {
GROUP BY ${sql.raw(bcCols)} HAVING COUNT(*) > 1
) g`);
+ const [cpd] = await query<{ c: number }>(sql`
+ SELECT COUNT(*) c FROM (
+ SELECT caption_save, parent_id FROM catalog_pages
+ WHERE caption_save <> ''
+ GROUP BY caption_save, parent_id HAVING COUNT(*) > 1
+ ) g`);
+
const furniData = (await readFurniData()) as Record<
string,
{ furnitype?: Array<{ id?: unknown; classname?: unknown }> }
@@ -176,6 +184,7 @@ export async function getFurniHealth(): Promise {
idMismatchVsFurnidata: idMismatch,
catalogDuplicates: Number(cd?.c ?? 0),
catalogBcDuplicates: Number(cdb?.c ?? 0),
+ catalogPageDuplicates: Number(cpd?.c ?? 0),
};
}
@@ -204,6 +213,67 @@ export interface DedupResult {
remapped: number;
catalogRemoved: number;
catalogBcRemoved: number;
+ catalogPageRemoved: number;
+}
+
+export interface CatalogPageDedupResult {
+ groups: number;
+ removed: number;
+ remapped: number;
+}
+
+/**
+ * Collapse duplicate catalog_pages that share the same (caption_save, parent_id)
+ * into a single survivor so the catalog tree never renders double links. The
+ * lowest id wins; its catalog_items (and builder-club items) are moved onto the
+ * survivor and any child pages are reparented to it, then the duplicates are
+ * deleted. Every write runs inside one transaction.
+ */
+export async function dedupeCatalogPages(): Promise {
+ const groups = await query<{ caption_save: string; parent_id: number }>(sql`
+ SELECT caption_save, parent_id
+ FROM catalog_pages
+ WHERE caption_save <> ''
+ GROUP BY caption_save, parent_id
+ HAVING COUNT(*) > 1`);
+ if (groups.length === 0) return { groups: 0, removed: 0, remapped: 0 };
+
+ let removed = 0;
+ let remapped = 0;
+ await withFurniDataLock(async () => {
+ await db.transaction(async (tx) => {
+ for (const g of groups) {
+ const pages = await query<{ id: number }>(sql`
+ SELECT id FROM catalog_pages
+ WHERE caption_save = ${g.caption_save} AND parent_id = ${g.parent_id}
+ ORDER BY id ASC`);
+ if (pages.length < 2) continue;
+ const [winner, ...losers] = pages.map((p) => p.id);
+ for (const loser of losers) {
+ const ci = (await tx.execute(sql`
+ UPDATE catalog_items SET page_id = ${winner} WHERE page_id = ${loser}`)) as unknown as [
+ Record,
+ unknown,
+ ];
+ remapped += Number(ci[0]?.affectedRows ?? 0);
+ if (await exists("catalog_items_bc")) {
+ const bci = (await tx.execute(sql`
+ UPDATE catalog_items_bc SET page_id = ${winner} WHERE page_id = ${loser}`)) as unknown as [
+ Record,
+ unknown,
+ ];
+ remapped += Number(bci[0]?.affectedRows ?? 0);
+ }
+ // Keep any child pages by reparenting them onto the survivor.
+ await tx.execute(sql`
+ UPDATE catalog_pages SET parent_id = ${winner} WHERE parent_id = ${loser}`);
+ await tx.execute(sql`DELETE FROM catalog_pages WHERE id = ${loser}`);
+ removed++;
+ }
+ }
+ });
+ });
+ return { groups: groups.length, removed, remapped };
}
/**
@@ -286,6 +356,7 @@ export async function removeDuplicateItemsBase(): Promise {
remapped,
catalogRemoved: 0,
catalogBcRemoved: 0,
+ catalogPageRemoved: 0,
};
});
}
@@ -312,14 +383,22 @@ async function dedupCatalogTable(table: string): Promise {
}
/**
- * Full duplicate cleanup: items_base (by classname) + catalog_items +
- * catalog_items_bc (by identical product on the same page).
+ * Full duplicate cleanup: items_base (by classname) + catalog_pages (by
+ * caption_save + parent_id) + catalog_items / catalog_items_bc (by identical
+ * product on the same page). The page merge runs before the row dedup so items
+ * moved onto the survivor are still de-duplicated within the page afterwards.
*/
export async function removeDuplicates(): Promise {
const ib = await removeDuplicateItemsBase();
+ const pageDedup = await dedupeCatalogPages();
const catalogRemoved = await dedupCatalogTable("catalog_items");
const catalogBcRemoved = await dedupCatalogTable("catalog_items_bc");
- return { ...ib, catalogRemoved, catalogBcRemoved };
+ return {
+ ...ib,
+ catalogPageRemoved: pageDedup.removed,
+ catalogRemoved,
+ catalogBcRemoved,
+ };
}
export interface AlignResult {