/** * merge-locale-duplicates.ts * * Merges duplicate DE/EN documents into single Payload localized documents. * * Problem: Before native localization, DE and EN were stored as separate rows. * Now each should be one document with locale-specific data in the *_locales tables. * * Strategy: * 1. Products: Match by slug → Keep DE row as canonical, copy EN data, delete EN row * 2. Posts: Match by slug → Same strategy * 3. Pages: Match by slug map (impressum↔legal-notice, blog↔blog, etc.) → Same strategy */ import pg from 'pg'; const { Pool } = pg; const DB_URL = process.env.DATABASE_URI || process.env.POSTGRES_URI || `postgresql://payload:120in09oenaoinsd9iaidon@127.0.0.1:54322/payload`; const pool = new Pool({ connectionString: DB_URL }); async function q(query: string, values: unknown[] = []): Promise { const result = await pool.query(query, values); return result.rows as T[]; } async function mergeProducts() { console.log('\n── PRODUCTS ───────────────────────────────────────'); const pairs = await q<{ de_id: number; en_id: number; slug: string }>(` SELECT de.id as de_id, en.id as en_id, de_loc.slug as slug FROM products de JOIN products_locales de_loc ON de_loc._parent_id = de.id AND de_loc._locale = 'de' JOIN products_locales en_loc ON en_loc.slug = de_loc.slug AND en_loc._locale = 'en' JOIN products en ON en.id = en_loc._parent_id WHERE de.id != en.id `); console.log(`Found ${pairs.length} DE/EN product pairs to merge`); for (const { de_id, en_id, slug } of pairs) { console.log(` Merging: ${slug} (DE id=${de_id} ← EN id=${en_id})`); const [enData] = await q(` SELECT * FROM products_locales WHERE _parent_id = $1 AND _locale = 'en' `, [en_id]); if (enData) { await q(` INSERT INTO products_locales (title, description, application, content, _locale, _parent_id) VALUES ($1, $2, $3, $4, 'en', $5) ON CONFLICT (_locale, _parent_id) DO UPDATE SET title = EXCLUDED.title, description = EXCLUDED.description, application = EXCLUDED.application, content = EXCLUDED.content `, [enData.title, enData.description, enData.application, enData.content, de_id]); } // Move categories from EN to DE if DE has none await q(` UPDATE products_categories SET _parent_id = $1 WHERE _parent_id = $2 AND NOT EXISTS (SELECT 1 FROM products_categories WHERE _parent_id = $1) `, [de_id, en_id]); // Move images (rels) from EN to DE if DE has none await q(` UPDATE products_rels SET parent = $1 WHERE parent = $2 AND NOT EXISTS (SELECT 1 FROM products_rels WHERE parent = $1) `, [de_id, en_id]); // Copy featuredImage if DE is missing one await q(` UPDATE products SET featured_image_id = ( SELECT featured_image_id FROM products WHERE id = $2 ) WHERE id = $1 AND featured_image_id IS NULL `, [de_id, en_id]); // Delete EN locale entry and EN product row await q(`DELETE FROM products_locales WHERE _parent_id = $1`, [en_id]); await q(`DELETE FROM _products_v WHERE parent = $1`, [en_id]); await q(`DELETE FROM products WHERE id = $1`, [en_id]); console.log(` ✓ ${slug}`); } const [{ count }] = await q(`SELECT count(*) FROM products`); console.log(`Products remaining: ${count}`); } async function mergePosts() { console.log('\n── POSTS ──────────────────────────────────────────'); const pairs = await q<{ de_id: number; en_id: number; slug: string }>(` SELECT de.id as de_id, en.id as en_id, de_loc.slug as slug FROM posts de JOIN posts_locales de_loc ON de_loc._parent_id = de.id AND de_loc._locale = 'de' JOIN posts_locales en_loc ON en_loc.slug = de_loc.slug AND en_loc._locale = 'en' JOIN posts en ON en.id = en_loc._parent_id WHERE de.id != en.id `); console.log(`Found ${pairs.length} DE/EN post pairs to merge`); for (const { de_id, en_id, slug } of pairs) { console.log(` Merging post: ${slug} (DE id=${de_id} ← EN id=${en_id})`); const [enData] = await q(` SELECT * FROM posts_locales WHERE _parent_id = $1 AND _locale = 'en' `, [en_id]); if (enData) { await q(` INSERT INTO posts_locales (title, slug, excerpt, category, content, _locale, _parent_id) VALUES ($1, $2, $3, $4, $5, 'en', $6) ON CONFLICT (_locale, _parent_id) DO UPDATE SET title = EXCLUDED.title, slug = EXCLUDED.slug, excerpt = EXCLUDED.excerpt, category = EXCLUDED.category, content = EXCLUDED.content `, [enData.title, enData.slug, enData.excerpt, enData.category, enData.content, de_id]); } // Copy featuredImage/date from EN if DE is missing await q(` UPDATE posts SET featured_image_id = COALESCE(featured_image_id, (SELECT featured_image_id FROM posts WHERE id = $2)), date = COALESCE(date, (SELECT date FROM posts WHERE id = $2)) WHERE id = $1 `, [de_id, en_id]); await q(`DELETE FROM posts_locales WHERE _parent_id = $1`, [en_id]); await q(`DELETE FROM _posts_v WHERE parent = $1`, [en_id]); await q(`DELETE FROM posts WHERE id = $1`, [en_id]); console.log(` ✓ ${slug}`); } const [{ count }] = await q(`SELECT count(*) FROM posts`); console.log(`Posts remaining: ${count}`); } // DE slug → EN slug mapping for pages const PAGE_SLUG_MAP: Record = { impressum: 'legal-notice', datenschutz: 'privacy-policy', agbs: 'terms', kontakt: 'contact', produkte: 'products', blog: 'blog', team: 'team', start: 'start', danke: 'thanks', }; async function mergePages() { console.log('\n── PAGES ──────────────────────────────────────────'); for (const [deSlug, enSlug] of Object.entries(PAGE_SLUG_MAP)) { const [dePage] = await q<{ id: number }>(` SELECT p.id FROM pages p JOIN pages_locales pl ON pl._parent_id = p.id AND pl._locale = 'de' AND pl.slug = $1 LIMIT 1 `, [deSlug]); const [enPage] = await q<{ id: number }>(` SELECT p.id FROM pages p JOIN pages_locales pl ON pl._parent_id = p.id AND pl._locale = 'en' AND pl.slug = $1 LIMIT 1 `, [enSlug]); if (!dePage && !enPage) { console.log(` ⚠ No page found for ${deSlug}/${enSlug} — skipping`); continue; } if (!dePage) { console.log(` ⚠ No DE page for "${deSlug}" — EN-only page id=${enPage!.id} kept`); continue; } if (!enPage) { console.log(` ⚠ No EN page for "${enSlug}" — DE-only page id=${dePage.id} kept`); continue; } if (dePage.id === enPage.id) { console.log(` ✓ ${deSlug}/${enSlug} already merged (id=${dePage.id})`); continue; } console.log(` Merging: ${deSlug}↔${enSlug} (DE id=${dePage.id} ← EN id=${enPage.id})`); const [enData] = await q(` SELECT * FROM pages_locales WHERE _parent_id = $1 AND _locale = 'en' `, [enPage.id]); if (enData) { await q(` INSERT INTO pages_locales (title, slug, excerpt, content, _locale, _parent_id) VALUES ($1, $2, $3, $4, 'en', $5) ON CONFLICT (_locale, _parent_id) DO UPDATE SET title = EXCLUDED.title, slug = EXCLUDED.slug, excerpt = EXCLUDED.excerpt, content = EXCLUDED.content `, [enData.title, enData.slug, enData.excerpt, enData.content, dePage.id]); } // Copy featuredImage/layout from EN if DE is missing await q(` UPDATE pages SET featured_image_id = COALESCE(featured_image_id, (SELECT featured_image_id FROM pages WHERE id = $2)), layout = COALESCE(layout, (SELECT layout FROM pages WHERE id = $2)) WHERE id = $1 `, [dePage.id, enPage.id]); await q(`DELETE FROM pages_locales WHERE _parent_id = $1`, [enPage.id]); await q(`DELETE FROM _pages_v WHERE parent = $1`, [enPage.id]); await q(`DELETE FROM pages WHERE id = $1`, [enPage.id]); console.log(` ✓ ${deSlug}/${enSlug}`); } const [{ count }] = await q(`SELECT count(*) FROM pages`); console.log(`Pages remaining: ${count}`); } async function main() { console.log('🔀 Merging duplicate locale documents into native Payload localization...'); try { await mergeProducts(); await mergePosts(); await mergePages(); console.log('\n── Final pages state ──────────────────────────────'); const pages = await q(` SELECT p.id, pl._locale, pl.slug, pl.title FROM pages p JOIN pages_locales pl ON pl._parent_id = p.id ORDER BY p.id, pl._locale `); pages.forEach((r) => console.log(` [id=${r.id}] ${r._locale}: ${r.slug} — ${r.title}`)); console.log('\n✅ Done!'); } finally { await pool.end(); } } main().catch((err) => { console.error('Fatal error:', err); process.exit(1); });