diff --git a/src/database/migrations/20250729064710-org-code-fix.js b/src/database/migrations/20250729064710-org-code-fix.js new file mode 100644 index 000000000..8b155916e --- /dev/null +++ b/src/database/migrations/20250729064710-org-code-fix.js @@ -0,0 +1,173 @@ +const { Sequelize } = require('sequelize') + +module.exports = { + async up(queryInterface, Sequelize) { + let transaction + let fk_retainer = [] + let table, fk_name, fkey, refTable, refKey + + try { + // Start a transaction + 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+]';` + + // Execute the query to fetch organizations with whitespace + const fetchOrg = await queryInterface.sequelize.query(ORG_FETCH_QUERY, { + type: Sequelize.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 = '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() + } + } catch (error) { + // Rollback transaction on error + if (transaction) await transaction.rollback() + console.error(`Error during transaction: ${error}`) + throw error + } + }, + + async down(queryInterface, Sequelize) { + console.warn( + 'Down migration not implemented: Cannot reliably restore original whitespace in organization codes.' + ) + }, +} diff --git a/src/routes/index.js b/src/routes/index.js index ab0e329f5..552a20b55 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -70,7 +70,7 @@ module.exports = (app) => { const version = (req.params.version.match(/^v\d+$/) || [])[0] // Match version like v1, v2, etc. const controllerName = (req.params.controller.match(/^[a-zA-Z0-9_-]+$/) || [])[0] // Allow only alphanumeric characters, underscore, and hyphen const file = req.params.file ? (req.params.file.match(/^[a-zA-Z0-9_-]+$/) || [])[0] : null // Same validation as controller, or null if file is not provided - const method = (req.params.method.match(/^[a-zA-Z0-9]+$/) || [])[0] // Allow only alphanumeric characters + const method = (req.params.method.match(/^[a-zA-Z0-9_-]+$/) || [])[0] // Allow only alphanumeric characters try { if (!version || !controllerName || !method || (req.params.file && !file)) { // Invalid input, return an error response diff --git a/src/validators/v1/organization.js b/src/validators/v1/organization.js index fe4beece9..f81a6d428 100644 --- a/src/validators/v1/organization.js +++ b/src/validators/v1/organization.js @@ -12,7 +12,12 @@ const common = require('@constants/common') module.exports = { create: (req) => { req.body = filterRequestBody(req.body, organization.create) - req.checkBody('code').trim().notEmpty().withMessage('code field is empty') + req.checkBody('code') + .trim() + .notEmpty() + .withMessage('code field is empty') + .matches(/^[a-z0-9_]+$/) + .withMessage('code is invalid. Only lowercase alphanumeric characters allowed') req.checkBody('tenant_code').trim().notEmpty().withMessage('tenant_code field is empty') req.checkBody('registration_codes') .optional({ checkFalsy: true })