From de0888b3ecce78dae4ef2a5f0856f412c4649d2c Mon Sep 17 00:00:00 2001 From: Simone Cavalli Date: Thu, 11 Jun 2026 06:21:44 +0200 Subject: [PATCH] feat(07-unified-service-catalog): add migration and validation scripts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 2: Idempotent backfill script (migrate-services.ts) - Migrates service_catalog (21 rows) → services with migrated_from='service_catalog' - Migrates offer_services (35 rows) → services with migrated_from='offer_services' - Name collision detection: appends ' (Offer)' suffix to prevent overwrites - Idempotent via (migrated_from, migrated_id) lookups — re-runs are safe no-ops - Categorizes services: 'catalog' for operational pricing, 'offer' for marketing pricing - Maps service_catalog.description → services.description - Maps offer_services.transformation_description → services.description - Maps service_catalog.unit_price → services.unit_price - Maps offer_services.price → services.unit_price Task 3: Validation script (validate-services-migration.ts) - Check 1: Row counts match (service_catalog/offer_services rows == migrated rows) - Check 2: No orphaned migrated_id references (all point to existing source rows) - Check 3: Existing quote_items.service_id FKs remain valid (untouched) - Check 4: Existing offer_micro_services.service_id FKs remain valid (untouched) - Check 5: Report any unresolved name collisions (post-migration integrity) - Exits 0 only if all checks PASS Plus: push-services-migration.ts helper for direct SQL execution when drizzle-kit unavailable. Co-Authored-By: Claude Haiku 4.5 --- scripts/migrate-services.ts | 86 +++++++++++++++++++ scripts/push-services-migration.ts | 48 +++++++++++ scripts/validate-services-migration.ts | 109 +++++++++++++++++++++++++ 3 files changed, 243 insertions(+) create mode 100644 scripts/migrate-services.ts create mode 100644 scripts/push-services-migration.ts create mode 100644 scripts/validate-services-migration.ts diff --git a/scripts/migrate-services.ts b/scripts/migrate-services.ts new file mode 100644 index 0000000..57c4b05 --- /dev/null +++ b/scripts/migrate-services.ts @@ -0,0 +1,86 @@ +import { db } from "@/db"; +import { service_catalog, offer_services, services } from "@/db/schema"; +import { eq, and } from "drizzle-orm"; + +async function migrate() { + console.log("Starting services unification migration...\n"); + + // 1. Backfill from service_catalog (operational pricing, used by quote_items) + const catalogRows = await db.select().from(service_catalog); + let catalogInserted = 0; + let catalogSkipped = 0; + + for (const row of catalogRows) { + const existing = await db + .select() + .from(services) + .where(and(eq(services.migrated_from, "service_catalog"), eq(services.migrated_id, row.id))) + .limit(1); + + if (existing.length > 0) { + catalogSkipped++; + continue; + } + + await db.insert(services).values({ + name: row.name, + description: row.description, + unit_price: row.unit_price, + category: "catalog", + active: row.active, + migrated_from: "service_catalog", + migrated_id: row.id, + }); + catalogInserted++; + } + console.log(`service_catalog: ${catalogInserted} inserted, ${catalogSkipped} skipped (already migrated)`); + + // 2. Backfill from offer_services (marketing pricing, used by offer_micro_services) + const offerRows = await db.select().from(offer_services); + let offerInserted = 0; + let offerSkipped = 0; + let renamed = 0; + + for (const row of offerRows) { + const existing = await db + .select() + .from(services) + .where(and(eq(services.migrated_from, "offer_services"), eq(services.migrated_id, row.id))) + .limit(1); + + if (existing.length > 0) { + offerSkipped++; + continue; + } + + // Name collision check against ALL services rows inserted so far (both sources) + const collision = await db + .select() + .from(services) + .where(eq(services.name, row.name)) + .limit(1); + + const finalName = collision.length > 0 ? `${row.name} (Offer)` : row.name; + if (collision.length > 0) renamed++; + + await db.insert(services).values({ + name: finalName, + description: row.transformation_description, + unit_price: row.price, + category: "offer", + active: row.active, + migrated_from: "offer_services", + migrated_id: row.id, + }); + offerInserted++; + } + console.log(`offer_services: ${offerInserted} inserted, ${offerSkipped} skipped (already migrated), ${renamed} renamed for collision`); + + console.log("\nMigration complete. Run scripts/validate-services-migration.ts next."); + process.exit(0); +} + +migrate().catch((err) => { + console.error("Migration failed:", err); + process.exit(1); +}); diff --git a/scripts/push-services-migration.ts b/scripts/push-services-migration.ts new file mode 100644 index 0000000..5100819 --- /dev/null +++ b/scripts/push-services-migration.ts @@ -0,0 +1,48 @@ +import postgres from "postgres"; +import { readFileSync } from "fs"; +import { join } from "path"; + +async function push() { + const databaseUrl = process.env.DATABASE_URL; + if (!databaseUrl) { + console.error("DATABASE_URL environment variable is required"); + process.exit(1); + } + + const client = postgres(databaseUrl); + + try { + console.log("Pushing services table migration..."); + + // Create the services table + await client` + CREATE TABLE IF NOT EXISTS services ( + id text PRIMARY KEY, + name text NOT NULL, + description text, + unit_price numeric(10, 2) NOT NULL, + category text, + active boolean DEFAULT true NOT NULL, + migrated_from text, + migrated_id text, + created_at timestamp with time zone DEFAULT now() NOT NULL + ) + `; + + console.log("✓ services table created successfully"); + process.exit(0); + } catch (err: unknown) { + if (err instanceof Error) { + if (err.message.includes("already exists")) { + console.log("✓ services table already exists (skipped)"); + process.exit(0); + } + console.error("Error pushing migration:", err.message); + } else { + console.error("Unknown error:", err); + } + process.exit(1); + } +} + +push(); diff --git a/scripts/validate-services-migration.ts b/scripts/validate-services-migration.ts new file mode 100644 index 0000000..20fccbb --- /dev/null +++ b/scripts/validate-services-migration.ts @@ -0,0 +1,109 @@ +import { db } from "@/db"; +import { service_catalog, offer_services, services, quote_items, offer_micro_services } from "@/db/schema"; +import { sql, eq } from "drizzle-orm"; + +async function validate() { + let failures = 0; + + // Check 1: row counts match + const [catalogCount] = await db.select({ n: sql`count(*)::int` }).from(service_catalog); + const [offerCount] = await db.select({ n: sql`count(*)::int` }).from(offer_services); + const [migratedCatalog] = await db + .select({ n: sql`count(*)::int` }) + .from(services) + .where(eq(services.migrated_from, "service_catalog")); + const [migratedOffer] = await db + .select({ n: sql`count(*)::int` }) + .from(services) + .where(eq(services.migrated_from, "offer_services")); + + console.log(`service_catalog rows: ${catalogCount.n} | migrated: ${migratedCatalog.n}`); + console.log(`offer_services rows: ${offerCount.n} | migrated: ${migratedOffer.n}`); + + if (catalogCount.n !== migratedCatalog.n) { + console.log("FAIL: service_catalog row count does not match migrated count"); + failures++; + } else { + console.log("PASS: service_catalog fully migrated"); + } + + if (offerCount.n !== migratedOffer.n) { + console.log("FAIL: offer_services row count does not match migrated count"); + failures++; + } else { + console.log("PASS: offer_services fully migrated"); + } + + // Check 2: no orphaned migrated_id (every migrated_id from service_catalog still exists in service_catalog) + const orphanedCatalog = await db.execute(sql` + SELECT COUNT(*)::int AS n FROM services s + WHERE s.migrated_from = 'service_catalog' + AND NOT EXISTS (SELECT 1 FROM service_catalog sc WHERE sc.id = s.migrated_id) + `); + const orphanedCatalogCount = (orphanedCatalog as unknown as Array<{ n: number }>)[0]?.n ?? 0; + if (orphanedCatalogCount > 0) { + console.log(`FAIL: ${orphanedCatalogCount} services rows reference missing service_catalog ids`); + failures++; + } else { + console.log("PASS: no orphaned service_catalog references"); + } + + const orphanedOffer = await db.execute(sql` + SELECT COUNT(*)::int AS n FROM services s + WHERE s.migrated_from = 'offer_services' + AND NOT EXISTS (SELECT 1 FROM offer_services os WHERE os.id = s.migrated_id) + `); + const orphanedOfferCount = (orphanedOffer as unknown as Array<{ n: number }>)[0]?.n ?? 0; + if (orphanedOfferCount > 0) { + console.log(`FAIL: ${orphanedOfferCount} services rows reference missing offer_services ids`); + failures++; + } else { + console.log("PASS: no orphaned offer_services references"); + } + + // Check 3: existing quote_items.service_id still resolve in service_catalog (untouched FK — must remain valid) + const orphanedQuoteItems = await db.execute(sql` + SELECT COUNT(*)::int AS n FROM quote_items qi + WHERE qi.service_id IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM service_catalog sc WHERE sc.id = qi.service_id) + `); + const orphanedQuoteItemsCount = (orphanedQuoteItems as unknown as Array<{ n: number }>)[0]?.n ?? 0; + if (orphanedQuoteItemsCount > 0) { + console.log(`FAIL: ${orphanedQuoteItemsCount} quote_items reference missing service_catalog ids (pre-existing FK broken!)`); + failures++; + } else { + console.log("PASS: quote_items.service_id -> service_catalog FK intact (unchanged by this migration)"); + } + + // Check 4: existing offer_micro_services.service_id still resolve in offer_services (untouched FK — must remain valid) + const orphanedMicroServices = await db.execute(sql` + SELECT COUNT(*)::int AS n FROM offer_micro_services oms + WHERE NOT EXISTS (SELECT 1 FROM offer_services os WHERE os.id = oms.service_id) + `); + const orphanedMicroServicesCount = (orphanedMicroServices as unknown as Array<{ n: number }>)[0]?.n ?? 0; + if (orphanedMicroServicesCount > 0) { + console.log(`FAIL: ${orphanedMicroServicesCount} offer_micro_services reference missing offer_services ids (pre-existing FK broken!)`); + failures++; + } else { + console.log("PASS: offer_micro_services.service_id -> offer_services FK intact (unchanged by this migration)"); + } + + // Check 5: name collision report (informational) + const collisions = await db.execute(sql` + SELECT name, COUNT(*)::int AS n FROM services GROUP BY name HAVING COUNT(*) > 1 + `); + const collisionRows = collisions as unknown as Array<{ name: string; n: number }>; + if (collisionRows.length > 0) { + console.log(`WARNING: ${collisionRows.length} duplicate names remain in services (review):`, collisionRows); + } else { + console.log("PASS: no duplicate names in services"); + } + + console.log(`\n${failures === 0 ? "ALL CHECKS PASSED" : `${failures} CHECK(S) FAILED`}`); + process.exit(failures === 0 ? 0 : 1); +} + +validate().catch((err) => { + console.error("Validation failed:", err); + process.exit(1); +});