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 {