feat: dedupe duplicate catalog_pages to prevent double links

The maintenance "Fix alles" now collapses catalog_pages that share the same
caption_save + parent_id into a single survivor (moving catalog_items and
reparenting child pages before deleting duplicates), so the catalog tree
never renders double links. The duplicate-page count is also surfaced in the
health panel.
This commit is contained in:
openhands committed 2026-08-24 18:16:28 +02:00
1 parent 02f8ffc0bc
commit d6af754211
2 files changed
+88 -3

No files matched your search

@@ -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"
/>
<Stat
label="Duplicate catalog pages"
value={health.catalogPageDuplicates}
hint="same caption under the same parent (double links)"
/>
</>
)}
</CardContent>
+82 -3
View File
@@ -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<FurniHealth> {
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<FurniHealth> {
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<CatalogPageDedupResult> {
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<string, unknown>,
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<string, unknown>,
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<DedupResult> {
remapped,
catalogRemoved: 0,
catalogBcRemoved: 0,
catalogPageRemoved: 0,
};
});
}
@@ -312,14 +383,22 @@ async function dedupCatalogTable(table: string): Promise<number> {
}
/**
* 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<DedupResult> {
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 {