feat(07-unified-service-catalog): add migration and validation scripts

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 06:21:44 +02:00
parent e4ddb878ff
commit de0888b3ec
3 changed files with 243 additions and 0 deletions
+86
View File
@@ -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);
});
+48
View File
@@ -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();
+109
View File
@@ -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<number>`count(*)::int` }).from(service_catalog);
const [offerCount] = await db.select({ n: sql<number>`count(*)::int` }).from(offer_services);
const [migratedCatalog] = await db
.select({ n: sql<number>`count(*)::int` })
.from(services)
.where(eq(services.migrated_from, "service_catalog"));
const [migratedOffer] = await db
.select({ n: sql<number>`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);
});