PSA/server/migrations/20260610100000_create_ticket_close_rules_tables.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

297 lines
14 KiB
JavaScript

/**
* Ticket close rules: schema (2026-06-10)
*
* Creates the seven tables behind per-board close validation gates, ticket
* checklists (with accountability + template provenance), and auto-close
* rules. See docs/plans/2026-06-10-ticket-close-rules/PRD.md.
*
* - board_close_rules one row per board: which gates are on
* - checklist_templates admin-defined reusable checklists
* - checklist_template_items items belonging to a template
* - checklist_template_apply_rules auto-apply matchers (null = match any)
* - ticket_checklist_items live checklist on a ticket (items are
* COPIED from templates, never referenced,
* so template edits don't rewrite history)
* - board_auto_close_rules status + inactivity timers per board
* - ticket_auto_close_state scan scratchpad: pending close per ticket
*
* All tables are tenant-scoped with composite (tenant, id) primary keys and
* Citus distribution, following ticket_audit_logs. No RLS policies — newer
* tables rely on application-level tenant scoping (see
* 20251111120000_disable_rls_on_survey_tables.cjs).
*/
// Helper: distribute a table by tenant if Citus is available
async function distributeIfCitus(knex, tableName) {
const citusFn = await knex.raw(`
SELECT EXISTS (
SELECT 1 FROM pg_proc WHERE proname = 'create_distributed_table'
) AS exists;
`);
if (citusFn.rows?.[0]?.exists) {
const alreadyDistributed = await knex.raw(`
SELECT EXISTS (
SELECT 1 FROM pg_dist_partition
WHERE logicalrelid = '${tableName}'::regclass
) AS is_distributed;
`);
if (!alreadyDistributed.rows?.[0]?.is_distributed) {
await knex.raw(`SELECT create_distributed_table('${tableName}', 'tenant')`);
}
}
}
// Helper: add a composite FK only if it doesn't already exist
async function addForeignKeyIfMissing(knex, constraintName, sql) {
await knex.raw(`
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = '${constraintName}'
) THEN
${sql};
END IF;
END $$;
`);
}
exports.up = async function (knex) {
// --- board_close_rules -------------------------------------------------
if (!(await knex.schema.hasTable('board_close_rules'))) {
await knex.schema.createTable('board_close_rules', (table) => {
table.uuid('tenant').notNullable();
table.uuid('board_id').notNullable();
table.boolean('require_resolution_comment').notNullable().defaultTo(false);
table.boolean('require_time_entry').notNullable().defaultTo(false);
table.boolean('require_checklist_complete').notNullable().defaultTo(false);
table.boolean('require_no_open_children').notNullable().defaultTo(false);
table.jsonb('required_fields').notNullable().defaultTo('[]')
.comment('Ticket fields that must be non-null to close: category_id, subcategory_id, priority_id, assigned_to.');
table.boolean('is_enabled').notNullable().defaultTo(true);
table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(knex.fn.now());
table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(knex.fn.now());
table.primary(['tenant', 'board_id']);
});
}
// --- checklist_templates -----------------------------------------------
if (!(await knex.schema.hasTable('checklist_templates'))) {
await knex.schema.createTable('checklist_templates', (table) => {
table.uuid('tenant').notNullable();
table.uuid('template_id').notNullable().defaultTo(knex.raw('gen_random_uuid()'));
table.text('name').notNullable();
table.text('description');
table.boolean('is_active').notNullable().defaultTo(true);
table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(knex.fn.now());
table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(knex.fn.now());
table.primary(['tenant', 'template_id']);
});
}
// --- checklist_template_items -------------------------------------------
if (!(await knex.schema.hasTable('checklist_template_items'))) {
await knex.schema.createTable('checklist_template_items', (table) => {
table.uuid('tenant').notNullable();
table.uuid('template_item_id').notNullable().defaultTo(knex.raw('gen_random_uuid()'));
table.uuid('template_id').notNullable();
table.text('item_name').notNullable();
table.text('description');
table.integer('order_number').notNullable().defaultTo(0);
table.boolean('is_required').notNullable().defaultTo(true);
table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(knex.fn.now());
table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(knex.fn.now());
table.primary(['tenant', 'template_item_id']);
table.index(['tenant', 'template_id'], 'checklist_template_items_template_idx');
});
}
// --- checklist_template_apply_rules --------------------------------------
if (!(await knex.schema.hasTable('checklist_template_apply_rules'))) {
await knex.schema.createTable('checklist_template_apply_rules', (table) => {
table.uuid('tenant').notNullable();
table.uuid('apply_rule_id').notNullable().defaultTo(knex.raw('gen_random_uuid()'));
table.uuid('template_id').notNullable();
table.uuid('board_id').nullable().comment('Null matches any board.');
table.uuid('category_id').nullable().comment('Null matches any category.');
table.uuid('subcategory_id').nullable().comment('Null matches any subcategory.');
table.uuid('priority_id').nullable().comment('Null matches any priority.');
table.boolean('is_enabled').notNullable().defaultTo(true);
table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(knex.fn.now());
table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(knex.fn.now());
table.primary(['tenant', 'apply_rule_id']);
table.index(['tenant', 'template_id'], 'checklist_template_apply_rules_template_idx');
});
}
// --- ticket_checklist_items ----------------------------------------------
if (!(await knex.schema.hasTable('ticket_checklist_items'))) {
await knex.schema.createTable('ticket_checklist_items', (table) => {
table.uuid('tenant').notNullable();
table.uuid('checklist_item_id').notNullable().defaultTo(knex.raw('gen_random_uuid()'));
table.uuid('ticket_id').notNullable();
table.text('item_name').notNullable();
table.text('description');
table.integer('order_number').notNullable().defaultTo(0);
table.uuid('assigned_to').nullable();
table.boolean('is_required').notNullable().defaultTo(true)
.comment('Only required items gate ticket closure.');
table.boolean('completed').notNullable().defaultTo(false);
table.uuid('completed_by').nullable()
.comment('Accountability: who checked the item. Cleared on uncheck (uncheck is audit-logged).');
table.timestamp('completed_at', { useTz: true }).nullable();
table.string('source', 16).notNullable().defaultTo('manual')
.comment('Provenance: manual, template, workflow.');
table.uuid('template_id').nullable()
.comment('Template the item was copied from; also the idempotency key for re-application. No FK — items outlive their template.');
table.uuid('created_by').nullable();
table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(knex.fn.now());
table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(knex.fn.now());
table.primary(['tenant', 'checklist_item_id']);
table.index(['tenant', 'ticket_id'], 'ticket_checklist_items_ticket_idx');
});
await knex.raw(`
ALTER TABLE ticket_checklist_items
ADD CONSTRAINT ticket_checklist_items_source_check
CHECK (source IN ('manual', 'template', 'workflow'))
`);
}
// --- board_auto_close_rules ----------------------------------------------
if (!(await knex.schema.hasTable('board_auto_close_rules'))) {
await knex.schema.createTable('board_auto_close_rules', (table) => {
table.uuid('tenant').notNullable();
table.uuid('rule_id').notNullable().defaultTo(knex.raw('gen_random_uuid()'));
table.uuid('board_id').notNullable();
table.uuid('trigger_status_id').notNullable()
.comment('Open status whose tickets age toward auto-close.');
table.integer('inactivity_days').notNullable();
table.integer('warning_days_before').nullable()
.comment('Send the warning this many days before the scheduled close. Null = no warning.');
table.uuid('close_to_status_id').notNullable()
.comment('Target status; must have is_closed = true (enforced by the server action).');
table.boolean('is_enabled').notNullable().defaultTo(true);
table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(knex.fn.now());
table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(knex.fn.now());
table.primary(['tenant', 'rule_id']);
table.unique(['tenant', 'board_id', 'trigger_status_id'], 'board_auto_close_rules_board_status_uq');
});
await knex.raw(`
ALTER TABLE board_auto_close_rules
ADD CONSTRAINT board_auto_close_rules_inactivity_check
CHECK (inactivity_days > 0)
`);
await knex.raw(`
ALTER TABLE board_auto_close_rules
ADD CONSTRAINT board_auto_close_rules_warning_check
CHECK (warning_days_before IS NULL OR (warning_days_before > 0 AND warning_days_before < inactivity_days))
`);
}
// --- ticket_auto_close_state ----------------------------------------------
if (!(await knex.schema.hasTable('ticket_auto_close_state'))) {
await knex.schema.createTable('ticket_auto_close_state', (table) => {
table.uuid('tenant').notNullable();
table.uuid('ticket_id').notNullable();
table.uuid('rule_id').notNullable();
table.timestamp('scheduled_close_at', { useTz: true }).notNullable();
table.timestamp('warning_sent_at', { useTz: true }).nullable();
table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(knex.fn.now());
table.primary(['tenant', 'ticket_id']);
table.index(['tenant', 'scheduled_close_at'], 'ticket_auto_close_state_due_idx');
});
}
// Distribute before FKs — Citus requires both sides distributed first.
await distributeIfCitus(knex, 'board_close_rules');
await distributeIfCitus(knex, 'checklist_templates');
await distributeIfCitus(knex, 'checklist_template_items');
await distributeIfCitus(knex, 'checklist_template_apply_rules');
await distributeIfCitus(knex, 'ticket_checklist_items');
await distributeIfCitus(knex, 'board_auto_close_rules');
await distributeIfCitus(knex, 'ticket_auto_close_state');
await addForeignKeyIfMissing(knex, 'board_close_rules_board_fkey', `
ALTER TABLE board_close_rules
ADD CONSTRAINT board_close_rules_board_fkey
FOREIGN KEY (tenant, board_id)
REFERENCES boards(tenant, board_id)
ON DELETE CASCADE
`);
await addForeignKeyIfMissing(knex, 'checklist_template_items_template_fkey', `
ALTER TABLE checklist_template_items
ADD CONSTRAINT checklist_template_items_template_fkey
FOREIGN KEY (tenant, template_id)
REFERENCES checklist_templates(tenant, template_id)
ON DELETE CASCADE
`);
await addForeignKeyIfMissing(knex, 'checklist_template_apply_rules_template_fkey', `
ALTER TABLE checklist_template_apply_rules
ADD CONSTRAINT checklist_template_apply_rules_template_fkey
FOREIGN KEY (tenant, template_id)
REFERENCES checklist_templates(tenant, template_id)
ON DELETE CASCADE
`);
await addForeignKeyIfMissing(knex, 'ticket_checklist_items_ticket_fkey', `
ALTER TABLE ticket_checklist_items
ADD CONSTRAINT ticket_checklist_items_ticket_fkey
FOREIGN KEY (tenant, ticket_id)
REFERENCES tickets(tenant, ticket_id)
ON DELETE CASCADE
`);
await addForeignKeyIfMissing(knex, 'board_auto_close_rules_board_fkey', `
ALTER TABLE board_auto_close_rules
ADD CONSTRAINT board_auto_close_rules_board_fkey
FOREIGN KEY (tenant, board_id)
REFERENCES boards(tenant, board_id)
ON DELETE CASCADE
`);
await addForeignKeyIfMissing(knex, 'board_auto_close_rules_trigger_status_fkey', `
ALTER TABLE board_auto_close_rules
ADD CONSTRAINT board_auto_close_rules_trigger_status_fkey
FOREIGN KEY (tenant, trigger_status_id)
REFERENCES statuses(tenant, status_id)
`);
await addForeignKeyIfMissing(knex, 'board_auto_close_rules_close_status_fkey', `
ALTER TABLE board_auto_close_rules
ADD CONSTRAINT board_auto_close_rules_close_status_fkey
FOREIGN KEY (tenant, close_to_status_id)
REFERENCES statuses(tenant, status_id)
`);
await addForeignKeyIfMissing(knex, 'ticket_auto_close_state_ticket_fkey', `
ALTER TABLE ticket_auto_close_state
ADD CONSTRAINT ticket_auto_close_state_ticket_fkey
FOREIGN KEY (tenant, ticket_id)
REFERENCES tickets(tenant, ticket_id)
ON DELETE CASCADE
`);
await addForeignKeyIfMissing(knex, 'ticket_auto_close_state_rule_fkey', `
ALTER TABLE ticket_auto_close_state
ADD CONSTRAINT ticket_auto_close_state_rule_fkey
FOREIGN KEY (tenant, rule_id)
REFERENCES board_auto_close_rules(tenant, rule_id)
ON DELETE CASCADE
`);
};
exports.down = async function (knex) {
await knex.schema.dropTableIfExists('ticket_auto_close_state');
await knex.schema.dropTableIfExists('board_auto_close_rules');
await knex.schema.dropTableIfExists('ticket_checklist_items');
await knex.schema.dropTableIfExists('checklist_template_apply_rules');
await knex.schema.dropTableIfExists('checklist_template_items');
await knex.schema.dropTableIfExists('checklist_templates');
await knex.schema.dropTableIfExists('board_close_rules');
};
// Citus requires FK manipulation to run outside a transaction block.
exports.config = { transaction: false };