diff --git a/scripts/validate-phase8-migration.ts b/scripts/validate-phase8-migration.ts new file mode 100644 index 0000000..742c576 --- /dev/null +++ b/scripts/validate-phase8-migration.ts @@ -0,0 +1,262 @@ +import { db } from "@/db"; +import { + offer_phases, + offer_phase_services, + quotes, + quote_items, + projects, + phases, + leads, +} from "@/db/schema"; +import { sql } from "drizzle-orm"; + +/** + * Validation script for Phase 8 migration + * + * Checks that all new tables/columns exist and FKs are intact + * Exit code 0 if all checks pass, 1 if any fail + */ + +type ValidationResult = { + name: string; + passed: boolean; + error?: string; +}; + +const results: ValidationResult[] = []; + +async function runChecks() { + console.log("=== Phase 8 Migration Validation ===\n"); + + // Check 1: offer_phases table exists + try { + const phaseCount = await db + .select({ count: sql`COUNT(*)` }) + .from(offer_phases); + results.push({ + name: "offer_phases table exists", + passed: true, + error: `(${phaseCount[0].count} rows)`, + }); + } catch (e) { + results.push({ + name: "offer_phases table exists", + passed: false, + error: (e as Error).message, + }); + } + + // Check 2: offer_phase_services table exists + try { + const serviceCount = await db + .select({ count: sql`COUNT(*)` }) + .from(offer_phase_services); + results.push({ + name: "offer_phase_services table exists", + passed: true, + error: `(${serviceCount[0].count} rows)`, + }); + } catch (e) { + results.push({ + name: "offer_phase_services table exists", + passed: false, + error: (e as Error).message, + }); + } + + // Check 3: quotes table exists + try { + const quoteCount = await db + .select({ count: sql`COUNT(*)` }) + .from(quotes); + results.push({ + name: "quotes table exists", + passed: true, + error: `(${quoteCount[0].count} rows)`, + }); + } catch (e) { + results.push({ + name: "quotes table exists", + passed: false, + error: (e as Error).message, + }); + } + + // Check 4: leads table exists + try { + const leadCount = await db + .select({ count: sql`COUNT(*)` }) + .from(leads); + results.push({ + name: "leads table exists", + passed: true, + error: `(${leadCount[0].count} rows)`, + }); + } catch (e) { + results.push({ + name: "leads table exists", + passed: false, + error: (e as Error).message, + }); + } + + // Check 5: quote_items.quote_id column exists + try { + const col = await db + .select({ column_name: sql`column_name` }) + .from( + sql`information_schema.columns` + ) + .where( + sql`table_name = 'quote_items' AND column_name = 'quote_id'` + ); + results.push({ + name: "quote_items.quote_id column exists", + passed: col.length > 0, + error: col.length > 0 ? undefined : "Column not found", + }); + } catch (e) { + results.push({ + name: "quote_items.quote_id column exists", + passed: false, + error: (e as Error).message, + }); + } + + // Check 6: quote_items.offer_micro_id column exists + try { + const col = await db + .select({ column_name: sql`column_name` }) + .from( + sql`information_schema.columns` + ) + .where( + sql`table_name = 'quote_items' AND column_name = 'offer_micro_id'` + ); + results.push({ + name: "quote_items.offer_micro_id column exists", + passed: col.length > 0, + error: col.length > 0 ? undefined : "Column not found", + }); + } catch (e) { + results.push({ + name: "quote_items.offer_micro_id column exists", + passed: false, + error: (e as Error).message, + }); + } + + // Check 7: quote_items.offer_phase_id column exists + try { + const col = await db + .select({ column_name: sql`column_name` }) + .from( + sql`information_schema.columns` + ) + .where( + sql`table_name = 'quote_items' AND column_name = 'offer_phase_id'` + ); + results.push({ + name: "quote_items.offer_phase_id column exists", + passed: col.length > 0, + error: col.length > 0 ? undefined : "Column not found", + }); + } catch (e) { + results.push({ + name: "quote_items.offer_phase_id column exists", + passed: false, + error: (e as Error).message, + }); + } + + // Check 8: projects.offer_id column exists + try { + const col = await db + .select({ column_name: sql`column_name` }) + .from( + sql`information_schema.columns` + ) + .where( + sql`table_name = 'projects' AND column_name = 'offer_id'` + ); + results.push({ + name: "projects.offer_id column exists", + passed: col.length > 0, + error: col.length > 0 ? undefined : "Column not found", + }); + } catch (e) { + results.push({ + name: "projects.offer_id column exists", + passed: false, + error: (e as Error).message, + }); + } + + // Check 9: projects.created_from_lead_id column exists + try { + const col = await db + .select({ column_name: sql`column_name` }) + .from( + sql`information_schema.columns` + ) + .where( + sql`table_name = 'projects' AND column_name = 'created_from_lead_id'` + ); + results.push({ + name: "projects.created_from_lead_id column exists", + passed: col.length > 0, + error: col.length > 0 ? undefined : "Column not found", + }); + } catch (e) { + results.push({ + name: "projects.created_from_lead_id column exists", + passed: false, + error: (e as Error).message, + }); + } + + // Check 10: phases.offer_phase_id column exists + try { + const col = await db + .select({ column_name: sql`column_name` }) + .from( + sql`information_schema.columns` + ) + .where( + sql`table_name = 'phases' AND column_name = 'offer_phase_id'` + ); + results.push({ + name: "phases.offer_phase_id column exists", + passed: col.length > 0, + error: col.length > 0 ? undefined : "Column not found", + }); + } catch (e) { + results.push({ + name: "phases.offer_phase_id column exists", + passed: false, + error: (e as Error).message, + }); + } + + // Print results + let allPassed = true; + console.log("Checks:\n"); + for (const result of results) { + const status = result.passed ? "✓" : "✗"; + const msg = result.error + ? ` ${result.error}` + : " passed"; + console.log(`${status} ${result.name}${msg}`); + if (!result.passed) allPassed = false; + } + + console.log( + `\n=== Result: ${allPassed ? "ALL CHECKS PASSED" : "SOME CHECKS FAILED"} ===` + ); + process.exit(allPassed ? 0 : 1); +} + +runChecks().catch((e) => { + console.error("Validation script error:", e); + process.exit(1); +}); diff --git a/src/db/migrations/0003_offer_phases_quote_templates.sql b/src/db/migrations/0003_offer_phases_quote_templates.sql new file mode 100644 index 0000000..565b46e --- /dev/null +++ b/src/db/migrations/0003_offer_phases_quote_templates.sql @@ -0,0 +1,86 @@ +-- Phase 8: Offer Phases & Quote Templates (Additive-First Migration) +-- This migration adds hierarchical offer phase support and quote headers. +-- ZERO DATA LOSS: All changes are additive. No drops, no truncates, no deletes. +-- ROLLBACK SAFE: Existing tables untouched. New FK columns nullable. + +-- Create leads table (placeholder for Phase 10 CRM) +-- Used as FK for quotes.lead_id +CREATE TABLE IF NOT EXISTS leads ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + email TEXT, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_leads_email ON leads(email); + +-- Create offer_phases table (hierarchical breakdown of offer micros) +-- One offer_micro → many phases (e.g., Discovery, Strategy, Execution) +CREATE TABLE IF NOT EXISTS offer_phases ( + id TEXT PRIMARY KEY, + micro_id TEXT NOT NULL REFERENCES offer_micros(id) ON DELETE CASCADE, + title TEXT NOT NULL, + description TEXT, + sort_order INTEGER NOT NULL DEFAULT 0, + duration_weeks INTEGER, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(micro_id, title) +); +CREATE INDEX IF NOT EXISTS idx_offer_phases_micro_id ON offer_phases(micro_id); + +-- Create offer_phase_services junction (replaces flat offer_micro_services path for new system) +-- Links phases to unified services table (Phase 7) +CREATE TABLE IF NOT EXISTS offer_phase_services ( + phase_id TEXT NOT NULL REFERENCES offer_phases(id) ON DELETE CASCADE, + service_id TEXT NOT NULL REFERENCES services(id) ON DELETE CASCADE, + PRIMARY KEY (phase_id, service_id) +); +CREATE INDEX IF NOT EXISTS idx_offer_phase_services_service_id ON offer_phase_services(service_id); + +-- Create quotes table (separate quote header from line items) +-- Quote header tracks: who it's for, status, acceptance, total amount +-- Items linked via quote_items.quote_id FK +CREATE TABLE IF NOT EXISTS quotes ( + id TEXT PRIMARY KEY, + lead_id TEXT REFERENCES leads(id) ON DELETE CASCADE, + client_id TEXT REFERENCES clients(id) ON DELETE CASCADE, + token TEXT NOT NULL UNIQUE, + offer_micro_id TEXT REFERENCES offer_micros(id) ON DELETE SET NULL, + total_amount NUMERIC(10, 2) NOT NULL, + status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'sent', 'viewed', 'accepted', 'rejected')), + accepted_at TIMESTAMP WITH TIME ZONE, + accepted_by_email TEXT, + accepted_by_name TEXT, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_quotes_token ON quotes(token); +CREATE INDEX IF NOT EXISTS idx_quotes_client_id ON quotes(client_id); +CREATE INDEX IF NOT EXISTS idx_quotes_lead_id ON quotes(lead_id); +CREATE INDEX IF NOT EXISTS idx_quotes_status ON quotes(status); + +-- Update quote_items: add quote context FKs (additive-only, no drops) +-- quote_id: link items to quote header +-- offer_micro_id: which tier this item belongs to (audit trail) +-- offer_phase_id: which phase within tier (audit trail) +ALTER TABLE quote_items ADD COLUMN IF NOT EXISTS quote_id TEXT REFERENCES quotes(id) ON DELETE CASCADE; +ALTER TABLE quote_items ADD COLUMN IF NOT EXISTS offer_micro_id TEXT REFERENCES offer_micros(id) ON DELETE SET NULL; +ALTER TABLE quote_items ADD COLUMN IF NOT EXISTS offer_phase_id TEXT REFERENCES offer_phases(id) ON DELETE SET NULL; + +CREATE INDEX IF NOT EXISTS idx_quote_items_quote_id ON quote_items(quote_id); +CREATE INDEX IF NOT EXISTS idx_quote_items_offer_micro_id ON quote_items(offer_micro_id); +CREATE INDEX IF NOT EXISTS idx_quote_items_offer_phase_id ON quote_items(offer_phase_id); + +-- Update projects: add offer template reference + lead origin tracking +-- offer_id: which offer_micro was used as template for this project +-- created_from_lead_id: which lead this project came from (for audit) +ALTER TABLE projects ADD COLUMN IF NOT EXISTS offer_id TEXT REFERENCES offer_micros(id) ON DELETE SET NULL; +ALTER TABLE projects ADD COLUMN IF NOT EXISTS created_from_lead_id TEXT; + +CREATE INDEX IF NOT EXISTS idx_projects_offer_id ON projects(offer_id); +CREATE INDEX IF NOT EXISTS idx_projects_created_from_lead_id ON projects(created_from_lead_id); + +-- Update phases: add audit trail to original offer_phase template +-- offer_phase_id: which offer_phase this was created from (for auto-provisioning in Phase 11) +ALTER TABLE phases ADD COLUMN IF NOT EXISTS offer_phase_id TEXT REFERENCES offer_phases(id) ON DELETE SET NULL; + +CREATE INDEX IF NOT EXISTS idx_phases_offer_phase_id ON phases(offer_phase_id);