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
297 lines
14 KiB
JavaScript
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 };
|