PSA/server/migrations/utils/client_owned_contracts_simplification.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

293 lines
8.9 KiB
JavaScript

const normalizeDateOnly = (value) => {
if (typeof value !== 'string') {
return null;
}
const trimmed = value.trim();
if (!trimmed) {
return null;
}
if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) {
return trimmed;
}
if (trimmed.includes('T')) {
return trimmed.slice(0, 10);
}
return trimmed;
};
const compareAssignmentOrder = (left, right) => {
const leftDate = normalizeDateOnly(left.start_date) ?? '9999-12-31';
const rightDate = normalizeDateOnly(right.start_date) ?? '9999-12-31';
if (leftDate !== rightDate) {
return leftDate.localeCompare(rightDate);
}
const leftId = typeof left.client_contract_id === 'string' ? left.client_contract_id : '';
const rightId = typeof right.client_contract_id === 'string' ? right.client_contract_id : '';
return leftId.localeCompare(rightId);
};
const sortAssignmentsDeterministically = (assignments) => [...assignments].sort(compareAssignmentOrder);
const toPositiveCount = (value) => {
const numeric = typeof value === 'string' ? Number(value) : value;
if (typeof numeric !== 'number' || !Number.isFinite(numeric)) {
return 0;
}
return Math.max(0, Math.trunc(numeric));
};
const distinctClientCount = (rows) => new Set(rows.map((row) => row.client_id).filter(Boolean)).size;
function detectSharedNonTemplateContractGroups(rows) {
const grouped = new Map();
for (const row of rows) {
if (row?.is_template === true) {
continue;
}
const key = `${row.tenant}:${row.contract_id}`;
const existing = grouped.get(key) ?? [];
existing.push(row);
grouped.set(key, existing);
}
return [...grouped.values()]
.filter((groupRows) => distinctClientCount(groupRows) > 1)
.map((groupRows) => sortAssignmentsDeterministically(groupRows));
}
function selectPreservedAssignment(assignments) {
if (!Array.isArray(assignments) || assignments.length === 0) {
throw new Error('At least one assignment is required to select the preserved contract owner');
}
const ordered = sortAssignmentsDeterministically(assignments);
const invoicedAssignments = ordered.filter((assignment) => toPositiveCount(assignment.invoice_count) > 0);
if (invoicedAssignments.length === 1) {
return {
preservedAssignment: invoicedAssignments[0],
reason: 'single_invoiced_assignment',
};
}
return {
preservedAssignment: ordered[0],
reason: 'earliest_start_date',
};
}
function assertCloneTargetsSupported(params) {
const {
tenant,
contractId,
cloneTargets,
contractDocumentAssociationsCount = 0,
pricingScheduleCount = 0,
timeEntryCount = 0,
usageTrackingCount = 0,
} = params;
if (!Array.isArray(cloneTargets) || cloneTargets.length === 0) {
return;
}
const unsupportedReasons = [];
if (toPositiveCount(contractDocumentAssociationsCount) > 0) {
unsupportedReasons.push('contract-scoped document associations exist');
}
if (toPositiveCount(pricingScheduleCount) > 0) {
unsupportedReasons.push('contract pricing schedules exist');
}
if (toPositiveCount(timeEntryCount) > 0) {
unsupportedReasons.push('contract-line time entries exist');
}
if (toPositiveCount(usageTrackingCount) > 0) {
unsupportedReasons.push('contract-line usage tracking exists');
}
if (unsupportedReasons.length > 0) {
throw new Error(
`Cannot split shared contract ${contractId} in tenant ${tenant}: ${unsupportedReasons.join(
'; '
)}.`
);
}
}
function remapRows(rows, transform) {
return Array.isArray(rows) ? rows.map((row) => transform({ ...row })) : [];
}
function buildSharedContractClonePlan(params, options = {}) {
const {
sourceContract,
assignments,
contractLines = [],
contractLineServices = [],
contractLineServiceDefaults = [],
contractLineDiscounts = [],
contractLineServiceConfigurations = [],
contractLineServiceBucketConfigs = [],
contractLineServiceFixedConfigs = [],
contractLineServiceHourlyConfig = [],
contractLineServiceHourlyConfigs = [],
contractLineServiceRateTiers = [],
contractLineServiceUsageConfig = [],
} = params;
if (!sourceContract?.contract_id || !sourceContract?.tenant) {
throw new Error('A source contract with tenant and contract_id is required');
}
const orderedAssignments = sortAssignmentsDeterministically(assignments);
const { preservedAssignment, reason } = selectPreservedAssignment(orderedAssignments);
const cloneTargets = orderedAssignments.filter(
(assignment) => assignment.client_contract_id !== preservedAssignment.client_contract_id
);
const createId =
typeof options.createId === 'function'
? options.createId
: () => {
throw new Error('createId option is required');
};
const clones = cloneTargets.map((cloneTarget) => {
const newContractId = createId('contract');
const lineIdMap = new Map();
const configIdMap = new Map();
const clonedContract = {
...sourceContract,
contract_id: newContractId,
owner_client_id: cloneTarget.client_id,
};
const clonedContractLines = remapRows(contractLines, (row) => {
const newLineId = createId('contract_line');
lineIdMap.set(row.contract_line_id, newLineId);
return {
...row,
contract_id: newContractId,
contract_line_id: newLineId,
cadence_owner:
typeof row.cadence_owner === 'string' && row.cadence_owner.length > 0
? row.cadence_owner
: 'client',
};
});
const clonedContractLineServices = remapRows(contractLineServices, (row) => ({
...row,
contract_line_id: lineIdMap.get(row.contract_line_id),
}));
const clonedContractLineServiceDefaults = remapRows(contractLineServiceDefaults, (row) => ({
...row,
default_id: createId('contract_line_default'),
contract_line_id: lineIdMap.get(row.contract_line_id),
}));
const clonedContractLineDiscounts = remapRows(contractLineDiscounts, (row) => ({
...row,
discount_id: createId('contract_line_discount'),
contract_line_id: lineIdMap.get(row.contract_line_id),
}));
const clonedContractLineServiceConfigurations = remapRows(
contractLineServiceConfigurations,
(row) => {
const newConfigId = createId('contract_line_service_config');
configIdMap.set(row.config_id, newConfigId);
return {
...row,
config_id: newConfigId,
contract_line_id: lineIdMap.get(row.contract_line_id),
};
}
);
const remapConfigIdRow = (row) => ({
...row,
config_id: configIdMap.get(row.config_id),
});
const clonedContractLineServiceBucketConfigs = remapRows(
contractLineServiceBucketConfigs,
remapConfigIdRow
);
const clonedContractLineServiceFixedConfigs = remapRows(
contractLineServiceFixedConfigs,
remapConfigIdRow
);
const clonedContractLineServiceHourlyConfig = remapRows(
contractLineServiceHourlyConfig,
remapConfigIdRow
);
const clonedContractLineServiceHourlyConfigs = remapRows(
contractLineServiceHourlyConfigs,
remapConfigIdRow
);
const clonedContractLineServiceUsageConfig = remapRows(
contractLineServiceUsageConfig,
remapConfigIdRow
);
const clonedContractLineServiceRateTiers = remapRows(contractLineServiceRateTiers, (row) => ({
...row,
tier_id: createId('contract_line_service_rate_tier'),
config_id: configIdMap.get(row.config_id),
}));
return {
targetClientContractId: cloneTarget.client_contract_id,
targetClientId: cloneTarget.client_id,
sourceAssignment: cloneTarget,
contract: clonedContract,
clientContractUpdate: {
client_contract_id: cloneTarget.client_contract_id,
contract_id: newContractId,
},
contractLines: clonedContractLines,
contractLineServices: clonedContractLineServices,
contractLineServiceDefaults: clonedContractLineServiceDefaults,
contractLineDiscounts: clonedContractLineDiscounts,
contractLineServiceConfigurations: clonedContractLineServiceConfigurations,
contractLineServiceBucketConfigs: clonedContractLineServiceBucketConfigs,
contractLineServiceFixedConfigs: clonedContractLineServiceFixedConfigs,
contractLineServiceHourlyConfig: clonedContractLineServiceHourlyConfig,
contractLineServiceHourlyConfigs: clonedContractLineServiceHourlyConfigs,
contractLineServiceRateTiers: clonedContractLineServiceRateTiers,
contractLineServiceUsageConfig: clonedContractLineServiceUsageConfig,
};
});
return {
contractId: sourceContract.contract_id,
tenant: sourceContract.tenant,
reason,
preservedAssignment,
preservedContractUpdate: {
contract_id: sourceContract.contract_id,
owner_client_id: preservedAssignment.client_id,
},
clones,
};
}
module.exports = {
detectSharedNonTemplateContractGroups,
selectPreservedAssignment,
assertCloneTargetsSupported,
buildSharedContractClonePlan,
};