From ab06036bc4092ac45401fd8f4754c294567d6f66 Mon Sep 17 00:00:00 2001 From: Nevil Mathew Date: Tue, 16 Sep 2025 16:00:02 +0530 Subject: [PATCH 1/2] Refactor default tenant/domain migration with safety improvements --- ...1425-create-deafult-tenants-and-domains.js | 46 --- ...1446-create-deafult-tenants-and-domains.js | 180 +++++++++++ .../migrations/20250729064710-org-code-fix.js | 305 ++++++++---------- ...0250916094307-add-organization-features.js | 98 ++++++ ...30802144103-add-entity-and-entity-types.js | 2 + 5 files changed, 420 insertions(+), 211 deletions(-) delete mode 100644 src/database/migrations/20250502091425-create-deafult-tenants-and-domains.js create mode 100644 src/database/migrations/20250506091446-create-deafult-tenants-and-domains.js create mode 100644 src/database/migrations/20250916094307-add-organization-features.js diff --git a/src/database/migrations/20250502091425-create-deafult-tenants-and-domains.js b/src/database/migrations/20250502091425-create-deafult-tenants-and-domains.js deleted file mode 100644 index b3c683fc..00000000 --- a/src/database/migrations/20250502091425-create-deafult-tenants-and-domains.js +++ /dev/null @@ -1,46 +0,0 @@ -'use strict' - -module.exports = { - up: async (queryInterface, Sequelize) => { - const now = new Date() - - // Insert into tenants - await queryInterface.bulkInsert('tenants', [ - { - code: process.env.DEFAULT_TENANT_CODE, - name: 'Default Tenant', - status: 'ACTIVE', - description: 'This is the default tenant.', - logo: 'https://www.logo.dev', - theming: JSON.stringify({ - primaryColor: '#4F46E5', - secondaryColor: '#F97316', - }), - meta: null, - created_by: null, - updated_by: null, - created_at: now, - updated_at: now, - deleted_at: null, - }, - ]) - - // Insert into tenant_domains - await queryInterface.bulkInsert('tenant_domains', [ - { - id: 1, - tenant_code: process.env.DEFAULT_TENANT_CODE, - domain: 'localhost', - verified: true, - created_at: now, - updated_at: now, - deleted_at: null, - }, - ]) - }, - - down: async (queryInterface, Sequelize) => { - await queryInterface.bulkDelete('tenant_domains', { tenant_code: 'default' }) - await queryInterface.bulkDelete('tenants', { code: 'default' }) - }, -} diff --git a/src/database/migrations/20250506091446-create-deafult-tenants-and-domains.js b/src/database/migrations/20250506091446-create-deafult-tenants-and-domains.js new file mode 100644 index 00000000..c566802e --- /dev/null +++ b/src/database/migrations/20250506091446-create-deafult-tenants-and-domains.js @@ -0,0 +1,180 @@ +'use strict' + +module.exports = { + up: async (queryInterface, Sequelize) => { + const now = new Date() + const t = await queryInterface.sequelize.transaction() + + // safe env fallbacks + const DEFAULT_TENANT_CODE = process.env.DEFAULT_TENANT_CODE + const DEFAULT_ORG_CODE = process.env.DEFAULT_ORGANISATION_CODE + const DEFAULT_ORG_NAME = process.env.DEFAULT_ORGANISATION_NAME || 'Default Organization' + + try { + // Insert tenant (if not exists) + const existingTenant = await queryInterface.rawSelect( + 'tenants', + { where: { code: DEFAULT_TENANT_CODE }, transaction: t }, + 'id' + ) + + if (!existingTenant) { + await queryInterface.bulkInsert( + 'tenants', + [ + { + code: DEFAULT_TENANT_CODE, + name: 'Default Tenant', + status: 'ACTIVE', + description: 'This is the default tenant.', + logo: 'https://www.logo.dev', + theming: JSON.stringify({ + primaryColor: '#4F46E5', + secondaryColor: '#F97316', + }), + meta: null, + created_by: null, + updated_by: null, + created_at: now, + updated_at: now, + deleted_at: null, + }, + ], + { transaction: t } + ) + } + + // Insert tenant_domain (if not exists) + const existingDomain = await queryInterface.rawSelect( + 'tenant_domains', + { where: { tenant_code: DEFAULT_TENANT_CODE, domain: 'localhost' }, transaction: t }, + 'id' + ) + + if (!existingDomain) { + await queryInterface.bulkInsert( + 'tenant_domains', + [ + { + tenant_code: DEFAULT_TENANT_CODE, + domain: 'localhost', + verified: true, + created_at: now, + updated_at: now, + deleted_at: null, + }, + ], + { transaction: t } + ) + } + + // Insert organization if not exists + const existingOrgId = await queryInterface.rawSelect( + 'organizations', + { where: { code: DEFAULT_ORG_CODE }, transaction: t }, + 'id' + ) + + let insertedOrgCode = DEFAULT_ORG_CODE + + if (!existingOrgId) { + await queryInterface.bulkInsert( + 'organizations', + [ + { + name: DEFAULT_ORG_NAME, + code: DEFAULT_ORG_CODE, + description: 'Default Organisation', + status: 'ACTIVE', + tenant_code: DEFAULT_TENANT_CODE, + created_at: now, + updated_at: now, + }, + ], + { transaction: t } + ) + + // get the id just inserted (DB may have returned id via serial; rawSelect by code ensures we get it) + } + + // Fetch all features to seed organization_features + const [features] = await queryInterface.sequelize.query('SELECT code, label, icon FROM features;', { + transaction: t, + }) + + if (features && features.length > 0) { + // prepare rows but skip duplicates: check existing by org + feature + const orgFeatureRows = [] + + for (const feature of features) { + // check if already exists + const exists = await queryInterface.rawSelect( + 'organization_features', + { + where: { + organization_code: DEFAULT_ORG_CODE, + feature_code: feature.code, + tenant_code: DEFAULT_TENANT_CODE, + }, + transaction: t, + }, + 'id' + ) + + if (!exists) { + orgFeatureRows.push({ + organization_code: DEFAULT_ORG_CODE, + feature_code: feature.code, + enabled: true, + feature_name: feature.label, + icon: feature.icon || null, + tenant_code: DEFAULT_TENANT_CODE, + created_at: now, + updated_at: now, + }) + } + } + + if (orgFeatureRows.length > 0) { + await queryInterface.bulkInsert('organization_features', orgFeatureRows, { transaction: t }) + } + } + + await t.commit() + } catch (err) { + await t.rollback() + console.error('Migration failed:', err) + throw err + } + }, + + down: async (queryInterface, Sequelize) => { + const t = await queryInterface.sequelize.transaction() + const DEFAULT_TENANT_CODE = process.env.DEFAULT_TENANT_CODE + const DEFAULT_ORG_CODE = process.env.DEFAULT_ORGANISATION_CODE + + try { + // Delete seeded organization_features for this org & tenant + await queryInterface.bulkDelete( + 'organization_features', + { organization_code: DEFAULT_ORG_CODE, tenant_code: DEFAULT_TENANT_CODE }, + { transaction: t } + ) + + // Delete organization + await queryInterface.bulkDelete('organizations', { code: DEFAULT_ORG_CODE }, { transaction: t }) + + // Delete tenant_domain(s) + await queryInterface.bulkDelete('tenant_domains', { tenant_code: DEFAULT_TENANT_CODE }, { transaction: t }) + + // Delete tenant + await queryInterface.bulkDelete('tenants', { code: DEFAULT_TENANT_CODE }, { transaction: t }) + + await t.commit() + } catch (err) { + await t.rollback() + console.error('Rollback failed:', err) + throw err + } + }, +} diff --git a/src/database/migrations/20250729064710-org-code-fix.js b/src/database/migrations/20250729064710-org-code-fix.js index aa4889c2..0f001948 100644 --- a/src/database/migrations/20250729064710-org-code-fix.js +++ b/src/database/migrations/20250729064710-org-code-fix.js @@ -1,184 +1,159 @@ -const { Sequelize } = require('sequelize') +const { QueryTypes } = require('sequelize') module.exports = { - async up(queryInterface, Sequelize) { + async up(queryInterface) { let transaction - let fk_retainer = [] - let table, fk_name, fkey, refTable, refKey + const ORG_FETCH_QUERY = `SELECT id, name, code FROM organizations WHERE code ~ '\\s+' OR code ~ '[A-Z]';` + const disableFK = (table, fk_name) => `ALTER TABLE ${table} DROP CONSTRAINT IF EXISTS ${fk_name};` + const enableFK = (table, fk_name, fkey, refTable, refKey) => + `ALTER TABLE ${table} ADD CONSTRAINT ${fk_name} FOREIGN KEY ${fkey} REFERENCES ${refTable} ${refKey} ON UPDATE NO ACTION ON DELETE CASCADE;` + const updateQuery = (table, key) => + `UPDATE ${table} SET ${key} = LOWER(REGEXP_REPLACE(${key}, '\\s+', '_', 'g')) WHERE ${key} ~ '[A-Z\\s]';` try { - // Start a transaction + // Check if any rows need changing before opening a transaction + const fetchOrg = await queryInterface.sequelize.query(ORG_FETCH_QUERY, { + type: QueryTypes.SELECT, + raw: true, + }) + + if (!fetchOrg || fetchOrg.length === 0) { + return + } + + // Start transaction only when we need to make changes transaction = await queryInterface.sequelize.transaction() - const ORG_FETCH_QUERY = `SELECT id, name, code FROM organizations WHERE code ~ '\\s+' OR code ~ '[A-Z]';` - const disableFK = (table, fk_name) => `ALTER TABLE ${table} DROP CONSTRAINT IF EXISTS ${fk_name};` - const enableFK = (table, fk_name, fkey, refTable, refKey) => - `ALTER TABLE ${table} ADD CONSTRAINT ${fk_name} FOREIGN KEY ${fkey} REFERENCES ${refTable} ${refKey} ON UPDATE NO ACTION ON DELETE CASCADE;` - const updateQuery = (table, key) => - `UPDATE ${table} SET ${key} = LOWER(REGEXP_REPLACE(${key}, '\\s+', '_', 'g')) WHERE ${key} ~ '[A-Z|\\s+]';` + const fk_retainer = [] + + // organization_registration_codes + fk_retainer.push( + enableFK( + 'organization_registration_codes', + 'fk_organization_code_tenant_code_in_org_reg_code', + '(organization_code, tenant_code)', + 'organizations', + '(code, tenant_code)' + ) + ) + await queryInterface.sequelize.query( + disableFK('organization_registration_codes', 'fk_organization_code_tenant_code_in_org_reg_code'), + { transaction } + ) + + // user_organizations + fk_retainer.push( + enableFK( + 'user_organizations', + 'fk_user_organizations_organizations', + '(organization_code, tenant_code)', + 'organizations', + '(code, tenant_code)' + ) + ) + await queryInterface.sequelize.query( + disableFK('user_organizations', 'fk_user_organizations_organizations'), + { transaction } + ) + + // organization_user_invites - first FK + fk_retainer.push( + enableFK( + 'organization_user_invites', + 'fk_org_user_invites_organization_id', + '(organization_code, tenant_code)', + 'organizations', + '(code, tenant_code)' + ) + ) + await queryInterface.sequelize.query( + disableFK('organization_user_invites', 'fk_org_user_invites_organization_id'), + { transaction } + ) + + // organization_user_invites - second FK + fk_retainer.push( + enableFK( + 'organization_user_invites', + 'fk_org_user_invites_org_code', + '(organization_code, tenant_code)', + 'organizations', + '(code, tenant_code)' + ) + ) + await queryInterface.sequelize.query( + disableFK('organization_user_invites', 'fk_org_user_invites_org_code'), + { transaction } + ) + + // user_organization_roles + fk_retainer.push( + enableFK( + 'user_organization_roles', + 'fk_user_org_roles_user_organizations', + '(user_id, organization_code, tenant_code)', + 'user_organizations', + '(user_id, organization_code, tenant_code)' + ) + ) + await queryInterface.sequelize.query( + disableFK('user_organization_roles', 'fk_user_org_roles_user_organizations'), + { transaction } + ) + + // organization_features + fk_retainer.push( + enableFK( + 'organization_features', + 'fk_org_features_organization', + '(organization_code, tenant_code)', + 'organizations', + '(code, tenant_code)' + ) + ) + await queryInterface.sequelize.query(disableFK('organization_features', 'fk_org_features_organization'), { + transaction, + }) - // Execute the query to fetch organizations with whitespace - const fetchOrg = await queryInterface.sequelize.query(ORG_FETCH_QUERY, { - type: Sequelize.QueryTypes.SELECT, + // Run updates + await queryInterface.sequelize.query(updateQuery('organizations', 'code'), { transaction }) + await queryInterface.sequelize.query(updateQuery('organization_registration_codes', 'organization_code'), { + transaction, + }) + await queryInterface.sequelize.query(updateQuery('organization_user_invites', 'organization_code'), { + transaction, + }) + await queryInterface.sequelize.query(updateQuery('user_organizations', 'organization_code'), { + transaction, + }) + await queryInterface.sequelize.query(updateQuery('user_organization_roles', 'organization_code'), { + transaction, + }) + await queryInterface.sequelize.query(updateQuery('organization_features', 'organization_code'), { + transaction, + }) + + // Verify (optional) + const fetchOrgs = await queryInterface.sequelize.query(ORG_FETCH_QUERY, { + type: QueryTypes.SELECT, raw: true, transaction, }) - if (fetchOrg.length > 0) { - // Disable foreign key constraints and store enable queries - table = 'organization_registration_codes' - fk_name = 'fk_organization_code_tenant_code_in_org_reg_code' - fkey = '(organization_code, tenant_code)' - refTable = 'organizations' - refKey = '(code, tenant_code)' - fk_retainer.push(enableFK(table, fk_name, fkey, refTable, refKey)) - await queryInterface.sequelize.query(disableFK(table, fk_name), { - type: Sequelize.QueryTypes.RAW, - raw: true, - transaction, - }) - - table = 'user_organizations' - fk_name = 'fk_user_organizations_organizations' - fkey = '(organization_code, tenant_code)' - refTable = 'organizations' - refKey = '(code, tenant_code)' - await queryInterface.sequelize.query(disableFK(table, fk_name), { - type: Sequelize.QueryTypes.RAW, - raw: true, - transaction, - }) - fk_retainer.push(enableFK(table, fk_name, fkey, refTable, refKey)) - - table = 'organization_user_invites' - fk_name = 'fk_org_user_invites_organization_id' - fkey = '(organization_code, tenant_code)' - refTable = 'organizations' - refKey = '(code, tenant_code)' - await queryInterface.sequelize.query(disableFK(table, fk_name), { - type: Sequelize.QueryTypes.RAW, - raw: true, - transaction, - }) - table = 'organization_user_invites' - fk_name = 'fk_org_user_invites_org_code' - fkey = '(organization_code, tenant_code)' - refTable = 'organizations' - refKey = '(code, tenant_code)' - await queryInterface.sequelize.query(disableFK(table, fk_name), { - type: Sequelize.QueryTypes.RAW, - raw: true, - transaction, - }) - fk_retainer.push(enableFK(table, fk_name, fkey, refTable, refKey)) - - table = 'user_organization_roles' - fk_name = 'fk_user_org_roles_user_organizations' - fkey = '(user_id, organization_code, tenant_code)' - refTable = 'user_organizations' - refKey = '(user_id, organization_code, tenant_code)' - await queryInterface.sequelize.query(disableFK(table, fk_name), { - type: Sequelize.QueryTypes.RAW, - raw: true, - transaction, - }) - fk_retainer.push(enableFK(table, fk_name, fkey, refTable, refKey)) - - table = 'organization_features' - fk_name = 'fk_org_features_organization' - fkey = '(organization_code, tenant_code)' - refTable = 'organizations' - refKey = '(code, tenant_code)' - await queryInterface.sequelize.query(disableFK(table, fk_name), { - type: Sequelize.QueryTypes.RAW, - raw: true, - transaction, - }) - fk_retainer.push(enableFK(table, fk_name, fkey, refTable, refKey)) - - // Update tables to remove whitespace - let updateTable = 'organizations' - let key = 'code' - const updateOrgs = await queryInterface.sequelize.query(updateQuery(updateTable, key), { - type: Sequelize.QueryTypes.UPDATE, - raw: true, - transaction, - }) - - updateTable = 'organization_registration_codes' - key = 'organization_code' - await queryInterface.sequelize.query(updateQuery(updateTable, key), { - type: Sequelize.QueryTypes.UPDATE, - raw: true, - transaction, - }) - - updateTable = 'organization_user_invites' - key = 'organization_code' - await queryInterface.sequelize.query(updateQuery(updateTable, key), { - type: Sequelize.QueryTypes.UPDATE, - raw: true, - transaction, - }) - - updateTable = 'user_organizations' - key = 'organization_code' - await queryInterface.sequelize.query(updateQuery(updateTable, key), { - type: Sequelize.QueryTypes.UPDATE, - raw: true, - transaction, - }) - - updateTable = 'user_organization_roles' - key = 'organization_code' - await queryInterface.sequelize.query(updateQuery(updateTable, key), { - type: Sequelize.QueryTypes.UPDATE, - raw: true, - transaction, - }) - updateTable = 'organization_features' - key = 'organization_code' - await queryInterface.sequelize.query(updateQuery(updateTable, key), { - type: Sequelize.QueryTypes.UPDATE, - raw: true, - transaction, - }) - - // Verify the update - const fetchOrgs = await queryInterface.sequelize.query(ORG_FETCH_QUERY, { - type: Sequelize.QueryTypes.SELECT, - raw: true, - transaction, - }) - - // Re-enable foreign key constraints - let fk_retainerPromise = [] - for (let i = 0; i < fk_retainer.length; i++) { - fk_retainerPromise.push( - queryInterface.sequelize.query(fk_retainer[i], { - type: Sequelize.QueryTypes.RAW, - raw: true, - transaction, - }) - ) - } - - await Promise.all(fk_retainerPromise) - - // Commit the transaction - await transaction.commit() + // Re-create foreign keys + for (const q of fk_retainer) { + await queryInterface.sequelize.query(q, { transaction }) } - } catch (error) { - // Rollback transaction on error + + await transaction.commit() + } catch (err) { if (transaction) await transaction.rollback() - console.error(`Error during transaction: ${error}`) - throw error + console.error('Error during migration:', err) + throw err } }, - async down(queryInterface, Sequelize) { - console.warn( - 'Down migration not implemented: Cannot reliably restore original whitespace in organization codes.' - ) + async down() { + console.warn('Down migration not implemented.') }, } diff --git a/src/database/migrations/20250916094307-add-organization-features.js b/src/database/migrations/20250916094307-add-organization-features.js new file mode 100644 index 00000000..e2d5805a --- /dev/null +++ b/src/database/migrations/20250916094307-add-organization-features.js @@ -0,0 +1,98 @@ +'use strict' + +module.exports = { + up: async (queryInterface, Sequelize) => { + const now = new Date() + const t = await queryInterface.sequelize.transaction() + + const DEFAULT_TENANT_CODE = process.env.DEFAULT_TENANT_CODE + const DEFAULT_ORG_CODE = process.env.DEFAULT_ORGANISATION_CODE + + try { + // ensure organization exists + const orgExists = await queryInterface.rawSelect( + 'organizations', + { where: { code: DEFAULT_ORG_CODE }, transaction: t }, + 'id' + ) + + if (!orgExists) { + // nothing to do if org missing + await t.commit() + return + } + + // fetch features + const [features] = await queryInterface.sequelize.query( + 'SELECT code, label, icon, display_order FROM features;', + { transaction: t } + ) + + if (!features || features.length === 0) { + await t.commit() + return + } + + const rowsToInsert = [] + + for (const f of features) { + // skip if already present + const exists = await queryInterface.rawSelect( + 'organization_features', + { + where: { + organization_code: DEFAULT_ORG_CODE, + feature_code: f.code, + tenant_code: DEFAULT_TENANT_CODE, + }, + transaction: t, + }, + 'id' + ) + + if (!exists) { + rowsToInsert.push({ + organization_code: DEFAULT_ORG_CODE, + feature_code: f.code, + enabled: true, + feature_name: f.label, + icon: f.icon || null, + tenant_code: DEFAULT_TENANT_CODE, + created_at: now, + updated_at: now, + display_order: f.display_order || null, + }) + } + } + + if (rowsToInsert.length > 0) { + await queryInterface.bulkInsert('organization_features', rowsToInsert, { transaction: t }) + } + + await t.commit() + } catch (err) { + await t.rollback() + console.error('organization_features migration failed:', err) + throw err + } + }, + + down: async (queryInterface, Sequelize) => { + const t = await queryInterface.sequelize.transaction() + const DEFAULT_TENANT_CODE = process.env.DEFAULT_TENANT_CODE || 'default' + const DEFAULT_ORG_CODE = process.env.DEFAULT_ORGANISATION_CODE || 'default' + + try { + await queryInterface.bulkDelete( + 'organization_features', + { organization_code: DEFAULT_ORG_CODE, tenant_code: DEFAULT_TENANT_CODE }, + { transaction: t } + ) + await t.commit() + } catch (err) { + await t.rollback() + console.error('organization_features rollback failed:', err) + throw err + } + }, +} diff --git a/src/database/seeders/20230802144103-add-entity-and-entity-types.js b/src/database/seeders/20230802144103-add-entity-and-entity-types.js index 9060fc5a..e0bb60b7 100644 --- a/src/database/seeders/20230802144103-add-entity-and-entity-types.js +++ b/src/database/seeders/20230802144103-add-entity-and-entity-types.js @@ -143,6 +143,7 @@ module.exports = { updated_by: 0, allow_filtering: true, organization_id: defaultOrgId, + organization_code: process.env.DEFAULT_ORGANISATION_CODE, tenant_code: process.env.DEFAULT_TENANT_CODE, regex: null, has_entities: true, @@ -174,6 +175,7 @@ module.exports = { eachEntity.type = 'SYSTEM' eachEntity.status = 'ACTIVE' eachEntity.tenant_code = process.env.DEFAULT_TENANT_CODE + eachEntity.organization_code = process.env.DEFAULT_ORGANISATION_CODE eachEntity.created_at = new Date() eachEntity.updated_at = new Date() eachEntity.created_by = 0 From d07d532ec9486984010ea70b3e558c8fa105eb00 Mon Sep 17 00:00:00 2001 From: Nevil Mathew Date: Tue, 16 Sep 2025 16:28:10 +0530 Subject: [PATCH 2/2] add: tenant clause --- .../20250506091446-create-deafult-tenants-and-domains.js | 8 ++++++-- .../20250916094307-add-organization-features.js | 5 ++--- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/database/migrations/20250506091446-create-deafult-tenants-and-domains.js b/src/database/migrations/20250506091446-create-deafult-tenants-and-domains.js index c566802e..8d22aec3 100644 --- a/src/database/migrations/20250506091446-create-deafult-tenants-and-domains.js +++ b/src/database/migrations/20250506091446-create-deafult-tenants-and-domains.js @@ -71,7 +71,7 @@ module.exports = { // Insert organization if not exists const existingOrgId = await queryInterface.rawSelect( 'organizations', - { where: { code: DEFAULT_ORG_CODE }, transaction: t }, + { where: { code: DEFAULT_ORG_CODE, tenant_code: DEFAULT_TENANT_CODE }, transaction: t }, 'id' ) @@ -162,7 +162,11 @@ module.exports = { ) // Delete organization - await queryInterface.bulkDelete('organizations', { code: DEFAULT_ORG_CODE }, { transaction: t }) + await queryInterface.bulkDelete( + 'organizations', + { code: DEFAULT_ORG_CODE, tenant_code: DEFAULT_TENANT_CODE }, + { transaction: t } + ) // Delete tenant_domain(s) await queryInterface.bulkDelete('tenant_domains', { tenant_code: DEFAULT_TENANT_CODE }, { transaction: t }) diff --git a/src/database/migrations/20250916094307-add-organization-features.js b/src/database/migrations/20250916094307-add-organization-features.js index e2d5805a..e3670be7 100644 --- a/src/database/migrations/20250916094307-add-organization-features.js +++ b/src/database/migrations/20250916094307-add-organization-features.js @@ -79,9 +79,8 @@ module.exports = { down: async (queryInterface, Sequelize) => { const t = await queryInterface.sequelize.transaction() - const DEFAULT_TENANT_CODE = process.env.DEFAULT_TENANT_CODE || 'default' - const DEFAULT_ORG_CODE = process.env.DEFAULT_ORGANISATION_CODE || 'default' - + const DEFAULT_TENANT_CODE = process.env.DEFAULT_TENANT_CODE + const DEFAULT_ORG_CODE = process.env.DEFAULT_ORGANISATION_CODE try { await queryInterface.bulkDelete( 'organization_features',