PSA/server/migrations/20251003000001_company_to_client_migration.cjs
Hermes 284313f908
Some checks are pending
Bidi Control Character Guard / bidi-control-guard (push) Waiting to run
Circular Dependency Check / Check for new circular dependencies (push) Waiting to run
Citus Migration Smoke / Combined migrations on single-node Citus (push) Waiting to run
E2E Fresh Install Tests / fresh-install-e2e (push) Waiting to run
ext-v2 guardrails / Run ext-v2 guard and ESLint (push) Waiting to run
Integration Tests / Check for relevant changes (push) Waiting to run
Integration Tests / ${{ (github.event_name == 'schedule' || github.event.inputs.suite == 'full') && 'Full integration suite' || 'Tier-1 integration subset' }} (push) Blocked by required conditions
Mobile checks / Mobile lint + typecheck (push) Waiting to run
Mobile checks / Mobile unit tests (push) Waiting to run
Mobile checks / Mobile dependency audit (report) (push) Waiting to run
Mobile checks / Mobile reproducibility checks (push) Waiting to run
Secrets guard (env backups) / Ensure no tracked env backup files (push) Waiting to run
Temporal Readiness / fast-readiness (push) Waiting to run
Temporal Readiness / docker-parity (push) Waiting to run
TypeScript Type Check / Nx affected typecheck (push) Waiting to run
Unit Tests / Skipped-test budget (push) Waiting to run
Unit Tests / Nx affected unit tests (push) Waiting to run
Unit Tests / Server unit coverage (informational) (push) Waiting to run
Validate Tenant Management Schema / Check for relevant changes (push) Waiting to run
Validate Tenant Management Schema / Validate Tenant Management Schema (push) Blocked by required conditions
EE Workflows Build Guard / ee-workflows-build-guard (push) Waiting to run
Initial import of AlgaPSA codebase from PSA server
Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz

Source: /opt/alga-psa on psa.joliet.tech
2026-06-22 16:12:17 -05:00

689 lines
28 KiB
JavaScript

/**
* Migration 1: Company → Client Rename - Base Migration
*
* This migration creates new client-related tables and adds client_id columns
* to all dependent tables while keeping the old company tables intact.
*
* Two-step approach:
* 1. Create new tables/columns (this migration)
* 2. Drop old tables/columns (cleanup migration after app cutover)
*/
exports.config = { transaction: false };
exports.up = async function(knex) {
console.log('Starting company to client migration...');
const createdTables = [];
const addedColumns = [];
try {
// Step 0: Rename tenants.company_name → tenants.client_name (if not already renamed)
const tenantHasCompanyName = await knex.schema.hasColumn('tenants', 'company_name');
const tenantHasClientName = await knex.schema.hasColumn('tenants', 'client_name');
if (tenantHasCompanyName && !tenantHasClientName) {
console.log('Renaming tenants.company_name → tenants.client_name...');
await knex.raw('ALTER TABLE tenants RENAME COLUMN company_name TO client_name');
console.log('✓ tenants table column renamed to client_name');
} else if (!tenantHasCompanyName && tenantHasClientName) {
console.log('tenants table already uses client_name column, skipping rename...');
} else if (!tenantHasCompanyName && !tenantHasClientName) {
throw new Error('❌ tenants table missing both company_name and client_name columns. Cannot proceed.');
} else {
console.log('Both company_name and client_name columns exist on tenants table. Please resolve manually.');
throw new Error('❌ Unexpected tenants table state - both company_name and client_name present');
}
// Step 1: Create clients table
const clientsExists = await knex.schema.hasTable('clients');
if (clientsExists) {
console.log('clients table already exists, skipping creation...');
} else {
console.log('Creating clients table...');
createdTables.push('clients');
// Clone structure from companies table with ALL columns
await knex.schema.createTable('clients', (table) => {
table.uuid('tenant').notNullable();
table.uuid('client_id').defaultTo(knex.raw('gen_random_uuid()')).notNullable();
table.text('client_name').notNullable();
table.text('url');
table.jsonb('properties');
table.text('billing_type');
table.text('payment_terms');
table.bigInteger('credit_limit');
table.text('preferred_payment_method');
table.boolean('auto_invoice').defaultTo(false);
table.text('invoice_delivery_method');
table.timestamp('created_at').defaultTo(knex.fn.now());
table.timestamp('updated_at').defaultTo(knex.fn.now());
table.boolean('is_inactive').defaultTo(false);
table.text('client_type');
table.boolean('is_tax_exempt').notNullable().defaultTo(false);
table.string('tax_exemption_certificate', 255);
table.string('tax_id_number', 255);
table.text('notes');
table.integer('credit_balance');
table.text('billing_cycle').notNullable().defaultTo('monthly');
table.string('timezone', 255);
table.uuid('notes_document_id');
table.uuid('invoice_template_id');
table.uuid('billing_contact_id');
table.string('billing_email', 255);
table.string('region_code', 255);
table.uuid('account_manager_id');
table.primary(['tenant', 'client_id']);
});
// Add constraints
// Citus cannot distribute a table with a unique constraint that
// excludes the partition column, and nothing references client_id
// alone (all FKs are composite on tenant). Skip it on Citus so the
// clients table stays distributable.
const citusForUnique = await knex.raw(
"SELECT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'citus') AS enabled"
);
if (!citusForUnique.rows[0].enabled) await knex.raw(`
ALTER TABLE clients
ADD CONSTRAINT clients_client_id_unique UNIQUE (client_id)
`);
await knex.raw(`
ALTER TABLE clients
ADD CONSTRAINT clients_tenant_client_name_unique UNIQUE (tenant, client_name)
`);
// Add CHECK constraint for billing_cycle
await knex.raw(`
ALTER TABLE clients
ADD CONSTRAINT clients_billing_cycle_check
CHECK (billing_cycle = ANY (ARRAY['weekly'::text, 'bi-weekly'::text, 'monthly'::text, 'quarterly'::text, 'semi-annually'::text, 'annually'::text]))
`);
// Add indexes matching companies table
await knex.raw('CREATE INDEX idx_clients_tenant_client_name ON clients(tenant, client_name)');
await knex.raw('CREATE INDEX idx_clients_tenant_inactive_name ON clients(tenant, is_inactive, client_name)');
await knex.raw('CREATE INDEX idx_clients_tenant_client_type ON clients(tenant, client_type)');
await knex.raw('CREATE INDEX idx_clients_tenant_url ON clients(tenant, url)');
await knex.raw('CREATE INDEX idx_clients_tenant_region_code ON clients(tenant, region_code)');
await knex.raw('CREATE INDEX idx_clients_tenant_account_manager ON clients(tenant, account_manager_id)');
// Add foreign keys (will be validated after backfill)
// Skip FK in Citus - it will be added by Citus migration after distribution
const citusEnabled = await knex.raw(`
SELECT EXISTS (
SELECT 1 FROM pg_extension WHERE extname = 'citus'
) as enabled
`);
if (!citusEnabled.rows[0].enabled) {
await knex.raw(`
ALTER TABLE clients
ADD CONSTRAINT clients_tenant_foreign
FOREIGN KEY (tenant) REFERENCES tenants(tenant) NOT VALID
`);
console.log('✓ Added FK to tenants (non-Citus)');
} else {
console.log('⊘ Skipped FK to tenants (Citus - will be added after distribution)');
}
console.log('✓ clients table created');
}
// Step 2: Backfill clients from companies with ALL columns
console.log('Backfilling clients from companies...');
await knex.raw(`
INSERT INTO clients (
tenant, client_id, client_name, url, properties, billing_type, payment_terms,
credit_limit, preferred_payment_method, auto_invoice, invoice_delivery_method,
created_at, updated_at, is_inactive, client_type, is_tax_exempt,
tax_exemption_certificate, tax_id_number, notes, credit_balance, billing_cycle,
timezone, notes_document_id, invoice_template_id, billing_contact_id,
billing_email, region_code, account_manager_id
)
SELECT
tenant, company_id, company_name, url, properties, billing_type, payment_terms,
credit_limit, preferred_payment_method, auto_invoice, invoice_delivery_method,
created_at, updated_at, is_inactive, client_type, is_tax_exempt,
tax_exemption_certificate, tax_id_number, notes, credit_balance, billing_cycle,
timezone, notes_document_id, invoice_template_id, billing_contact_id,
billing_email, region_code, account_manager_id
FROM companies
ON CONFLICT (tenant, client_id) DO NOTHING
`);
const count = await knex('clients').count('* as count');
console.log(`✓ Backfilled ${count[0].count} clients`);
// Step 3: Create other renamed tables
await createClientLocations(knex, createdTables);
await createClientBillingCycles(knex, createdTables);
await createClientBillingSettings(knex, createdTables);
await createClientTaxSettings(knex, createdTables);
await createClientTaxRates(knex, createdTables);
await createClientBillingPlans(knex, createdTables);
await createClientPlanBundles(knex, createdTables);
// Step 4: Add client_id columns to dependent tables
await addClientIdColumns(knex, addedColumns);
// Step 5: Backfill client_id from company_id
await backfillClientIds(knex);
// Step 6: Verify row counts
await verifyRowCounts(knex);
console.log('✓ Company to client migration completed successfully');
} catch (error) {
console.error('❌ Migration failed:', error.message);
console.log('Rolling back changes...');
try {
// Drop created tables in reverse order
for (const table of createdTables.reverse()) {
console.log(` Dropping table: ${table}`);
await knex.schema.dropTableIfExists(table);
}
// Drop added columns
for (const {table, column} of addedColumns.reverse()) {
console.log(` Dropping column: ${table}.${column}`);
const tableExists = await knex.schema.hasTable(table);
if (tableExists) {
const hasColumn = await knex.schema.hasColumn(table, column);
if (hasColumn) {
await knex.schema.table(table, (t) => {
t.dropColumn(column);
});
}
}
}
console.log('✓ Rollback completed');
} catch (rollbackError) {
console.error('❌ Rollback failed:', rollbackError.message);
console.error('Manual cleanup may be required!');
}
throw error; // Re-throw to mark migration as failed
}
};
async function createClientLocations(knex, createdTables) {
const exists = await knex.schema.hasTable('client_locations');
if (!exists) {
console.log('Creating client_locations...');
createdTables.push('client_locations');
await knex.schema.createTable('client_locations', (table) => {
table.uuid('location_id').notNullable();
table.uuid('tenant').notNullable();
table.uuid('client_id').notNullable();
table.string('location_name');
table.string('address_line1').notNullable();
table.string('address_line2');
table.string('address_line3');
table.string('city').notNullable();
table.string('state_province');
table.string('postal_code');
table.string('country_code').notNullable();
table.string('country_name').notNullable();
table.string('region_code');
table.boolean('is_billing_address').defaultTo(false);
table.boolean('is_shipping_address').defaultTo(false);
table.boolean('is_default').defaultTo(false);
table.string('phone');
table.string('fax');
table.string('email');
table.text('notes');
table.boolean('is_active').defaultTo(true);
table.timestamp('created_at').defaultTo(knex.fn.now());
table.timestamp('updated_at').defaultTo(knex.fn.now());
table.primary(['location_id', 'tenant']);
});
// Add indexes
await knex.raw('CREATE INDEX idx_client_locations_tenant_client_id ON client_locations(tenant, client_id)');
await knex.raw('CREATE INDEX idx_client_locations_tenant_is_default ON client_locations(tenant, is_default)');
await knex.raw('CREATE INDEX idx_client_locations_tenant_is_active ON client_locations(tenant, is_active)');
await knex.raw('CREATE INDEX idx_client_locations_default_phone ON client_locations(tenant, client_id, is_default, phone)');
await knex.raw('CREATE INDEX idx_client_locations_default_address ON client_locations(tenant, client_id, is_default, address_line1)');
} else {
console.log('client_locations already exists, skipping creation...');
}
// Backfill (always run, idempotent)
console.log('Backfilling client_locations from company_locations...');
await knex.raw(`
INSERT INTO client_locations (
location_id, tenant, client_id, location_name, address_line1, address_line2,
address_line3, city, state_province, postal_code, country_code, country_name,
region_code, is_billing_address, is_shipping_address, is_default,
phone, fax, email, notes, is_active, created_at, updated_at
)
SELECT
location_id, tenant, company_id, location_name, address_line1, address_line2,
address_line3, city, state_province, postal_code, country_code, country_name,
region_code, is_billing_address, is_shipping_address, is_default,
phone, fax, email, notes, is_active, created_at, updated_at
FROM company_locations
ON CONFLICT (location_id, tenant) DO NOTHING
`);
const count = await knex('client_locations').count('* as count');
console.log(`✓ client_locations has ${count[0].count} rows`);
}
async function createClientBillingCycles(knex, createdTables) {
const exists = await knex.schema.hasTable('client_billing_cycles');
if (!exists) {
console.log('Creating client_billing_cycles...');
createdTables.push('client_billing_cycles');
await knex.schema.createTable('client_billing_cycles', (table) => {
table.uuid('tenant').notNullable();
table.uuid('billing_cycle_id').defaultTo(knex.raw('gen_random_uuid()')).notNullable();
table.uuid('client_id').notNullable();
table.string('billing_cycle').notNullable().defaultTo('monthly');
table.timestamp('effective_date').notNullable().defaultTo(knex.fn.now());
table.timestamp('period_start_date').notNullable().defaultTo(knex.fn.now());
table.timestamp('period_end_date');
table.boolean('is_active').notNullable().defaultTo(true);
table.timestamp('created_at').defaultTo(knex.fn.now());
table.timestamp('updated_at').defaultTo(knex.fn.now());
table.primary(['tenant', 'billing_cycle_id']);
});
} else {
console.log('client_billing_cycles already exists, skipping creation...');
}
// Backfill (always run, idempotent)
console.log('Backfilling client_billing_cycles from company_billing_cycles...');
await knex.raw(`
INSERT INTO client_billing_cycles (
tenant, billing_cycle_id, client_id, billing_cycle, effective_date,
period_start_date, period_end_date, is_active, created_at, updated_at
)
SELECT
tenant, billing_cycle_id, company_id, billing_cycle, effective_date,
period_start_date, period_end_date, is_active, created_at, updated_at
FROM company_billing_cycles
ON CONFLICT (tenant, billing_cycle_id) DO NOTHING
`);
const count = await knex('client_billing_cycles').count('* as count');
console.log(`✓ client_billing_cycles has ${count[0].count} rows`);
}
async function createClientBillingSettings(knex, createdTables) {
const exists = await knex.schema.hasTable('client_billing_settings');
if (!exists) {
console.log('Creating client_billing_settings...');
createdTables.push('client_billing_settings');
await knex.schema.createTable('client_billing_settings', (table) => {
table.uuid('tenant').notNullable();
table.uuid('client_id').notNullable();
table.text('zero_dollar_invoice_handling').notNullable();
table.boolean('suppress_zero_dollar_invoices').notNullable();
table.integer('credit_expiration_days');
table.specificType('credit_expiration_notification_days', 'integer[]');
table.boolean('enable_credit_expiration');
table.timestamp('created_at').notNullable().defaultTo(knex.fn.now());
table.timestamp('updated_at').notNullable().defaultTo(knex.fn.now());
table.primary(['tenant', 'client_id']);
});
} else {
console.log('client_billing_settings already exists, skipping creation...');
}
// Backfill (always run, idempotent)
console.log('Backfilling client_billing_settings from company_billing_settings...');
await knex.raw(`
INSERT INTO client_billing_settings (
tenant, client_id, zero_dollar_invoice_handling, suppress_zero_dollar_invoices,
credit_expiration_days, credit_expiration_notification_days, enable_credit_expiration,
created_at, updated_at
)
SELECT
tenant, company_id, zero_dollar_invoice_handling, suppress_zero_dollar_invoices,
credit_expiration_days, credit_expiration_notification_days, enable_credit_expiration,
created_at, updated_at
FROM company_billing_settings
ON CONFLICT (tenant, client_id) DO NOTHING
`);
const count = await knex('client_billing_settings').count('* as count');
console.log(`✓ client_billing_settings has ${count[0].count} rows`);
}
async function createClientTaxSettings(knex, createdTables) {
const exists = await knex.schema.hasTable('client_tax_settings');
if (!exists) {
console.log('Creating client_tax_settings...');
createdTables.push('client_tax_settings');
await knex.schema.createTable('client_tax_settings', (table) => {
table.uuid('tenant').notNullable();
table.uuid('client_id').notNullable();
table.boolean('is_reverse_charge_applicable').defaultTo(false);
table.primary(['tenant', 'client_id']);
});
} else {
console.log('client_tax_settings already exists, skipping creation...');
}
// Backfill (always run, idempotent)
console.log('Backfilling client_tax_settings from company_tax_settings...');
await knex.raw(`
INSERT INTO client_tax_settings (
tenant, client_id, is_reverse_charge_applicable
)
SELECT
tenant, company_id, is_reverse_charge_applicable
FROM company_tax_settings
ON CONFLICT (tenant, client_id) DO NOTHING
`);
const count = await knex('client_tax_settings').count('* as count');
console.log(`✓ client_tax_settings has ${count[0].count} rows`);
}
async function createClientTaxRates(knex, createdTables) {
const exists = await knex.schema.hasTable('client_tax_rates');
if (!exists) {
console.log('Creating client_tax_rates...');
createdTables.push('client_tax_rates');
await knex.schema.createTable('client_tax_rates', (table) => {
table.uuid('tenant').notNullable();
table.uuid('client_tax_rates_id').defaultTo(knex.raw('gen_random_uuid()')).notNullable();
table.uuid('client_id').notNullable();
table.uuid('tax_rate_id').notNullable();
table.uuid('location_id');
table.boolean('is_default').notNullable().defaultTo(false);
table.timestamp('created_at').notNullable().defaultTo(knex.fn.now());
table.timestamp('updated_at').notNullable().defaultTo(knex.fn.now());
table.primary(['client_tax_rates_id', 'tenant']);
});
} else {
console.log('client_tax_rates already exists, skipping creation...');
}
// Backfill (always run, idempotent)
console.log('Backfilling client_tax_rates from company_tax_rates...');
await knex.raw(`
INSERT INTO client_tax_rates (
tenant, client_tax_rates_id, client_id, tax_rate_id, location_id,
is_default, created_at, updated_at
)
SELECT
tenant, company_tax_rates_id, company_id, tax_rate_id, location_id,
is_default, created_at, updated_at
FROM company_tax_rates
ON CONFLICT (client_tax_rates_id, tenant) DO NOTHING
`);
const count = await knex('client_tax_rates').count('* as count');
console.log(`✓ client_tax_rates has ${count[0].count} rows`);
}
async function createClientBillingPlans(knex, createdTables) {
const exists = await knex.schema.hasTable('client_billing_plans');
if (!exists) {
console.log('Creating client_billing_plans...');
createdTables.push('client_billing_plans');
await knex.schema.createTable('client_billing_plans', (table) => {
table.uuid('tenant').notNullable();
table.uuid('client_billing_plan_id').defaultTo(knex.raw('gen_random_uuid()')).notNullable();
table.uuid('client_id').notNullable();
table.uuid('plan_id').notNullable();
table.uuid('service_category');
table.boolean('is_active').defaultTo(true);
table.timestamp('start_date', { useTz: true }).notNullable();
table.timestamp('end_date', { useTz: true });
table.uuid('client_bundle_id');
table.primary(['tenant', 'client_billing_plan_id']);
});
// Add indexes
await knex.raw('CREATE INDEX idx_client_billing_plans_tenant ON client_billing_plans(tenant)');
await knex.raw('CREATE INDEX idx_client_billing_plans_tenant_client_id ON client_billing_plans(tenant, client_id)');
await knex.raw('CREATE INDEX idx_client_billing_plans_plan_id ON client_billing_plans(tenant, plan_id)');
await knex.raw('CREATE INDEX client_billing_plans_client_bundle_id_index ON client_billing_plans(client_bundle_id)');
} else {
console.log('client_billing_plans already exists, skipping creation...');
}
// Backfill (always run, idempotent)
console.log('Backfilling client_billing_plans from company_billing_plans...');
await knex.raw(`
INSERT INTO client_billing_plans (
tenant, client_billing_plan_id, client_id, plan_id, service_category,
is_active, start_date, end_date, client_bundle_id
)
SELECT
tenant, company_billing_plan_id, company_id, plan_id, service_category,
is_active, start_date, end_date, company_bundle_id
FROM company_billing_plans
ON CONFLICT (tenant, client_billing_plan_id) DO NOTHING
`);
const count = await knex('client_billing_plans').count('* as count');
console.log(`✓ client_billing_plans has ${count[0].count} rows`);
}
async function createClientPlanBundles(knex, createdTables) {
const exists = await knex.schema.hasTable('client_plan_bundles');
if (!exists) {
console.log('Creating client_plan_bundles...');
createdTables.push('client_plan_bundles');
await knex.schema.createTable('client_plan_bundles', (table) => {
table.uuid('tenant').notNullable();
table.uuid('client_bundle_id').defaultTo(knex.raw('gen_random_uuid()')).notNullable();
table.uuid('client_id').notNullable();
table.uuid('bundle_id').notNullable();
table.timestamp('start_date', { useTz: true }).notNullable();
table.timestamp('end_date', { useTz: true });
table.boolean('is_active').defaultTo(true);
table.timestamp('created_at', { useTz: true }).defaultTo(knex.raw('CURRENT_TIMESTAMP'));
table.timestamp('updated_at', { useTz: true }).defaultTo(knex.raw('CURRENT_TIMESTAMP'));
table.primary(['tenant', 'client_bundle_id']);
});
// Add unique constraint
await knex.raw(`
ALTER TABLE client_plan_bundles
ADD CONSTRAINT client_plan_bundles_tenant_client_bundle_id_unique UNIQUE (tenant, client_bundle_id)
`);
// Add indexes
await knex.raw('CREATE INDEX client_plan_bundles_tenant_index ON client_plan_bundles(tenant)');
await knex.raw('CREATE INDEX client_plan_bundles_client_id_index ON client_plan_bundles(client_id)');
await knex.raw('CREATE INDEX client_plan_bundles_bundle_id_index ON client_plan_bundles(bundle_id)');
} else {
console.log('client_plan_bundles already exists, skipping creation...');
}
// Backfill (always run, idempotent)
console.log('Backfilling client_plan_bundles from company_plan_bundles...');
await knex.raw(`
INSERT INTO client_plan_bundles (
tenant, client_bundle_id, client_id, bundle_id, start_date, end_date,
is_active, created_at, updated_at
)
SELECT
tenant, company_bundle_id, company_id, bundle_id, start_date, end_date,
is_active, created_at, updated_at
FROM company_plan_bundles
ON CONFLICT (tenant, client_bundle_id) DO NOTHING
`);
const count = await knex('client_plan_bundles').count('* as count');
console.log(`✓ client_plan_bundles has ${count[0].count} rows`);
}
async function addClientIdColumns(knex, addedColumns) {
console.log('Adding client_id columns to dependent tables...');
const tables = [
'assets', 'bucket_usage', 'company_billing_plans', 'company_plan_bundles',
'contacts', 'credit_reconciliation_reports', 'credit_tracking',
'inbound_ticket_defaults', 'interactions', 'invoices', 'payment_methods',
'plan_discounts', 'projects', 'tenant_companies', 'tickets',
'transactions', 'usage_tracking'
];
for (const table of tables) {
const tableExists = await knex.schema.hasTable(table);
if (!tableExists) {
console.log(` ⊘ Table ${table} does not exist, skipping...`);
continue;
}
const hasColumn = await knex.schema.hasColumn(table, 'client_id');
if (!hasColumn) {
console.log(` Adding client_id to ${table}...`);
await knex.schema.table(table, (t) => {
t.uuid('client_id');
});
addedColumns.push({ table, column: 'client_id' });
} else {
console.log(`${table}.client_id already exists`);
}
}
console.log('✓ Finished adding client_id columns');
}
async function backfillClientIds(knex) {
console.log('Backfilling client_id columns...');
const tables = [
'assets', 'bucket_usage', 'company_billing_plans', 'company_plan_bundles',
'contacts', 'credit_reconciliation_reports', 'credit_tracking',
'inbound_ticket_defaults', 'interactions', 'invoices', 'payment_methods',
'plan_discounts', 'projects', 'tenant_companies', 'tickets',
'transactions', 'usage_tracking'
];
for (const table of tables) {
const tableExists = await knex.schema.hasTable(table);
if (!tableExists) {
console.log(` ⊘ Table ${table} does not exist, skipping...`);
continue;
}
const hasCompanyId = await knex.schema.hasColumn(table, 'company_id');
if (!hasCompanyId) {
console.log(`${table} has no company_id column, skipping...`);
continue;
}
console.log(` Backfilling ${table}.client_id...`);
const result = await knex.raw(`
UPDATE ${table}
SET client_id = company_id
WHERE client_id IS NULL AND company_id IS NOT NULL
`);
console.log(` ✓ Updated ${result.rowCount || 0} rows`);
}
console.log('✓ Finished backfilling client_id columns');
}
async function verifyRowCounts(knex) {
console.log('\nVerifying row counts...');
// Verify main table
const companiesCount = await knex('companies').count('* as count');
const clientsCount = await knex('clients').count('* as count');
if (companiesCount[0].count !== clientsCount[0].count) {
throw new Error(`Row count mismatch: companies=${companiesCount[0].count}, clients=${clientsCount[0].count}`);
}
console.log(` ✓ clients: ${clientsCount[0].count} rows match companies`);
// Verify related tables
const relatedTables = [
{ old: 'company_locations', new: 'client_locations' },
{ old: 'company_billing_cycles', new: 'client_billing_cycles' },
{ old: 'company_billing_settings', new: 'client_billing_settings' },
{ old: 'company_tax_settings', new: 'client_tax_settings' },
{ old: 'company_tax_rates', new: 'client_tax_rates' },
{ old: 'company_billing_plans', new: 'client_billing_plans' },
{ old: 'company_plan_bundles', new: 'client_plan_bundles' }
];
for (const { old: oldTable, new: newTable } of relatedTables) {
const oldTableExists = await knex.schema.hasTable(oldTable);
const newTableExists = await knex.schema.hasTable(newTable);
if (!oldTableExists || !newTableExists) {
console.log(` ⊘ Skipping ${oldTable}${newTable} (table missing)`);
continue;
}
const oldCount = await knex(oldTable).count('* as count');
const newCount = await knex(newTable).count('* as count');
if (oldCount[0].count !== newCount[0].count) {
throw new Error(`Row count mismatch: ${oldTable}=${oldCount[0].count}, ${newTable}=${newCount[0].count}`);
}
console.log(`${newTable}: ${newCount[0].count} rows match ${oldTable}`);
}
console.log('\n✓ All row count verifications passed');
}
exports.down = async function(knex) {
console.log('Rolling back company to client migration...');
// Restore tenants.client_name → tenants.company_name if needed
const tenantHasClientName = await knex.schema.hasColumn('tenants', 'client_name');
const tenantHasCompanyName = await knex.schema.hasColumn('tenants', 'company_name');
if (tenantHasClientName && !tenantHasCompanyName) {
console.log('Renaming tenants.client_name back to tenants.company_name...');
await knex.raw('ALTER TABLE tenants RENAME COLUMN client_name TO company_name');
console.log('✓ tenants table column restored to company_name');
}
// Drop new tables
await knex.schema.dropTableIfExists('client_plan_bundles');
await knex.schema.dropTableIfExists('client_billing_plans');
await knex.schema.dropTableIfExists('client_tax_rates');
await knex.schema.dropTableIfExists('client_tax_settings');
await knex.schema.dropTableIfExists('client_billing_settings');
await knex.schema.dropTableIfExists('client_billing_cycles');
await knex.schema.dropTableIfExists('client_locations');
await knex.schema.dropTableIfExists('clients');
// Drop client_id columns
const tables = [
'assets', 'bucket_usage', 'company_billing_plans', 'company_plan_bundles',
'contacts', 'credit_reconciliation_reports', 'credit_tracking',
'inbound_ticket_defaults', 'interactions', 'invoices', 'payment_methods',
'plan_discounts', 'projects', 'tenant_companies', 'tickets',
'transactions', 'usage_tracking'
];
for (const table of tables) {
const tableExists = await knex.schema.hasTable(table);
if (!tableExists) continue;
const hasColumn = await knex.schema.hasColumn(table, 'client_id');
if (hasColumn) {
await knex.schema.table(table, (t) => {
t.dropColumn('client_id');
});
}
}
console.log('✓ Rollback completed');
};