PSA/server/migrations/20260610150000_make_standard_statuses_global.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

168 lines
6.9 KiB
JavaScript

/**
* Convert standard_statuses from a per-tenant table into a global reference
* catalog, matching every other standard_* table.
*
* Per-tenant copies are collapsed to one canonical row per (name, item_type);
* rows referencing the removed duplicates are remapped before deletion.
* In Citus deployments the table is already a reference table, so all DDL
* here propagates without distribution changes.
*/
const STANDARD_STATUS_CATALOG = [
{ name: 'Planned', item_type: 'project', display_order: 1, is_closed: false, is_default: false },
{ name: 'In Progress', item_type: 'project', display_order: 2, is_closed: false, is_default: false },
{ name: 'On Hold', item_type: 'project', display_order: 3, is_closed: false, is_default: false },
{ name: 'Completed', item_type: 'project', display_order: 4, is_closed: true, is_default: false },
{ name: 'Cancelled', item_type: 'project', display_order: 5, is_closed: true, is_default: false },
{ name: 'To Do', item_type: 'project_task', display_order: 1, is_closed: false, is_default: false },
{ name: 'In Progress', item_type: 'project_task', display_order: 2, is_closed: false, is_default: false },
{ name: 'In Review', item_type: 'project_task', display_order: 3, is_closed: false, is_default: false },
{ name: 'Done', item_type: 'project_task', display_order: 4, is_closed: true, is_default: false },
{ name: 'Blocked', item_type: 'project_task', display_order: 5, is_closed: false, is_default: false },
{ name: 'Open', item_type: 'ticket', display_order: 1, is_closed: false, is_default: true },
{ name: 'In Progress', item_type: 'ticket', display_order: 2, is_closed: false, is_default: false },
{ name: 'Waiting for Customer', item_type: 'ticket', display_order: 3, is_closed: false, is_default: false },
{ name: 'Resolved', item_type: 'ticket', display_order: 4, is_closed: true, is_default: false },
{ name: 'Closed', item_type: 'ticket', display_order: 5, is_closed: true, is_default: false },
{ name: 'Planned', item_type: 'interaction', display_order: 1, is_closed: false, is_default: false },
{ name: 'In Progress', item_type: 'interaction', display_order: 2, is_closed: false, is_default: false },
{ name: 'Completed', item_type: 'interaction', display_order: 3, is_closed: true, is_default: true },
{ name: 'Cancelled', item_type: 'interaction', display_order: 4, is_closed: true, is_default: false },
];
const CANONICAL_MAP_SQL = `
SELECT ss.standard_status_id AS old_id, m.canonical_id
FROM standard_statuses ss
JOIN (
SELECT name, item_type, min(standard_status_id::text)::uuid AS canonical_id
FROM standard_statuses
GROUP BY name, item_type
) m ON m.name = ss.name AND m.item_type = ss.item_type
WHERE ss.standard_status_id <> m.canonical_id
`;
async function recoverOrphanedProjectTaskMappings(knex) {
const allStandardStatuses = await knex('standard_statuses').select('standard_status_id');
const projectTaskStatuses = await knex('standard_statuses')
.select('name', 'standard_status_id')
.where({ item_type: 'project_task' });
if (allStandardStatuses.length === 0 || projectTaskStatuses.length === 0) {
return;
}
const caseClauses = [];
const bindings = [];
for (const status of projectTaskStatuses) {
caseClauses.push('WHEN ? THEN ?::uuid');
bindings.push(status.name, status.standard_status_id);
}
bindings.push(
allStandardStatuses.map((status) => status.standard_status_id),
projectTaskStatuses.map((status) => status.name),
);
// Keep the distributed UPDATE independent of standard_statuses. Citus rejects
// this recovery step when the target table is distributed and the UPDATE also
// reads the reference/local catalog in FROM or a correlated subquery.
await knex.raw(`
UPDATE project_status_mappings
SET standard_status_id = CASE custom_name
${caseClauses.join('\n ')}
ELSE standard_status_id
END
WHERE is_standard = true
AND standard_status_id IS NOT NULL
AND NOT (standard_status_id = ANY(?::uuid[]))
AND custom_name = ANY(?::text[])
`, bindings);
}
exports.config = { transaction: false };
exports.up = async function up(knex) {
const hasTenant = await knex.schema.hasColumn('standard_statuses', 'tenant');
if (hasTenant) {
await knex.raw('DROP POLICY IF EXISTS tenant_isolation_policy ON standard_statuses');
await knex.raw('ALTER TABLE standard_statuses DISABLE ROW LEVEL SECURITY');
await knex.raw(`
UPDATE project_status_mappings psm
SET standard_status_id = canon.canonical_id
FROM (${CANONICAL_MAP_SQL}) canon
WHERE psm.standard_status_id = canon.old_id
`);
if (await knex.schema.hasTable('project_template_status_mappings')) {
await knex.raw(`
UPDATE project_template_status_mappings ptsm
SET status_id = canon.canonical_id
FROM (${CANONICAL_MAP_SQL}) canon
WHERE ptsm.status_id = canon.old_id
`);
}
if (await knex.schema.hasColumn('statuses', 'standard_status_id')) {
await knex.raw(`
UPDATE statuses s
SET standard_status_id = canon.canonical_id
FROM (${CANONICAL_MAP_SQL}) canon
WHERE s.standard_status_id = canon.old_id
`);
}
await knex.raw(`
DELETE FROM standard_statuses ss
USING (
SELECT name, item_type, min(standard_status_id::text)::uuid AS canonical_id
FROM standard_statuses
GROUP BY name, item_type
) m
WHERE m.name = ss.name AND m.item_type = ss.item_type
AND ss.standard_status_id <> m.canonical_id
`);
// Mappings left pointing at rows that no longer exist (e.g. removed by
// tenant deletion before this migration) are recovered via custom_name.
await recoverOrphanedProjectTaskMappings(knex);
await knex.raw('ALTER TABLE standard_statuses DROP CONSTRAINT IF EXISTS standard_statuses_name_item_type_tenant_key');
await knex.raw('ALTER TABLE standard_statuses DROP COLUMN tenant');
await knex.raw(`
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'standard_statuses_name_item_type_key'
AND conrelid = 'standard_statuses'::regclass
) THEN
ALTER TABLE standard_statuses
ADD CONSTRAINT standard_statuses_name_item_type_key UNIQUE (name, item_type);
END IF;
END
$$
`);
}
await knex('standard_statuses')
.insert(STANDARD_STATUS_CATALOG)
.onConflict(['name', 'item_type'])
.merge(['display_order', 'is_closed', 'is_default']);
};
exports.down = async function down(knex) {
const hasTenant = await knex.schema.hasColumn('standard_statuses', 'tenant');
if (!hasTenant) {
// Per-tenant copies cannot be reconstructed; restore the old shape with
// the global rows left as tenant-less seeds.
await knex.raw('ALTER TABLE standard_statuses DROP CONSTRAINT IF EXISTS standard_statuses_name_item_type_key');
await knex.raw('ALTER TABLE standard_statuses ADD COLUMN tenant uuid REFERENCES tenants (tenant)');
}
};