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); +});