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
Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz Source: /opt/alga-psa on psa.joliet.tech
689 lines
28 KiB
JavaScript
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');
|
|
};
|