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
481 lines
16 KiB
JavaScript
481 lines
16 KiB
JavaScript
/**
|
|
* Migration 2 Combined Cleanup: finalize contracts + contract line rename.
|
|
*
|
|
* Run only after the application fully depends on the new contract and
|
|
* contract line identifiers. This drops legacy tables/columns and tightens
|
|
* constraints so the system no longer references the old plan/bundle names.
|
|
*/
|
|
|
|
exports.config = { transaction: false };
|
|
|
|
exports.up = async function up(knex) {
|
|
console.log('='.repeat(80));
|
|
console.log('Starting combined contracts + contract lines cleanup migration...');
|
|
console.log('WARNING: This will drop legacy billing tables/columns. Ensure dual-write period is complete.');
|
|
console.log('='.repeat(80));
|
|
|
|
await addContractForeignKeys(knex);
|
|
await addContractLineForeignKeys(knex);
|
|
await makeContractLineIdNotNull(knex);
|
|
await dropLegacyPlanColumns(knex);
|
|
await dropLegacyBundleColumns(knex);
|
|
await dropLegacyTables(knex);
|
|
await verifyDataIntegrity(knex);
|
|
await finalVerification(knex);
|
|
|
|
console.log('='.repeat(80));
|
|
console.log('✓ Combined cleanup migration completed');
|
|
console.log('='.repeat(80));
|
|
};
|
|
|
|
exports.down = async function down(knex) {
|
|
console.log('='.repeat(80));
|
|
console.log('WARNING: Cleanup rollback is best-effort only and may require manual intervention');
|
|
console.log('Restoring dropped tables/columns automatically is not supported.');
|
|
console.log('='.repeat(80));
|
|
|
|
console.log('⚠ Legacy tables were dropped and are not recreated automatically.');
|
|
};
|
|
|
|
async function addContractForeignKeys(knex) {
|
|
console.log('Adding permanent foreign keys for contracts tables...');
|
|
|
|
const citusEnabled = await knex.raw(`
|
|
SELECT EXISTS (
|
|
SELECT 1 FROM pg_extension WHERE extname = 'citus'
|
|
) AS enabled
|
|
`);
|
|
|
|
const isCitus = citusEnabled.rows[0].enabled;
|
|
|
|
try {
|
|
console.log(' Ensuring client_contracts → contracts FK');
|
|
await knex.schema.alterTable('client_contracts', (table) => {
|
|
if (isCitus) {
|
|
table.foreign(['tenant', 'contract_id'])
|
|
.references(['tenant', 'contract_id'])
|
|
.inTable('contracts')
|
|
.onDelete('CASCADE');
|
|
} else {
|
|
table.foreign('contract_id')
|
|
.references('contract_id')
|
|
.inTable('contracts')
|
|
.onDelete('CASCADE');
|
|
}
|
|
});
|
|
} catch (error) {
|
|
console.log(` ⚠ client_contracts → contracts FK may already exist: ${error.message}`);
|
|
}
|
|
|
|
const clientsExists = await knex.schema.hasTable('clients');
|
|
const targetTable = clientsExists ? 'clients' : 'companies';
|
|
const targetColumn = clientsExists ? 'client_id' : 'company_id';
|
|
|
|
try {
|
|
console.log(` Ensuring client_contracts → ${targetTable} FK`);
|
|
await knex.schema.alterTable('client_contracts', (table) => {
|
|
if (isCitus) {
|
|
table.foreign(['tenant', 'client_id'])
|
|
.references(['tenant', targetColumn])
|
|
.inTable(targetTable)
|
|
.onDelete('CASCADE');
|
|
} else {
|
|
table.foreign('client_id')
|
|
.references(targetColumn)
|
|
.inTable(targetTable)
|
|
.onDelete('CASCADE');
|
|
}
|
|
});
|
|
} catch (error) {
|
|
console.log(` ⚠ client_contracts → ${targetTable} FK may already exist: ${error.message}`);
|
|
}
|
|
|
|
try {
|
|
console.log(' Ensuring contract_line_mappings → contracts FK');
|
|
await knex.schema.alterTable('contract_line_mappings', (table) => {
|
|
if (isCitus) {
|
|
table.foreign(['tenant', 'contract_id'])
|
|
.references(['tenant', 'contract_id'])
|
|
.inTable('contracts')
|
|
.onDelete('CASCADE');
|
|
} else {
|
|
table.foreign('contract_id')
|
|
.references('contract_id')
|
|
.inTable('contracts')
|
|
.onDelete('CASCADE');
|
|
}
|
|
});
|
|
} catch (error) {
|
|
console.log(` ⚠ contract_line_mappings → contracts FK may already exist: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
async function addContractLineForeignKeys(knex) {
|
|
console.log('Adding permanent foreign keys for contract line tables...');
|
|
|
|
const tables = [
|
|
{ table: 'bucket_usage', fkName: 'bucket_usage_contract_line_fk' },
|
|
{ table: 'plan_discounts', fkName: 'plan_discounts_contract_line_fk' },
|
|
{ table: 'plan_service_configuration', fkName: 'plan_service_configuration_contract_line_fk' },
|
|
{ table: 'plan_services', fkName: 'plan_services_contract_line_fk' },
|
|
{ table: 'usage_tracking', fkName: 'usage_tracking_contract_line_fk' },
|
|
{ table: 'contract_line_mappings', fkName: 'contract_line_mappings_contract_line_fk' },
|
|
];
|
|
|
|
for (const { table, fkName } of tables) {
|
|
const exists = await knex.schema.hasTable(table);
|
|
if (!exists) {
|
|
continue;
|
|
}
|
|
|
|
const hasColumn = await knex.schema.hasColumn(table, 'contract_line_id');
|
|
if (!hasColumn) {
|
|
continue;
|
|
}
|
|
|
|
const hasTenant = await knex.schema.hasColumn(table, 'tenant');
|
|
if (!hasTenant) {
|
|
continue;
|
|
}
|
|
|
|
const existingFk = await knex.raw(`
|
|
SELECT constraint_name
|
|
FROM information_schema.table_constraints
|
|
WHERE table_name = '${table}'
|
|
AND constraint_type = 'FOREIGN KEY'
|
|
AND constraint_name = '${fkName}'
|
|
AND table_schema = current_schema()
|
|
`);
|
|
|
|
if (existingFk.rows.length > 0) {
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
console.log(` Adding ${fkName} on ${table}`);
|
|
await knex.raw(`
|
|
ALTER TABLE ${table}
|
|
ADD CONSTRAINT ${fkName}
|
|
FOREIGN KEY (tenant, contract_line_id)
|
|
REFERENCES contract_lines(tenant, contract_line_id)
|
|
ON DELETE CASCADE
|
|
`);
|
|
} catch (error) {
|
|
console.log(` ⚠ Could not add ${fkName}: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
const timeEntriesExists = await knex.schema.hasTable('time_entries');
|
|
if (timeEntriesExists) {
|
|
const fkExists = await knex.raw(`
|
|
SELECT constraint_name
|
|
FROM information_schema.table_constraints
|
|
WHERE table_name = 'time_entries'
|
|
AND constraint_type = 'FOREIGN KEY'
|
|
AND constraint_name = 'time_entries_client_contract_line_fk'
|
|
AND table_schema = current_schema()
|
|
`);
|
|
|
|
if (fkExists.rows.length === 0) {
|
|
try {
|
|
console.log(' Adding time_entries_client_contract_line_fk on time_entries');
|
|
await knex.raw(`
|
|
ALTER TABLE time_entries
|
|
ADD CONSTRAINT time_entries_client_contract_line_fk
|
|
FOREIGN KEY (tenant, contract_line_id)
|
|
REFERENCES client_contract_lines(tenant, client_contract_line_id)
|
|
ON DELETE SET NULL
|
|
`);
|
|
} catch (error) {
|
|
console.log(` ⚠ Could not add FK on time_entries: ${error.message}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
const clientContractLinesExists = await knex.schema.hasTable('client_contract_lines');
|
|
if (clientContractLinesExists) {
|
|
const fkExists = await knex.raw(`
|
|
SELECT constraint_name
|
|
FROM information_schema.table_constraints
|
|
WHERE table_name = 'client_contract_lines'
|
|
AND constraint_type = 'FOREIGN KEY'
|
|
AND constraint_name = 'client_contract_lines_contract_line_fk'
|
|
AND table_schema = current_schema()
|
|
`);
|
|
|
|
if (fkExists.rows.length === 0) {
|
|
try {
|
|
console.log(' Adding client_contract_lines_contract_line_fk');
|
|
await knex.raw(`
|
|
ALTER TABLE client_contract_lines
|
|
ADD CONSTRAINT client_contract_lines_contract_line_fk
|
|
FOREIGN KEY (tenant, contract_line_id)
|
|
REFERENCES contract_lines(tenant, contract_line_id)
|
|
ON DELETE CASCADE
|
|
`);
|
|
} catch (error) {
|
|
console.log(` ⚠ Could not add client_contract_lines FK: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
const contractFkExists = await knex.raw(`
|
|
SELECT constraint_name
|
|
FROM information_schema.table_constraints
|
|
WHERE table_name = 'client_contract_lines'
|
|
AND constraint_type = 'FOREIGN KEY'
|
|
AND constraint_name = 'client_contract_lines_client_contract_fk'
|
|
AND table_schema = current_schema()
|
|
`);
|
|
|
|
if (contractFkExists.rows.length === 0) {
|
|
try {
|
|
console.log(' Adding client_contract_lines_client_contract_fk');
|
|
await knex.raw(`
|
|
ALTER TABLE client_contract_lines
|
|
ADD CONSTRAINT client_contract_lines_client_contract_fk
|
|
FOREIGN KEY (tenant, client_contract_id)
|
|
REFERENCES client_contracts(tenant, client_contract_id)
|
|
ON DELETE SET NULL
|
|
`);
|
|
} catch (error) {
|
|
console.log(` ⚠ Could not add client_contract_lines → client_contracts FK: ${error.message}`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async function makeContractLineIdNotNull(knex) {
|
|
console.log('Making contract_line_id columns NOT NULL where safe...');
|
|
|
|
const tables = [
|
|
'bucket_usage',
|
|
'plan_discounts',
|
|
'plan_service_configuration',
|
|
'plan_services',
|
|
'contract_line_mappings',
|
|
];
|
|
|
|
for (const table of tables) {
|
|
const tableExists = await knex.schema.hasTable(table);
|
|
if (!tableExists) {
|
|
continue;
|
|
}
|
|
|
|
const hasColumn = await knex.schema.hasColumn(table, 'contract_line_id');
|
|
if (!hasColumn) {
|
|
continue;
|
|
}
|
|
|
|
const [{ count }] = await knex(table).whereNull('contract_line_id').count('* as count');
|
|
if (parseInt(count, 10) > 0) {
|
|
console.log(` ⚠ ${table} still has ${count} NULL contract_line_id values; skipping NOT NULL`);
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
await knex.raw(`ALTER TABLE ${table} ALTER COLUMN contract_line_id SET NOT NULL`);
|
|
console.log(` ✓ Set contract_line_id NOT NULL on ${table}`);
|
|
} catch (error) {
|
|
console.log(` ⚠ Failed to set NOT NULL on ${table}: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
console.log(' Skipping NOT NULL for time_entries/usage_tracking (optional linkage)');
|
|
}
|
|
|
|
async function dropLegacyPlanColumns(knex) {
|
|
console.log('Dropping legacy plan_id/billing_plan_id columns...');
|
|
|
|
const planTables = [
|
|
'bucket_usage',
|
|
'plan_discounts',
|
|
'plan_service_configuration',
|
|
'plan_services',
|
|
'contract_line_mappings',
|
|
];
|
|
|
|
for (const table of planTables) {
|
|
const exists = await knex.schema.hasTable(table);
|
|
if (!exists) {
|
|
continue;
|
|
}
|
|
|
|
const hasColumn = await knex.schema.hasColumn(table, 'plan_id');
|
|
if (!hasColumn) {
|
|
continue;
|
|
}
|
|
|
|
await dropColumnWithFk(knex, table, 'plan_id');
|
|
}
|
|
|
|
const billingPlanTables = ['time_entries', 'usage_tracking'];
|
|
|
|
for (const table of billingPlanTables) {
|
|
const exists = await knex.schema.hasTable(table);
|
|
if (!exists) {
|
|
continue;
|
|
}
|
|
|
|
const hasColumn = await knex.schema.hasColumn(table, 'billing_plan_id');
|
|
if (!hasColumn) {
|
|
continue;
|
|
}
|
|
|
|
await dropColumnWithFk(knex, table, 'billing_plan_id');
|
|
}
|
|
}
|
|
|
|
async function dropLegacyBundleColumns(knex) {
|
|
console.log('Dropping legacy bundle columns...');
|
|
|
|
const tableExists = await knex.schema.hasTable('client_billing_plans');
|
|
if (!tableExists) {
|
|
console.log(' client_billing_plans table not found, skipping');
|
|
return;
|
|
}
|
|
|
|
const hasColumn = await knex.schema.hasColumn('client_billing_plans', 'client_bundle_id');
|
|
if (!hasColumn) {
|
|
console.log(' client_bundle_id already removed');
|
|
return;
|
|
}
|
|
|
|
await dropColumnWithFk(knex, 'client_billing_plans', 'client_bundle_id');
|
|
}
|
|
|
|
async function dropLegacyTables(knex) {
|
|
console.log('Dropping legacy plan/billing tables...');
|
|
console.log('Note: Tables should already be undistributed from step 2 migration');
|
|
|
|
const tables = [
|
|
'bundle_billing_plans',
|
|
'client_plan_bundles',
|
|
'plan_bundles',
|
|
'client_billing_plans',
|
|
'billing_plan_fixed_config',
|
|
'billing_plans',
|
|
'plan_services',
|
|
'plan_service_configuration',
|
|
'plan_discounts',
|
|
'plan_service_bucket_config',
|
|
'plan_service_fixed_config',
|
|
'plan_service_hourly_config',
|
|
'plan_service_hourly_configs',
|
|
'plan_service_rate_tiers',
|
|
'plan_service_usage_config',
|
|
];
|
|
|
|
for (const table of tables) {
|
|
const exists = await knex.schema.hasTable(table);
|
|
if (!exists) {
|
|
continue;
|
|
}
|
|
|
|
console.log(` Dropping ${table}...`);
|
|
await knex.raw(`DROP TABLE IF EXISTS ${table} CASCADE`);
|
|
}
|
|
}
|
|
|
|
async function dropColumnWithFk(knex, tableName, columnName) {
|
|
console.log(` Dropping column ${tableName}.${columnName}`);
|
|
|
|
try {
|
|
const fks = await knex.raw(`
|
|
SELECT constraint_name
|
|
FROM information_schema.table_constraints
|
|
WHERE table_name = '${tableName}'
|
|
AND constraint_type = 'FOREIGN KEY'
|
|
AND constraint_name LIKE '%${columnName}%'
|
|
AND table_schema = current_schema()
|
|
`);
|
|
|
|
for (const fk of fks.rows) {
|
|
await knex.raw(`ALTER TABLE ${tableName} DROP CONSTRAINT IF EXISTS ${fk.constraint_name}`);
|
|
}
|
|
|
|
await knex.schema.table(tableName, (table) => {
|
|
table.dropColumn(columnName);
|
|
});
|
|
console.log(` ✓ Dropped ${columnName} from ${tableName}`);
|
|
} catch (error) {
|
|
console.log(` ⚠ Could not drop ${columnName} from ${tableName}: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
async function verifyDataIntegrity(knex) {
|
|
console.log('Verifying data integrity after cleanup...');
|
|
|
|
const invalidClientContracts = await knex.raw(`
|
|
SELECT COUNT(*) AS count
|
|
FROM client_contracts cc
|
|
LEFT JOIN contracts c ON cc.tenant = c.tenant AND cc.contract_id = c.contract_id
|
|
WHERE c.contract_id IS NULL
|
|
`);
|
|
|
|
if (parseInt(invalidClientContracts.rows[0].count, 10) > 0) {
|
|
throw new Error(`Found ${invalidClientContracts.rows[0].count} client_contracts with invalid contract references`);
|
|
}
|
|
|
|
const invalidMappings = await knex.raw(`
|
|
SELECT COUNT(*) AS count
|
|
FROM contract_line_mappings clm
|
|
LEFT JOIN contracts c ON clm.tenant = c.tenant AND clm.contract_id = c.contract_id
|
|
WHERE c.contract_id IS NULL
|
|
`);
|
|
|
|
if (parseInt(invalidMappings.rows[0].count, 10) > 0) {
|
|
throw new Error(`Found ${invalidMappings.rows[0].count} contract_line_mappings with invalid contract references`);
|
|
}
|
|
|
|
console.log(' ✓ Data integrity checks passed');
|
|
}
|
|
|
|
async function finalVerification(knex) {
|
|
console.log('Final verification: ensure new schema is authoritative...');
|
|
|
|
const checks = [
|
|
{ table: 'contracts', shouldExist: true },
|
|
{ table: 'client_contracts', shouldExist: true },
|
|
{ table: 'contract_lines', shouldExist: true },
|
|
{ table: 'client_contract_lines', shouldExist: true },
|
|
{ table: 'plan_bundles', shouldExist: false },
|
|
{ table: 'client_plan_bundles', shouldExist: false },
|
|
{ table: 'bundle_billing_plans', shouldExist: false },
|
|
{ table: 'billing_plans', shouldExist: false },
|
|
{ table: 'billing_plan_fixed_config', shouldExist: false },
|
|
{ table: 'client_billing_plans', shouldExist: false },
|
|
{ table: 'plan_services', shouldExist: false },
|
|
{ table: 'plan_service_configuration', shouldExist: false },
|
|
{ table: 'plan_discounts', shouldExist: false },
|
|
{ table: 'plan_service_bucket_config', shouldExist: false },
|
|
{ table: 'plan_service_fixed_config', shouldExist: false },
|
|
{ table: 'plan_service_hourly_config', shouldExist: false },
|
|
{ table: 'plan_service_hourly_configs', shouldExist: false },
|
|
{ table: 'plan_service_rate_tiers', shouldExist: false },
|
|
{ table: 'plan_service_usage_config', shouldExist: false },
|
|
];
|
|
|
|
for (const check of checks) {
|
|
const exists = await knex.schema.hasTable(check.table);
|
|
if (check.shouldExist && !exists) {
|
|
throw new Error(`Required table ${check.table} is missing after cleanup`);
|
|
}
|
|
|
|
if (!check.shouldExist && exists) {
|
|
throw new Error(`Legacy table ${check.table} still exists after cleanup`);
|
|
}
|
|
}
|
|
|
|
const hasContractName = await knex.schema.hasColumn('contracts', 'contract_name');
|
|
if (!hasContractName) {
|
|
throw new Error('contracts.contract_name column missing after rename');
|
|
}
|
|
|
|
const hasContractLineName = await knex.schema.hasColumn('contract_lines', 'contract_line_name');
|
|
if (!hasContractLineName) {
|
|
throw new Error('contract_lines.contract_line_name column missing after rename');
|
|
}
|
|
|
|
console.log(' ✓ Final verification complete');
|
|
}
|