PSA/server/migrations/20251211120000_add_tenant_notification_settings.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

535 lines
23 KiB
JavaScript

/**
* Create tenant-specific notification settings tables
* This migration moves notification category/subtype settings from global to per-tenant
*/
// Citus reference-table conversion cannot share a transaction with parallel
// distributed operations from earlier migrations in the same knex batch.
exports.config = { transaction: false };
exports.up = async function(knex) {
console.log('Creating tenant-specific notification settings tables...');
// Check if Citus is enabled - we need to know this to handle FK constraints properly
const citusFn = await knex.raw(`
SELECT EXISTS (
SELECT 1 FROM pg_proc
WHERE proname = 'create_distributed_table'
) AS exists;
`);
const isCitus = citusFn.rows?.[0]?.exists;
// The notification catalogs (categories/subtypes/system_email_templates)
// are reference tables on production Citus, and the tenant-scoped tables
// that FK into them (notification_logs, user_notification_preferences,
// tenant_email_templates) are distributed. A fresh Citus chain reaches this
// point with all six still local, holding FKs out to already-distributed
// users/tenants — so neither group can be converted while the cross-FKs tie
// them into one local graph. Drop the cross-FKs, convert each group to its
// production shape, then re-add them (distributed -> reference FKs are
// supported). No-op on prod, where everything is already converted.
if (isCitus) {
await knex.raw("SET citus.multi_shard_modify_mode TO 'sequential'");
const isReference = async (table) => {
const { rows } = await knex.raw(
"SELECT 1 FROM pg_dist_partition WHERE logicalrelid = ?::regclass AND partmethod = 'n' AND repmodel = 't'",
[table]
);
return rows.length > 0;
};
const isDistributed = async (table) => {
const { rows } = await knex.raw(
"SELECT 1 FROM pg_dist_partition WHERE logicalrelid = ?::regclass AND partmethod = 'h'",
[table]
);
return rows.length > 0;
};
const referenceTables = ['notification_categories', 'notification_subtypes', 'system_email_templates'];
const distributedTables = ['notification_logs', 'user_notification_preferences', 'tenant_email_templates'];
const pendingReference = [];
for (const t of referenceTables) {
if (!(await isReference(t))) pendingReference.push(t);
}
const pendingDistributed = [];
for (const t of distributedTables) {
if (!(await isDistributed(t))) pendingDistributed.push(t);
}
if (pendingReference.length > 0 || pendingDistributed.length > 0) {
const { rows: crossFks } = await knex.raw(`
SELECT conrelid::regclass::text AS tbl, conname, pg_get_constraintdef(oid) AS def
FROM pg_constraint
WHERE contype = 'f'
AND conrelid::regclass::text = ANY(ARRAY['notification_logs', 'user_notification_preferences', 'tenant_email_templates'])
AND confrelid::regclass::text = ANY(ARRAY['notification_categories', 'notification_subtypes', 'system_email_templates'])
`);
for (const fk of crossFks) {
await knex.raw(`ALTER TABLE ${fk.tbl} DROP CONSTRAINT "${fk.conname}"`);
}
// create_reference_table also accepts the citus-local tables that the
// first conversion auto-creates from the rest of the catalog FK graph.
for (const t of pendingReference) {
await knex.raw('SELECT create_reference_table(?)', [t]);
}
for (const t of pendingDistributed) {
await knex.raw("SELECT create_distributed_table(?, 'tenant')", [t]);
}
for (const fk of crossFks) {
await knex.raw(`ALTER TABLE ${fk.tbl} ADD CONSTRAINT "${fk.conname}" ${fk.def}`);
}
for (const t of [...pendingReference, ...pendingDistributed]) {
await knex.raw('SELECT truncate_local_data_after_distributing_table(?::regclass)', [t]);
}
}
}
// Helper function to check if table exists
const tableExists = async (tableName) => {
const result = await knex.raw(`
SELECT EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = ?
) AS exists;
`, [tableName]);
return result.rows[0].exists;
};
// Helper function to check if constraint exists
const constraintExists = async (tableName, constraintName) => {
const result = await knex.raw(`
SELECT EXISTS (
SELECT 1 FROM information_schema.table_constraints
WHERE table_schema = 'public'
AND table_name = ?
AND constraint_name = ?
) AS exists;
`, [tableName, constraintName]);
return result.rows[0].exists;
};
// In Citus, we need to:
// 1. Create tables WITHOUT foreign keys
// 2. Distribute the tables
// 3. Add foreign keys AFTER distribution
// This avoids "out of shared memory" errors when Citus tries to convert local tables
// Create tenant_notification_category_settings (without FKs for Citus)
if (!await tableExists('tenant_notification_category_settings')) {
await knex.schema.createTable('tenant_notification_category_settings', table => {
table.uuid('tenant').notNullable();
table.uuid('tenant_notification_category_setting_id').defaultTo(knex.raw('gen_random_uuid()')).notNullable();
table.integer('category_id').notNullable();
table.boolean('is_enabled').notNullable().defaultTo(true);
table.boolean('is_default_enabled').notNullable().defaultTo(true);
table.timestamps(true, true);
// Composite primary key with tenant first for Citus
table.primary(['tenant_notification_category_setting_id', 'tenant']);
// Foreign keys - only add in non-Citus environments
// In Citus, these will be added AFTER distribution
if (!isCitus) {
table.foreign('tenant').references('tenant').inTable('tenants').onDelete('CASCADE');
table.foreign('category_id').references('id').inTable('notification_categories').onDelete('CASCADE');
}
// Unique constraint on tenant + category_id
table.unique(['tenant', 'category_id']);
});
console.log(' ✓ Created tenant_notification_category_settings');
} else {
console.log(' - tenant_notification_category_settings already exists');
}
// Create tenant_notification_subtype_settings (without FKs for Citus)
if (!await tableExists('tenant_notification_subtype_settings')) {
await knex.schema.createTable('tenant_notification_subtype_settings', table => {
table.uuid('tenant').notNullable();
table.uuid('tenant_notification_subtype_setting_id').defaultTo(knex.raw('gen_random_uuid()')).notNullable();
table.integer('subtype_id').notNullable();
table.boolean('is_enabled').notNullable().defaultTo(true);
table.boolean('is_default_enabled').notNullable().defaultTo(true);
table.timestamps(true, true);
// Composite primary key with tenant first for Citus
table.primary(['tenant_notification_subtype_setting_id', 'tenant']);
// Foreign keys - only add in non-Citus environments
if (!isCitus) {
table.foreign('tenant').references('tenant').inTable('tenants').onDelete('CASCADE');
table.foreign('subtype_id').references('id').inTable('notification_subtypes').onDelete('CASCADE');
}
// Unique constraint on tenant + subtype_id
table.unique(['tenant', 'subtype_id']);
});
console.log(' ✓ Created tenant_notification_subtype_settings');
} else {
console.log(' - tenant_notification_subtype_settings already exists');
}
// Create tenant_internal_notification_category_settings (without FKs for Citus)
if (!await tableExists('tenant_internal_notification_category_settings')) {
await knex.schema.createTable('tenant_internal_notification_category_settings', table => {
table.uuid('tenant').notNullable();
table.uuid('tenant_internal_notification_category_setting_id').defaultTo(knex.raw('gen_random_uuid()')).notNullable();
table.integer('category_id').notNullable();
table.boolean('is_enabled').notNullable().defaultTo(true);
table.boolean('is_default_enabled').notNullable().defaultTo(true);
table.timestamps(true, true);
// Composite primary key with tenant first for Citus
table.primary(['tenant_internal_notification_category_setting_id', 'tenant']);
// Foreign keys - only add in non-Citus environments
if (!isCitus) {
table.foreign('tenant').references('tenant').inTable('tenants').onDelete('CASCADE');
table.foreign('category_id')
.references('internal_notification_category_id')
.inTable('internal_notification_categories')
.onDelete('CASCADE');
}
// Unique constraint on tenant + category_id
table.unique(['tenant', 'category_id']);
});
console.log(' ✓ Created tenant_internal_notification_category_settings');
} else {
console.log(' - tenant_internal_notification_category_settings already exists');
}
// Create tenant_internal_notification_subtype_settings (without FKs for Citus)
if (!await tableExists('tenant_internal_notification_subtype_settings')) {
await knex.schema.createTable('tenant_internal_notification_subtype_settings', table => {
table.uuid('tenant').notNullable();
table.uuid('tenant_internal_notification_subtype_setting_id').defaultTo(knex.raw('gen_random_uuid()')).notNullable();
table.integer('subtype_id').notNullable();
table.boolean('is_enabled').notNullable().defaultTo(true);
table.boolean('is_default_enabled').notNullable().defaultTo(true);
table.timestamps(true, true);
// Composite primary key with tenant first for Citus
table.primary(['tenant_internal_notification_subtype_setting_id', 'tenant']);
// Foreign keys - only add in non-Citus environments
if (!isCitus) {
table.foreign('tenant').references('tenant').inTable('tenants').onDelete('CASCADE');
table.foreign('subtype_id')
.references('internal_notification_subtype_id')
.inTable('internal_notification_subtypes')
.onDelete('CASCADE');
}
// Unique constraint on tenant + subtype_id
table.unique(['tenant', 'subtype_id']);
});
console.log(' ✓ Created tenant_internal_notification_subtype_settings');
} else {
console.log(' - tenant_internal_notification_subtype_settings already exists');
}
// Create indexes for lookups (IF NOT EXISTS)
await knex.raw(`
CREATE INDEX IF NOT EXISTS idx_tenant_notification_category_settings_lookup
ON tenant_notification_category_settings(tenant, category_id);
CREATE INDEX IF NOT EXISTS idx_tenant_notification_subtype_settings_lookup
ON tenant_notification_subtype_settings(tenant, subtype_id);
CREATE INDEX IF NOT EXISTS idx_tenant_internal_notification_category_settings_lookup
ON tenant_internal_notification_category_settings(tenant, category_id);
CREATE INDEX IF NOT EXISTS idx_tenant_internal_notification_subtype_settings_lookup
ON tenant_internal_notification_subtype_settings(tenant, subtype_id);
`);
console.log(' ✓ Created lookup indexes');
// Handle Citus distribution and FK constraints
if (isCitus) {
console.log(' Citus detected, handling distribution and foreign keys...');
// Note: We skip making internal_notification_* tables reference tables
// because they have complex FK relationships that prevent conversion.
// The FK constraints to these tables will also be skipped in Citus.
// Distribute the new tenant settings tables
const tables = [
'tenant_notification_category_settings',
'tenant_notification_subtype_settings',
'tenant_internal_notification_category_settings',
'tenant_internal_notification_subtype_settings'
];
for (const tableName of tables) {
// Check if table is already distributed
const isDistributed = await knex.raw(`
SELECT EXISTS (
SELECT 1 FROM pg_dist_partition
WHERE logicalrelid = '${tableName}'::regclass
) AS distributed;
`);
if (!isDistributed.rows[0].distributed) {
await knex.raw(`SELECT create_distributed_table('${tableName}', 'tenant')`);
console.log(` ✓ Distributed ${tableName}`);
} else {
console.log(` - ${tableName} already distributed`);
}
}
// Now add foreign key constraints AFTER distribution (idempotent)
console.log(' Adding foreign key constraints after distribution...');
// Define FK constraints to add
// Note: We skip FK constraints to internal_notification_* tables in Citus
// because those tables cannot be converted to reference tables
const fkConstraints = [
// FK to tenants table (distributed table)
{
table: 'tenant_notification_category_settings',
constraint: 'tenant_notification_category_settings_tenant_foreign',
sql: `ALTER TABLE tenant_notification_category_settings
ADD CONSTRAINT tenant_notification_category_settings_tenant_foreign
FOREIGN KEY (tenant) REFERENCES tenants(tenant) ON DELETE CASCADE`
},
{
table: 'tenant_notification_subtype_settings',
constraint: 'tenant_notification_subtype_settings_tenant_foreign',
sql: `ALTER TABLE tenant_notification_subtype_settings
ADD CONSTRAINT tenant_notification_subtype_settings_tenant_foreign
FOREIGN KEY (tenant) REFERENCES tenants(tenant) ON DELETE CASCADE`
},
{
table: 'tenant_internal_notification_category_settings',
constraint: 'tenant_internal_notification_category_settings_tenant_foreign',
sql: `ALTER TABLE tenant_internal_notification_category_settings
ADD CONSTRAINT tenant_internal_notification_category_settings_tenant_foreign
FOREIGN KEY (tenant) REFERENCES tenants(tenant) ON DELETE CASCADE`
},
{
table: 'tenant_internal_notification_subtype_settings',
constraint: 'tenant_internal_notification_subtype_settings_tenant_foreign',
sql: `ALTER TABLE tenant_internal_notification_subtype_settings
ADD CONSTRAINT tenant_internal_notification_subtype_settings_tenant_foreign
FOREIGN KEY (tenant) REFERENCES tenants(tenant) ON DELETE CASCADE`
},
// FK to reference tables (notification_categories, notification_subtypes)
// These are already reference tables in Citus
{
table: 'tenant_notification_category_settings',
constraint: 'tenant_notification_category_settings_category_id_foreign',
sql: `ALTER TABLE tenant_notification_category_settings
ADD CONSTRAINT tenant_notification_category_settings_category_id_foreign
FOREIGN KEY (category_id) REFERENCES notification_categories(id) ON DELETE CASCADE`
},
{
table: 'tenant_notification_subtype_settings',
constraint: 'tenant_notification_subtype_settings_subtype_id_foreign',
sql: `ALTER TABLE tenant_notification_subtype_settings
ADD CONSTRAINT tenant_notification_subtype_settings_subtype_id_foreign
FOREIGN KEY (subtype_id) REFERENCES notification_subtypes(id) ON DELETE CASCADE`
}
// Note: FK constraints to internal_notification_categories and internal_notification_subtypes
// are intentionally skipped in Citus - referential integrity is enforced at application level
];
for (const fk of fkConstraints) {
if (!await constraintExists(fk.table, fk.constraint)) {
await knex.raw(fk.sql);
console.log(` ✓ Added ${fk.constraint}`);
} else {
console.log(` - ${fk.constraint} already exists`);
}
}
} else {
console.log(' Citus not detected, skipping table distribution');
}
// Seed tenant settings from current global values
// Skip seeding if data already exists (idempotent)
console.log('Seeding tenant settings from global values...');
// Check if seeding is needed (any existing data means we skip)
const existingCategorySettings = await knex('tenant_notification_category_settings').first();
if (existingCategorySettings) {
console.log(' - Seeding skipped: data already exists');
} else {
// Get all tenants
const tenants = await knex('tenants').select('tenant');
console.log(` Found ${tenants.length} tenants`);
if (tenants.length > 0) {
// Seed notification_categories
const categories = await knex('notification_categories')
.select('id', 'is_enabled', 'is_default_enabled');
if (categories.length > 0) {
const categorySettings = [];
for (const tenant of tenants) {
for (const category of categories) {
categorySettings.push({
tenant: tenant.tenant,
tenant_notification_category_setting_id: knex.raw('gen_random_uuid()'),
category_id: category.id,
is_enabled: category.is_enabled,
is_default_enabled: category.is_default_enabled,
created_at: knex.fn.now(),
updated_at: knex.fn.now()
});
}
}
// Insert in batches of 100
for (let i = 0; i < categorySettings.length; i += 100) {
const batch = categorySettings.slice(i, i + 100);
await knex('tenant_notification_category_settings').insert(batch);
}
console.log(` ✓ Seeded ${categorySettings.length} category settings`);
}
// Seed notification_subtypes
const subtypes = await knex('notification_subtypes')
.select('id', 'is_enabled', 'is_default_enabled');
if (subtypes.length > 0) {
const subtypeSettings = [];
for (const tenant of tenants) {
for (const subtype of subtypes) {
subtypeSettings.push({
tenant: tenant.tenant,
tenant_notification_subtype_setting_id: knex.raw('gen_random_uuid()'),
subtype_id: subtype.id,
is_enabled: subtype.is_enabled,
is_default_enabled: subtype.is_default_enabled,
created_at: knex.fn.now(),
updated_at: knex.fn.now()
});
}
}
// Insert in batches of 100
for (let i = 0; i < subtypeSettings.length; i += 100) {
const batch = subtypeSettings.slice(i, i + 100);
await knex('tenant_notification_subtype_settings').insert(batch);
}
console.log(` ✓ Seeded ${subtypeSettings.length} subtype settings`);
}
// Seed internal_notification_categories
const internalCategories = await knex('internal_notification_categories')
.select('internal_notification_category_id', 'is_enabled', 'is_default_enabled');
if (internalCategories.length > 0) {
const internalCategorySettings = [];
for (const tenant of tenants) {
for (const category of internalCategories) {
internalCategorySettings.push({
tenant: tenant.tenant,
tenant_internal_notification_category_setting_id: knex.raw('gen_random_uuid()'),
category_id: category.internal_notification_category_id,
is_enabled: category.is_enabled,
is_default_enabled: category.is_default_enabled,
created_at: knex.fn.now(),
updated_at: knex.fn.now()
});
}
}
// Insert in batches of 100
for (let i = 0; i < internalCategorySettings.length; i += 100) {
const batch = internalCategorySettings.slice(i, i + 100);
await knex('tenant_internal_notification_category_settings').insert(batch);
}
console.log(` ✓ Seeded ${internalCategorySettings.length} internal category settings`);
}
// Seed internal_notification_subtypes
const internalSubtypes = await knex('internal_notification_subtypes')
.select('internal_notification_subtype_id', 'is_enabled', 'is_default_enabled');
if (internalSubtypes.length > 0) {
const internalSubtypeSettings = [];
for (const tenant of tenants) {
for (const subtype of internalSubtypes) {
internalSubtypeSettings.push({
tenant: tenant.tenant,
tenant_internal_notification_subtype_setting_id: knex.raw('gen_random_uuid()'),
subtype_id: subtype.internal_notification_subtype_id,
is_enabled: subtype.is_enabled,
is_default_enabled: subtype.is_default_enabled,
created_at: knex.fn.now(),
updated_at: knex.fn.now()
});
}
}
// Insert in batches of 100
for (let i = 0; i < internalSubtypeSettings.length; i += 100) {
const batch = internalSubtypeSettings.slice(i, i + 100);
await knex('tenant_internal_notification_subtype_settings').insert(batch);
}
console.log(` ✓ Seeded ${internalSubtypeSettings.length} internal subtype settings`);
}
}
}
// Reset global tables to all enabled (they become reference data)
console.log('Resetting global tables to enabled state...');
await knex('notification_categories').update({
is_enabled: true,
is_default_enabled: true,
updated_at: knex.fn.now()
});
await knex('notification_subtypes').update({
is_enabled: true,
is_default_enabled: true,
updated_at: knex.fn.now()
});
await knex('internal_notification_categories').update({
is_enabled: true,
is_default_enabled: true,
updated_at: knex.fn.now()
});
await knex('internal_notification_subtypes').update({
is_enabled: true,
is_default_enabled: true,
updated_at: knex.fn.now()
});
console.log(' ✓ Global tables reset to enabled');
console.log('Migration completed successfully!');
};
exports.down = async function(knex) {
console.log('Reverting tenant-specific notification settings tables...');
// Drop indexes first
await knex.raw(`
DROP INDEX IF EXISTS idx_tenant_internal_notification_subtype_settings_lookup;
DROP INDEX IF EXISTS idx_tenant_internal_notification_category_settings_lookup;
DROP INDEX IF EXISTS idx_tenant_notification_subtype_settings_lookup;
DROP INDEX IF EXISTS idx_tenant_notification_category_settings_lookup;
`);
console.log(' ✓ Dropped indexes');
// Drop tables in reverse dependency order
await knex.schema.dropTableIfExists('tenant_internal_notification_subtype_settings');
console.log(' ✓ Dropped tenant_internal_notification_subtype_settings');
await knex.schema.dropTableIfExists('tenant_internal_notification_category_settings');
console.log(' ✓ Dropped tenant_internal_notification_category_settings');
await knex.schema.dropTableIfExists('tenant_notification_subtype_settings');
console.log(' ✓ Dropped tenant_notification_subtype_settings');
await knex.schema.dropTableIfExists('tenant_notification_category_settings');
console.log(' ✓ Dropped tenant_notification_category_settings');
console.log('Rollback completed successfully!');
};