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
453 lines
18 KiB
JavaScript
453 lines
18 KiB
JavaScript
/**
|
|
* Backfill dedicated contract template tables from legacy combined schema.
|
|
*
|
|
* This migration copies any legacy template data (rows where `contracts.is_template = true`)
|
|
* into the new `contract_template_*` tables so that subsequent code can rely solely on the
|
|
* separated structure. After backfill, `contracts` is free to represent only client-specific
|
|
* agreements while templates live independently. Original UUIDs are preserved to maintain
|
|
* referential integrity with client contract records.
|
|
*
|
|
* @param { import('knex').Knex } knex
|
|
* @returns { Promise<void> }
|
|
*/
|
|
exports.up = async function up(knex) {
|
|
await knex.transaction(async (trx) => {
|
|
// Contract templates
|
|
const hasLegacyTemplateFlag = await trx.schema.hasColumn('contracts', 'is_template');
|
|
const hasLegacyTemplateStatus = await trx.schema.hasColumn('contracts', 'status');
|
|
const hasLegacyTemplateMetadata = await trx.schema.hasColumn('contracts', 'template_metadata');
|
|
|
|
if (hasLegacyTemplateFlag) {
|
|
await trx
|
|
.insert(function insertTemplates() {
|
|
const templateStatusExpr = hasLegacyTemplateStatus
|
|
? trx.ref('c.status')
|
|
: trx.raw(`CASE WHEN c.is_active = true THEN 'active' ELSE 'inactive' END`);
|
|
const templateMetadataExpr = hasLegacyTemplateMetadata
|
|
? trx.ref('c.template_metadata')
|
|
: trx.raw('NULL::jsonb');
|
|
|
|
this.select({
|
|
tenant: 'c.tenant',
|
|
template_id: 'c.contract_id',
|
|
template_name: 'c.contract_name',
|
|
template_description: 'c.contract_description',
|
|
default_billing_frequency: 'c.billing_frequency',
|
|
template_status: templateStatusExpr,
|
|
template_metadata: templateMetadataExpr,
|
|
created_at: 'c.created_at',
|
|
updated_at: 'c.updated_at',
|
|
})
|
|
.from({ c: 'contracts' })
|
|
.where('c.is_template', true);
|
|
})
|
|
.into('contract_templates')
|
|
.onConflict(['tenant', 'template_id'])
|
|
.ignore();
|
|
}
|
|
|
|
// Template lines
|
|
const hasLegacyTemplateLineFlag = await trx.schema.hasColumn('contract_lines', 'is_template');
|
|
const hasLegacyTemplateTerms = await trx.schema.hasTable('contract_line_template_terms');
|
|
|
|
if (hasLegacyTemplateLineFlag) {
|
|
await trx
|
|
.insert(function insertTemplateLines() {
|
|
const query = this.select({
|
|
tenant: 'cl.tenant',
|
|
template_line_id: 'cl.contract_line_id',
|
|
template_id: 'map.contract_id',
|
|
template_line_name: 'cl.contract_line_name',
|
|
description: 'cl.description',
|
|
billing_frequency: 'cl.billing_frequency',
|
|
line_type: 'cl.contract_line_type',
|
|
service_category: 'cl.service_category',
|
|
is_active: 'cl.is_active',
|
|
enable_overtime: hasLegacyTemplateTerms
|
|
? knex.raw('COALESCE(terms.enable_overtime, cl.enable_overtime, false)')
|
|
: knex.raw('COALESCE(cl.enable_overtime, false)'),
|
|
overtime_rate: hasLegacyTemplateTerms
|
|
? knex.raw('COALESCE(terms.overtime_rate, cl.overtime_rate)')
|
|
: knex.raw('cl.overtime_rate'),
|
|
overtime_threshold: hasLegacyTemplateTerms
|
|
? knex.raw('COALESCE(terms.overtime_threshold, cl.overtime_threshold)')
|
|
: knex.raw('cl.overtime_threshold'),
|
|
enable_after_hours_rate: hasLegacyTemplateTerms
|
|
? knex.raw('COALESCE(terms.enable_after_hours_rate, cl.enable_after_hours_rate, false)')
|
|
: knex.raw('COALESCE(cl.enable_after_hours_rate, false)'),
|
|
after_hours_multiplier: hasLegacyTemplateTerms
|
|
? knex.raw('COALESCE(terms.after_hours_multiplier, cl.after_hours_multiplier)')
|
|
: knex.raw('cl.after_hours_multiplier'),
|
|
minimum_billable_time: hasLegacyTemplateTerms ? knex.ref('terms.minimum_billable_time') : knex.raw('NULL'),
|
|
round_up_to_nearest: hasLegacyTemplateTerms ? knex.ref('terms.round_up_to_nearest') : knex.raw('NULL'),
|
|
created_at: 'cl.created_at',
|
|
updated_at: 'cl.updated_at',
|
|
}).from({ map: 'contract_line_mappings' })
|
|
.join({ cl: 'contract_lines' }, function joinLines() {
|
|
this.on('map.contract_line_id', '=', 'cl.contract_line_id').andOn(
|
|
'map.tenant',
|
|
'=',
|
|
'cl.tenant'
|
|
);
|
|
})
|
|
.join({ c: 'contracts' }, function joinTemplates() {
|
|
this.on('map.contract_id', '=', 'c.contract_id').andOn('map.tenant', '=', 'c.tenant');
|
|
});
|
|
|
|
if (hasLegacyTemplateTerms) {
|
|
query.leftJoin({ terms: 'contract_line_template_terms' }, function joinTerms() {
|
|
this.on('terms.contract_line_id', '=', 'cl.contract_line_id').andOn(
|
|
'terms.tenant',
|
|
'=',
|
|
'cl.tenant'
|
|
);
|
|
});
|
|
}
|
|
|
|
query.where('c.is_template', true);
|
|
})
|
|
.into('contract_template_lines')
|
|
.onConflict(['tenant', 'template_line_id'])
|
|
.ignore();
|
|
}
|
|
|
|
if (hasLegacyTemplateFlag) {
|
|
// Line mappings
|
|
await trx
|
|
.insert(function insertMappings() {
|
|
this.select({
|
|
tenant: 'map.tenant',
|
|
template_id: 'map.contract_id',
|
|
template_line_id: 'map.contract_line_id',
|
|
display_order: 'map.display_order',
|
|
custom_rate: 'map.custom_rate',
|
|
created_at: 'map.created_at',
|
|
})
|
|
.from({ map: 'contract_line_mappings' })
|
|
.join({ c: 'contracts' }, function joinContracts() {
|
|
this.on('map.contract_id', '=', 'c.contract_id').andOn('map.tenant', '=', 'c.tenant');
|
|
})
|
|
.where('c.is_template', true);
|
|
})
|
|
.into('contract_template_line_mappings')
|
|
.onConflict(['tenant', 'template_id', 'template_line_id'])
|
|
.ignore();
|
|
|
|
// Template services
|
|
await trx
|
|
.insert(function insertServices() {
|
|
this.select({
|
|
tenant: 'svc.tenant',
|
|
template_line_id: 'svc.contract_line_id',
|
|
service_id: 'svc.service_id',
|
|
quantity: knex.raw('COALESCE(tpl.default_quantity, svc.quantity)'),
|
|
custom_rate: 'svc.custom_rate',
|
|
notes: 'tpl.notes',
|
|
display_order: knex.raw('COALESCE(tpl.display_order, 0)'),
|
|
created_at: knex.raw('COALESCE(tpl.created_at, NOW())'),
|
|
updated_at: knex.raw('COALESCE(tpl.updated_at, NOW())'),
|
|
})
|
|
.from({ svc: 'contract_line_services' })
|
|
.join({ map: 'contract_line_mappings' }, function joinMappings() {
|
|
this.on('svc.contract_line_id', '=', 'map.contract_line_id').andOn(
|
|
'svc.tenant',
|
|
'=',
|
|
'map.tenant'
|
|
);
|
|
})
|
|
.join({ c: 'contracts' }, function joinTemplates() {
|
|
this.on('map.contract_id', '=', 'c.contract_id').andOn('map.tenant', '=', 'c.tenant');
|
|
})
|
|
.leftJoin({ tpl: 'contract_template_services' }, function joinTemplateSvc() {
|
|
this.on('tpl.contract_line_id', '=', 'svc.contract_line_id')
|
|
.andOn('tpl.service_id', '=', 'svc.service_id')
|
|
.andOn('tpl.tenant', '=', 'svc.tenant');
|
|
})
|
|
.where('c.is_template', true);
|
|
})
|
|
.into('contract_template_line_services')
|
|
.onConflict(['tenant', 'template_line_id', 'service_id'])
|
|
.ignore();
|
|
|
|
// Service configuration + overlays
|
|
await trx
|
|
.insert(function insertServiceConfig() {
|
|
this.select({
|
|
tenant: 'cfg.tenant',
|
|
config_id: 'cfg.config_id',
|
|
template_line_id: 'cfg.contract_line_id',
|
|
service_id: 'cfg.service_id',
|
|
configuration_type: 'cfg.configuration_type',
|
|
custom_rate: 'cfg.custom_rate',
|
|
quantity: 'cfg.quantity',
|
|
created_at: 'cfg.created_at',
|
|
updated_at: 'cfg.updated_at',
|
|
})
|
|
.from({ cfg: 'contract_line_service_configuration' })
|
|
.join({ map: 'contract_line_mappings' }, function joinMappings() {
|
|
this.on('cfg.contract_line_id', '=', 'map.contract_line_id').andOn(
|
|
'cfg.tenant',
|
|
'=',
|
|
'map.tenant'
|
|
);
|
|
})
|
|
.join({ c: 'contracts' }, function joinTemplates() {
|
|
this.on('map.contract_id', '=', 'c.contract_id').andOn('map.tenant', '=', 'c.tenant');
|
|
})
|
|
.where('c.is_template', true);
|
|
})
|
|
.into('contract_template_line_service_configuration')
|
|
.onConflict(['tenant', 'config_id'])
|
|
.ignore();
|
|
|
|
await trx
|
|
.insert(function insertBucket() {
|
|
this.select({
|
|
tenant: 'src.tenant',
|
|
config_id: 'src.config_id',
|
|
total_minutes: 'src.total_minutes',
|
|
billing_period: 'src.billing_period',
|
|
overage_rate: 'src.overage_rate',
|
|
allow_rollover: 'src.allow_rollover',
|
|
created_at: 'src.created_at',
|
|
updated_at: 'src.updated_at',
|
|
})
|
|
.from({ src: 'contract_line_service_bucket_config' })
|
|
.join({ cfg: 'contract_line_service_configuration' }, function joinCfg() {
|
|
this.on('src.config_id', '=', 'cfg.config_id').andOn('src.tenant', '=', 'cfg.tenant');
|
|
})
|
|
.join({ map: 'contract_line_mappings' }, function joinMappings() {
|
|
this.on('cfg.contract_line_id', '=', 'map.contract_line_id').andOn(
|
|
'cfg.tenant',
|
|
'=',
|
|
'map.tenant'
|
|
);
|
|
})
|
|
.join({ c: 'contracts' }, function joinContracts() {
|
|
this.on('map.contract_id', '=', 'c.contract_id').andOn('map.tenant', '=', 'c.tenant');
|
|
})
|
|
.where('c.is_template', true);
|
|
})
|
|
.into('contract_template_line_service_bucket_config')
|
|
.onConflict(['tenant', 'config_id'])
|
|
.ignore();
|
|
|
|
await trx
|
|
.insert(function insertHourly() {
|
|
this.select({
|
|
tenant: 'src.tenant',
|
|
config_id: 'src.config_id',
|
|
minimum_billable_time: 'src.minimum_billable_time',
|
|
round_up_to_nearest: 'src.round_up_to_nearest',
|
|
enable_overtime: 'src.enable_overtime',
|
|
overtime_rate: 'src.overtime_rate',
|
|
overtime_threshold: 'src.overtime_threshold',
|
|
enable_after_hours_rate: 'src.enable_after_hours_rate',
|
|
after_hours_multiplier: 'src.after_hours_multiplier',
|
|
created_at: 'src.created_at',
|
|
updated_at: 'src.updated_at',
|
|
})
|
|
.from({ src: 'contract_line_service_hourly_config' })
|
|
.join({ cfg: 'contract_line_service_configuration' }, function joinCfg() {
|
|
this.on('src.config_id', '=', 'cfg.config_id').andOn('src.tenant', '=', 'cfg.tenant');
|
|
})
|
|
.join({ map: 'contract_line_mappings' }, function joinMappings() {
|
|
this.on('cfg.contract_line_id', '=', 'map.contract_line_id').andOn(
|
|
'cfg.tenant',
|
|
'=',
|
|
'map.tenant'
|
|
);
|
|
})
|
|
.join({ c: 'contracts' }, function joinContracts() {
|
|
this.on('map.contract_id', '=', 'c.contract_id').andOn('map.tenant', '=', 'c.tenant');
|
|
})
|
|
.where('c.is_template', true);
|
|
})
|
|
.into('contract_template_line_service_hourly_config')
|
|
.onConflict(['tenant', 'config_id'])
|
|
.ignore();
|
|
|
|
await trx
|
|
.insert(function insertUsage() {
|
|
this.select({
|
|
tenant: 'src.tenant',
|
|
config_id: 'src.config_id',
|
|
unit_of_measure: 'src.unit_of_measure',
|
|
enable_tiered_pricing: 'src.enable_tiered_pricing',
|
|
created_at: 'src.created_at',
|
|
updated_at: 'src.updated_at',
|
|
})
|
|
.from({ src: 'contract_line_service_usage_config' })
|
|
.join({ cfg: 'contract_line_service_configuration' }, function joinCfg() {
|
|
this.on('src.config_id', '=', 'cfg.config_id').andOn('src.tenant', '=', 'cfg.tenant');
|
|
})
|
|
.join({ map: 'contract_line_mappings' }, function joinMappings() {
|
|
this.on('cfg.contract_line_id', '=', 'map.contract_line_id').andOn(
|
|
'cfg.tenant',
|
|
'=',
|
|
'map.tenant'
|
|
);
|
|
})
|
|
.join({ c: 'contracts' }, function joinContracts() {
|
|
this.on('map.contract_id', '=', 'c.contract_id').andOn('map.tenant', '=', 'c.tenant');
|
|
})
|
|
.where('c.is_template', true);
|
|
})
|
|
.into('contract_template_line_service_usage_config')
|
|
.onConflict(['tenant', 'config_id'])
|
|
.ignore();
|
|
|
|
await trx
|
|
.insert(function insertDefaults() {
|
|
this.select({
|
|
tenant: 'def.tenant',
|
|
default_id: 'def.default_id',
|
|
template_line_id: 'def.contract_line_id',
|
|
service_id: 'def.service_id',
|
|
line_type: 'def.line_type',
|
|
default_tax_behavior: 'def.default_tax_behavior',
|
|
metadata: 'def.metadata',
|
|
created_at: 'def.created_at',
|
|
updated_at: 'def.updated_at',
|
|
})
|
|
.from({ def: 'contract_line_service_defaults' })
|
|
.join({ map: 'contract_line_mappings' }, function joinMappings() {
|
|
this.on('def.contract_line_id', '=', 'map.contract_line_id').andOn(
|
|
'def.tenant',
|
|
'=',
|
|
'map.tenant'
|
|
);
|
|
})
|
|
.join({ c: 'contracts' }, function joinContracts() {
|
|
this.on('map.contract_id', '=', 'c.contract_id').andOn('map.tenant', '=', 'c.tenant');
|
|
})
|
|
.where('c.is_template', true);
|
|
})
|
|
.into('contract_template_line_defaults')
|
|
.onConflict(['tenant', 'default_id'])
|
|
.ignore();
|
|
|
|
if (hasLegacyTemplateTerms) {
|
|
await trx
|
|
.insert(function insertTerms() {
|
|
this.select({
|
|
tenant: 'terms.tenant',
|
|
template_line_id: 'terms.contract_line_id',
|
|
billing_frequency: 'terms.billing_frequency',
|
|
enable_overtime: 'terms.enable_overtime',
|
|
overtime_rate: 'terms.overtime_rate',
|
|
overtime_threshold: 'terms.overtime_threshold',
|
|
enable_after_hours_rate: 'terms.enable_after_hours_rate',
|
|
after_hours_multiplier: 'terms.after_hours_multiplier',
|
|
minimum_billable_time: 'terms.minimum_billable_time',
|
|
round_up_to_nearest: 'terms.round_up_to_nearest',
|
|
created_at: 'terms.created_at',
|
|
updated_at: 'terms.updated_at',
|
|
})
|
|
.from({ terms: 'contract_line_template_terms' })
|
|
.join({ map: 'contract_line_mappings' }, function joinMappings() {
|
|
this.on('terms.contract_line_id', '=', 'map.contract_line_id').andOn(
|
|
'terms.tenant',
|
|
'=',
|
|
'map.tenant'
|
|
);
|
|
})
|
|
.join({ c: 'contracts' }, function joinContracts() {
|
|
this.on('map.contract_id', '=', 'c.contract_id').andOn('map.tenant', '=', 'c.tenant');
|
|
})
|
|
.where('c.is_template', true);
|
|
})
|
|
.into('contract_template_line_terms')
|
|
.onConflict(['tenant', 'template_line_id'])
|
|
.ignore();
|
|
}
|
|
}
|
|
|
|
if (hasLegacyTemplateFlag) {
|
|
await trx
|
|
.insert(function insertFixedConfig() {
|
|
this.select({
|
|
tenant: 'cfg.tenant',
|
|
template_line_id: 'cfg.contract_line_id',
|
|
base_rate: 'cfg.base_rate',
|
|
enable_proration: 'cfg.enable_proration',
|
|
billing_cycle_alignment: 'cfg.billing_cycle_alignment',
|
|
created_at: 'cfg.created_at',
|
|
updated_at: 'cfg.updated_at',
|
|
})
|
|
.from({ cfg: 'contract_line_fixed_config' })
|
|
.join({ map: 'contract_line_mappings' }, function joinMappings() {
|
|
this.on('cfg.contract_line_id', '=', 'map.contract_line_id').andOn(
|
|
'cfg.tenant',
|
|
'=',
|
|
'map.tenant'
|
|
);
|
|
})
|
|
.join({ c: 'contracts' }, function joinContracts() {
|
|
this.on('map.contract_id', '=', 'c.contract_id').andOn('map.tenant', '=', 'c.tenant');
|
|
})
|
|
.where('c.is_template', true);
|
|
})
|
|
.into('contract_template_line_fixed_config')
|
|
.onConflict(['tenant', 'template_line_id'])
|
|
.ignore();
|
|
|
|
await trx
|
|
.insert(function insertPricingSchedules() {
|
|
this.select({
|
|
tenant: 'ps.tenant',
|
|
schedule_id: 'ps.schedule_id',
|
|
template_id: 'ps.contract_id',
|
|
effective_date: 'ps.effective_date',
|
|
end_date: 'ps.end_date',
|
|
duration_value: 'ps.duration_value',
|
|
duration_unit: 'ps.duration_unit',
|
|
custom_rate: 'ps.custom_rate',
|
|
notes: 'ps.notes',
|
|
created_by: 'ps.created_by',
|
|
updated_by: 'ps.updated_by',
|
|
created_at: 'ps.created_at',
|
|
updated_at: 'ps.updated_at',
|
|
})
|
|
.from({ ps: 'contract_pricing_schedules' })
|
|
.join({ c: 'contracts' }, function joinContracts() {
|
|
this.on('ps.contract_id', '=', 'c.contract_id').andOn('ps.tenant', '=', 'c.tenant');
|
|
})
|
|
.where('c.is_template', true);
|
|
})
|
|
.into('contract_template_pricing_schedules')
|
|
.onConflict(['tenant', 'schedule_id'])
|
|
.ignore();
|
|
}
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Reverting simply clears the new template tables (the legacy combined schema remains).
|
|
*
|
|
* @param { import('knex').Knex } knex
|
|
* @returns { Promise<void> }
|
|
*/
|
|
exports.down = async function down(knex) {
|
|
await knex.transaction(async (trx) => {
|
|
const tables = [
|
|
'contract_template_pricing_schedules',
|
|
'contract_template_line_fixed_config',
|
|
'contract_template_line_terms',
|
|
'contract_template_line_defaults',
|
|
'contract_template_line_service_usage_config',
|
|
'contract_template_line_service_hourly_config',
|
|
'contract_template_line_service_bucket_config',
|
|
'contract_template_line_service_configuration',
|
|
'contract_template_line_services',
|
|
'contract_template_line_mappings',
|
|
'contract_template_lines',
|
|
'contract_templates',
|
|
];
|
|
|
|
for (const table of tables) {
|
|
await trx(table).del();
|
|
}
|
|
});
|
|
};
|