PSA/server/migrations/20260313120000_create_comment_reactions.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

221 lines
8.3 KiB
JavaScript

/**
* Migration: Create comment_reactions and project_task_comment_reactions tables
*
* Adds emoji reaction support for both ticket comments and project task comments.
* Each table stores individual user reactions with toggle behavior enforced by
* a unique constraint (one reaction per user per emoji per comment).
* CitusDB-compatible primary keys with tenant co-location.
*/
const PROJECT_TASK_COMMENTS_FKS = [
{
name: 'project_task_comments_tenant_foreign',
sql: `
ALTER TABLE project_task_comments
ADD CONSTRAINT project_task_comments_tenant_foreign
FOREIGN KEY (tenant) REFERENCES tenants(tenant) NOT VALID
`,
},
{
name: 'project_task_comments_tenant_task_id_foreign',
sql: `
ALTER TABLE project_task_comments
ADD CONSTRAINT project_task_comments_tenant_task_id_foreign
FOREIGN KEY (tenant, task_id)
REFERENCES project_tasks(tenant, task_id)
ON DELETE CASCADE
NOT VALID
`,
},
{
name: 'project_task_comments_tenant_user_id_foreign',
sql: `
ALTER TABLE project_task_comments
ADD CONSTRAINT project_task_comments_tenant_user_id_foreign
FOREIGN KEY (tenant, user_id)
REFERENCES users(tenant, user_id)
NOT VALID
`,
},
];
async function isTableDistributed(knex, tableName) {
const result = await knex.raw(`
SELECT EXISTS (
SELECT 1 FROM pg_dist_partition
WHERE logicalrelid = ?::regclass
) AS is_distributed;
`, [tableName]);
return Boolean(result.rows?.[0]?.is_distributed);
}
async function hasConstraint(knex, tableName, constraintName) {
const result = await knex.raw(`
SELECT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conrelid = ?::regclass
AND conname = ?
) AS exists;
`, [tableName, constraintName]);
return Boolean(result.rows?.[0]?.exists);
}
async function dropProjectTaskCommentsForeignKeys(knex) {
for (const { name } of PROJECT_TASK_COMMENTS_FKS) {
await knex.raw(`
ALTER TABLE project_task_comments
DROP CONSTRAINT IF EXISTS ${name}
`);
}
}
async function ensureProjectTaskCommentsForeignKeys(knex) {
for (const fk of PROJECT_TASK_COMMENTS_FKS) {
if (!(await hasConstraint(knex, 'project_task_comments', fk.name))) {
await knex.raw(fk.sql);
}
}
}
exports.up = async function(knex) {
// ── Ticket comment reactions ──
if (!(await knex.schema.hasTable('comment_reactions'))) {
await knex.schema.createTable('comment_reactions', (table) => {
table.uuid('tenant').notNullable();
table.uuid('reaction_id').defaultTo(knex.raw('gen_random_uuid()')).notNullable();
table.uuid('comment_id').notNullable();
table.uuid('user_id').notNullable();
table.text('emoji').notNullable();
table.timestamp('created_at').defaultTo(knex.fn.now()).notNullable();
table.primary(['reaction_id', 'tenant']);
table.foreign('tenant').references('tenants.tenant');
table.foreign(['tenant', 'comment_id'])
.references(['tenant', 'comment_id'])
.inTable('comments');
table.foreign(['tenant', 'user_id'])
.references(['tenant', 'user_id'])
.inTable('users');
table.unique(['tenant', 'comment_id', 'user_id', 'emoji'], {
indexName: 'uq_comment_reactions_user_emoji'
});
table.index(['tenant', 'comment_id'], 'idx_comment_reactions_comment');
table.index(['tenant', 'comment_id', 'created_at'], 'idx_comment_reactions_comment_created');
});
}
// ── Project task comment reactions ──
if (!(await knex.schema.hasTable('project_task_comment_reactions'))) {
await knex.schema.createTable('project_task_comment_reactions', (table) => {
table.uuid('tenant').notNullable();
table.uuid('reaction_id').defaultTo(knex.raw('gen_random_uuid()')).notNullable();
table.uuid('task_comment_id').notNullable();
table.uuid('user_id').notNullable();
table.text('emoji').notNullable();
table.timestamp('created_at').defaultTo(knex.fn.now()).notNullable();
table.primary(['reaction_id', 'tenant']);
table.foreign('tenant').references('tenants.tenant');
table.foreign(['tenant', 'task_comment_id'])
.references(['tenant', 'task_comment_id'])
.inTable('project_task_comments');
table.foreign(['tenant', 'user_id'])
.references(['tenant', 'user_id'])
.inTable('users');
table.unique(['tenant', 'task_comment_id', 'user_id', 'emoji'], {
indexName: 'uq_task_comment_reactions_user_emoji'
});
table.index(['tenant', 'task_comment_id'], 'idx_task_comment_reactions_comment');
table.index(['tenant', 'task_comment_id', 'created_at'], 'idx_task_comment_reactions_comment_created');
});
}
// ── Citus distribution ──
const citusFn = await knex.raw(`
SELECT EXISTS (
SELECT 1 FROM pg_proc
WHERE proname = 'create_distributed_table'
) AS exists;
`);
if (citusFn.rows?.[0]?.exists) {
// The FK re-adds below need project_tasks distributed, which on fresh
// Citus chains pulls in the whole projects subsystem in dependency
// order. project_status_mappings carries two Citus-incompatible
// constraints: a lone UNIQUE(project_status_mapping_id) backing a
// single-column FK from project_tasks (recreated as a composite FK
// against the PK), and an ON DELETE SET NULL FK to standard_statuses,
// which must stay local (PK has no tenant) — dropped on Citus. No-op on
// clusters where the subsystem is already distributed.
for (const table of ['projects', 'project_phases']) {
if (!(await isTableDistributed(knex, table))) {
await knex.raw(`SELECT create_distributed_table('${table}', 'tenant', colocate_with => 'tenants')`);
}
}
if (!(await isTableDistributed(knex, 'project_status_mappings'))) {
for (const [table, constraint] of [
['project_tasks', 'project_tasks_project_status_mapping_id_foreign'],
['project_status_mappings', 'project_status_mappings_project_status_mapping_id_unique'],
['project_status_mappings', 'project_status_mappings_standard_status_id_foreign'],
]) {
if (await hasConstraint(knex, table, constraint)) {
await knex.raw(`ALTER TABLE ${table} DROP CONSTRAINT ${constraint}`);
}
}
await knex.raw(`SELECT create_distributed_table('project_status_mappings', 'tenant', colocate_with => 'tenants')`);
}
if (!(await isTableDistributed(knex, 'project_tasks'))) {
await knex.raw(`SELECT create_distributed_table('project_tasks', 'tenant', colocate_with => 'tenants')`);
}
if (!(await hasConstraint(knex, 'project_tasks', 'project_tasks_project_status_mapping_id_foreign'))) {
await knex.raw(`
ALTER TABLE project_tasks
ADD CONSTRAINT project_tasks_project_status_mapping_id_foreign
FOREIGN KEY (tenant, project_status_mapping_id)
REFERENCES project_status_mappings(tenant, project_status_mapping_id)
`);
}
const projectTaskCommentsExists = await knex.schema.hasTable('project_task_comments');
if (projectTaskCommentsExists && !(await isTableDistributed(knex, 'project_task_comments'))) {
// Existing hosts may contain legacy orphan rows. Drop these FKs before
// Citus copies local rows into shards, then re-add them as NOT VALID
// after distribution so future writes are still enforced.
await dropProjectTaskCommentsForeignKeys(knex);
}
// comments / project_task_comments must be distributed before the
// reaction tables that FK them (Citus requires referenced tables to be
// distributed). comments is a no-op on clusters that already have it.
for (const table of ['comments', 'project_task_comments', 'comment_reactions', 'project_task_comment_reactions']) {
if (!(await isTableDistributed(knex, table))) {
await knex.raw(`SELECT create_distributed_table('${table}', 'tenant', colocate_with => 'tenants')`);
}
}
if (await isTableDistributed(knex, 'project_task_comments')) {
await ensureProjectTaskCommentsForeignKeys(knex);
}
} else {
console.warn('[create_comment_reactions] Skipping create_distributed_table (function unavailable)');
}
};
exports.config = { transaction: false };
exports.down = async function(knex) {
await knex.schema.dropTableIfExists('project_task_comment_reactions');
await knex.schema.dropTableIfExists('comment_reactions');
};