diff --git a/src/database/migrations/20250925122507-update-unique-user-roles.js b/src/database/migrations/20250925122507-update-unique-user-roles.js new file mode 100644 index 00000000..84b78264 --- /dev/null +++ b/src/database/migrations/20250925122507-update-unique-user-roles.js @@ -0,0 +1,32 @@ +// 20250925120000-update-unique-user-roles.js +module.exports = { + up: async (queryInterface, Sequelize) => { + const table = 'user_roles' + + // drop old constraint if it exists + await queryInterface.sequelize.query( + `ALTER TABLE "${table}" DROP CONSTRAINT IF EXISTS unique_title_org_id_tenant_code;` + ) + + // create partial unique index ignoring soft-deleted rows + await queryInterface.sequelize.query( + `CREATE UNIQUE INDEX IF NOT EXISTS unique_title_org_id_tenant_code + ON "${table}" (title, organization_id, tenant_code) + WHERE deleted_at IS NULL;` + ) + }, + + down: async (queryInterface, Sequelize) => { + const table = 'user_roles' + + // drop the partial index + await queryInterface.sequelize.query(`DROP INDEX IF EXISTS unique_title_org_id_tenant_code;`) + + // restore original constraint (no WHERE clause) + await queryInterface.addConstraint(table, { + fields: ['title', 'organization_id', 'tenant_code'], + type: 'unique', + name: 'unique_title_org_id_tenant_code', + }) + }, +} diff --git a/src/generics/utils.js b/src/generics/utils.js index 7eb3b01b..022e5983 100644 --- a/src/generics/utils.js +++ b/src/generics/utils.js @@ -802,7 +802,7 @@ async function fetchAndMapAllExternalEntities(entities, service, endPoint, tenan }, data: { query: { - 'metaInformation.name': { + 'metaInformation.externalId': { $in: entities, // Dynamically pass the array here }, tenantId: tenantCode, @@ -818,7 +818,11 @@ async function fetchAndMapAllExternalEntities(entities, service, endPoint, tenan }) responseBody = data.reduce((acc, { _id, entityType, metaInformation }) => { - const key = metaInformation?.name?.replaceAll(/\s+/g, '').toLowerCase() + const normalize = (s) => (s ?? '').toString().replace(/\s+/g, '').toLowerCase() + const namePart = normalize(metaInformation?.externalId) + const typePart = normalize(entityType) + if (!namePart || !typePart) return acc + const key = `${namePart}${typePart}` if (key) { acc[key] = { _id, name: metaInformation?.name, entityType, externalId: metaInformation.externalId } } diff --git a/src/helpers/userInvite.js b/src/helpers/userInvite.js index b229b2aa..216d7661 100644 --- a/src/helpers/userInvite.js +++ b/src/helpers/userInvite.js @@ -273,31 +273,59 @@ module.exports = class UserInviteHelper { // Extract and prepare meta fields row.meta = { block: row?.block - ? externalEntityNameIdMap?.[row.block?.replaceAll(/\s+/g, '').toLowerCase()]?._id || null + ? externalEntityNameIdMap?.[ + `${row.block?.replaceAll(/\s+/g, '').toLowerCase()}${'block' + .replaceAll(/\s+/g, '') + .toLowerCase()}` + ]?._id || null : '', state: row?.state - ? externalEntityNameIdMap?.[row.state?.replaceAll(/\s+/g, '').toLowerCase()]?._id || null + ? externalEntityNameIdMap?.[ + `${row.state?.replaceAll(/\s+/g, '').toLowerCase()}${'state' + .replaceAll(/\s+/g, '') + .toLowerCase()}` + ]?._id || null : '', school: row?.school - ? externalEntityNameIdMap?.[row.school?.replaceAll(/\s+/g, '').toLowerCase()]?._id || null + ? externalEntityNameIdMap?.[ + `${row.school?.replaceAll(/\s+/g, '').toLowerCase()}${'school' + .replaceAll(/\s+/g, '') + .toLowerCase()}` + ]?._id || null : '', cluster: row?.cluster - ? externalEntityNameIdMap?.[row.cluster?.replaceAll(/\s+/g, '').toLowerCase()]?._id || null + ? externalEntityNameIdMap?.[ + `${row.cluster?.replaceAll(/\s+/g, '').toLowerCase()}${'cluster' + .replaceAll(/\s+/g, '') + .toLowerCase()}` + ]?._id || null : '', district: row?.district - ? externalEntityNameIdMap?.[row.district?.replaceAll(/\s+/g, '').toLowerCase()]?._id || null + ? externalEntityNameIdMap?.[ + `${row.district?.replaceAll(/\s+/g, '').toLowerCase()}${'district' + .replaceAll(/\s+/g, '') + .toLowerCase()}` + ]?._id || null : '', professional_role: row?.professional_role - ? externalEntityNameIdMap?.[row.professional_role?.replaceAll(/\s+/g, '').toLowerCase()] - ?._id || '' + ? externalEntityNameIdMap?.[ + `${row.professional_role?.replaceAll(/\s+/g, '').toLowerCase()}${'professional_role' + .replaceAll(/\s+/g, '') + .toLowerCase()}` + ]?._id || '' : '', professional_subroles: row?.professional_subroles ? row.professional_subroles .split(',') .map( (prof_subRole) => - externalEntityNameIdMap[prof_subRole?.replaceAll(/\s+/g, '').toLowerCase()] - ?._id + externalEntityNameIdMap[ + `${prof_subRole + ?.replaceAll(/\s+/g, '') + .toLowerCase()}${'professional_subroles' + .replaceAll(/\s+/g, '') + .toLowerCase()}` + ]?._id ) || [] : [], } diff --git a/src/locales/en.json b/src/locales/en.json index 21726f8a..64d5f726 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -85,6 +85,9 @@ "ORG_ROLE_REQ_UPDATED": "Organisation request updated", "FILE_UPLOAD_MODIFY_ERROR": "File update failed", "STATUS_UPDATE_FAILED": "Failed to deactivate user", + "ORG_DEACTIVATION_FAILED": "Failed to deactivate organisation.", + "ORG_NOT_FOUND": "Organisation not found.", + "ORG_ALREADY_INACTIVE": "Organisation is already inactive.", "USER_DEACTIVATED": "User deactivated Successfully", "USER_CSV_UPLOADED_FAILED": "Failed to Uploaded User Invites CSV", "ORG_DEACTIVATED": "Organization deactivated Successfully", @@ -178,5 +181,6 @@ "ORG_UNIQUE_CONSTRAIN_ERROR": "Organization Creation / Updation Failed. code / registration_code not unique.", "REG_CODE_ERROR": "registration_code is not valid or unique.", "INVALID_REG_CODE_ERROR": "registration_codes {{errorMessage}}. Invalid Code(s) : {{errorValues}}", - "UNIQUE_CONSTRAINT_ERROR": "{{fields}} is Invalid." + "UNIQUE_CONSTRAINT_ERROR": "{{fields}} is Invalid.", + "USER_PROFILE_FETCHED_SUCCESSFULLY": "User profile fetched successfully!" } diff --git a/src/sample.csv b/src/sample.csv index 609628f0..57d13301 100644 --- a/src/sample.csv +++ b/src/sample.csv @@ -1,4 +1,4 @@ "name","email","phone_code","phone","username","password","roles","state","district","block","cluster","school","professional_role","professional_subroles" -"SarahB","sarahB@tunerlabs.com","+91","7012345499","sarahB_tunerlabs","Password@123","mentee","Karnataka","BENGALURU RURAL","NELAMANGALA","BASAVANAHALLI","SHARADA HPS","Teacher","Teacher (Class 1-5)" -"JohnB","johnB@tunerlabs.com","+91","7012345599","johnB_honai","Password@123","mentor","Karnataka","BENGALURU RURAL","NELAMANGALA","BASAVANAHALLI","SHARADA HPS","Teacher","Teacher (Class 1-5)" -"PradeepB","pradeepB@tunerlabs.com","+91","7012345699","pradeepB_tl","Password@123","mentor,session_manager","Karnataka","BENGALURU RURAL","NELAMANGALA","BASAVANAHALLI","SHARADA HPS","Teacher","Teacher (Class 1-5)" +"SarahB","sarahB@tunerlabs.com","+91","7012345499","sarahB_tunerlabs","Password@123","mentee",16,1603,160301,1603010007,16030100406,student,"student-preschool-class-2,student-class-1-5" +"JohnB","johnB@tunerlabs.com","+91","7012345599","johnB_honai","Password@123","mentor",16,1603,160301,1603010007,16030100406,student,"student-preschool-class-2,student-class-1-5" +"PradeepB","pradeepB@tunerlabs.com","+91","7012345699","pradeepB_tl","Password@123","mentor,session_manager",16,1603,160301,1603010007,16030100406,student,"student-preschool-class-2,student-class-1-5" diff --git a/src/services/admin.js b/src/services/admin.js index 21a44139..1bb0b0ea 100644 --- a/src/services/admin.js +++ b/src/services/admin.js @@ -651,6 +651,26 @@ module.exports = class AdminHelper { static async deactivateOrg(organizationCode, tenantCode, loggedInUserId) { try { + const org = await organizationQueries.findOne({ code: organizationCode, tenant_code: tenantCode }) + + if (!org) { + return responses.failureResponse({ + message: 'ORG_NOT_FOUND', + statusCode: httpStatusCode.not_found, + responseCode: 'CLIENT_ERROR', + }) + } + + if (org.status === common.INACTIVE_STATUS) { + return responses.failureResponse({ + message: 'ORG_ALREADY_INACTIVE', + statusCode: httpStatusCode.bad_request, + responseCode: 'CLIENT_ERROR', + }) + } + + // proceed with update + // 1. Deactivate org const orgRowsAffected = await organizationQueries.update( { @@ -663,9 +683,9 @@ module.exports = class AdminHelper { } ) - if (orgRowsAffected === 0) { + if (!orgRowsAffected) { return responses.failureResponse({ - message: 'STATUS_UPDATE_FAILED', + message: 'ORG_DEACTIVATION_FAILED', statusCode: httpStatusCode.bad_request, responseCode: 'CLIENT_ERROR', }) diff --git a/src/validators/v1/admin.js b/src/validators/v1/admin.js index 32173eac..7538047f 100644 --- a/src/validators/v1/admin.js +++ b/src/validators/v1/admin.js @@ -163,4 +163,18 @@ module.exports = { req.checkBody(field).isArray().notEmpty().withMessage(` ${field} must be an array and should not be empty.`) } }, + + deactivateOrg: (req) => { + req.checkParams('id') + .notEmpty() + .withMessage('id is required and should not be empty.') + .matches(/^[a-z0-9_]+$/) + .withMessage('id must be lowercase alphanumeric with underscores.') + + req.checkHeaders('tenant-id') + .notEmpty() + .withMessage('tenant-id is required and should not be empty.') + .matches(/^[a-z0-9_]+$/) + .withMessage('tenant-id must be lowercase alphanumeric with underscores.') + }, } diff --git a/src/validators/v1/organization.js b/src/validators/v1/organization.js index f81a6d42..0068779a 100644 --- a/src/validators/v1/organization.js +++ b/src/validators/v1/organization.js @@ -27,7 +27,7 @@ module.exports = { .trim() .notEmpty() .withMessage('name field is empty') - .matches(/^[A-Za-z ]+$/) + .matches(/^[A-Za-z0-9 ]+$/) .withMessage('name is invalid') req.checkBody('description') @@ -47,7 +47,7 @@ module.exports = { .trim() .notEmpty() .withMessage('name field is empty') - .matches(/^[A-Za-z ]+$/) + .matches(/^[A-Za-z0-9 ]+$/) .withMessage('name is invalid') req.checkBody('description') diff --git a/src/validators/v1/user-role.js b/src/validators/v1/user-role.js index 776435ac..32c20983 100644 --- a/src/validators/v1/user-role.js +++ b/src/validators/v1/user-role.js @@ -1,5 +1,6 @@ const filterRequestBody = require('../common') const { userRole } = require('@constants/blacklistConfig') +const common = require('@constants/common') const validateList = (req, allowedVariables) => { allowedVariables.forEach((variable) => { req.checkQuery(variable) @@ -12,88 +13,117 @@ const validateList = (req, allowedVariables) => { module.exports = { create: (req) => { req.body = filterRequestBody(req.body, userRole.create) + req.checkBody('title') .trim() .notEmpty() - .withMessage('title field is empty') + .withMessage('title is required') .matches(/^[a-z_]+$/) - .withMessage('title is invalid, must not contain spaces') + .withMessage('title must contain only lowercase letters (a–z) and underscores') req.checkBody('user_type') .trim() .notEmpty() - .withMessage('userType field is empty') - .matches(/^[0-9]+$/) - .withMessage('userType is invalid, must not contain spaces') + .withMessage('user_type is required') + .isIn(['0', '1']) + .withMessage('user_type must be 0 (non-admin) or 1 (admin)') req.checkBody('visibility') .trim() .notEmpty() - .withMessage('visibility field is empty') - .matches(/^[A-Z_]+$/) - .withMessage('visibility is invalid, must not contain spaces') + .withMessage('visibility is required') + .isIn(['PUBLIC']) + .withMessage('visibility must be PUBLIC') req.checkBody('label') .trim() .notEmpty() - .withMessage('label field is empty') + .withMessage('label is required') .matches(/^[A-Z][a-zA-Z\s]*$/) - .withMessage('label is invalid, first letter must be capital') + .withMessage('label must start with an uppercase letter and contain only letters and spaces') + .isLength({ max: 50 }) + .withMessage('label must be at most 50 characters') req.checkBody('status') .trim() - .matches(/^[A-Za-z]*$/) - .withMessage('status is invalid, must not contain spaces') .optional({ checkFalsy: true }) - .notEmpty() - .withMessage('status field must be a non-empty string when provided') + .isIn([common.ACTIVE_STATUS, common.INACTIVE_STATUS]) + .withMessage(`status must be either ${common.ACTIVE_STATUS} or ${common.INACTIVE_STATUS} when provided`) }, update: (req) => { req.body = filterRequestBody(req.body, userRole.update) - req.checkParams('id').notEmpty().withMessage('id param is empty') + + req.checkParams('id').notEmpty().withMessage('id param is required') req.checkBody('title') .trim() .notEmpty() - .withMessage('title field is empty') + .withMessage('title is required') .matches(/^[a-z_]+$/) - .withMessage('title is invalid, must not contain spaces') + .withMessage('title must contain only lowercase letters (a–z) and underscores') req.checkBody('user_type') .trim() .notEmpty() - .withMessage('userType field is empty') - .matches(/^[0-9]+$/) - .withMessage('userType is invalid, must not contain spaces') + .withMessage('user_type is required') + .isIn(['0', '1']) + .withMessage('user_type must be 0 (non-admin) or 1 (admin)') req.checkBody('visibility') .trim() .notEmpty() - .withMessage('visibility field is empty') - .matches(/^[A-Z_]+$/) - .withMessage('visibility is invalid, must not contain spaces') + .withMessage('visibility is required') + .isIn(['PUBLIC']) + .withMessage('visibility must be PUBLIC') req.checkBody('status') .trim() - .matches(/^[A-Za-z]*$/) - .withMessage('status is invalid, must not contain spaces') .optional({ checkFalsy: true }) - .notEmpty() - .withMessage('status field must be a non-empty string when provided') - }, + .isIn([common.ACTIVE_STATUS, common.INACTIVE_STATUS]) + .withMessage(`status must be either ${common.ACTIVE_STATUS} or ${common.INACTIVE_STATUS} when provided`) + req.checkBody('label') + .trim() + .optional() + .matches(/^[A-Z][a-zA-Z\s]*$/) + .withMessage('label must start with an uppercase letter and contain only letters and spaces') + .isLength({ max: 50 }) + .withMessage('label must be at most 50 characters') + }, delete: (req) => { req.checkParams('id').notEmpty().withMessage('id param is empty') }, list: (req) => { - const allowedVariables = ['title', 'user_type', 'visibility', 'organization_id', 'status'] - validateList(req, allowedVariables) - }, + req.checkQuery('title') + .optional() + .trim() + .matches(/^[a-z_]+$/) + .withMessage('title is invalid. Use only lowercase letters a–z and underscores') + + req.checkQuery('user_type') + .optional() + .trim() + .isIn(['0', '1']) + .withMessage('user_type is invalid. Allowed values: 0 (non-admin) or 1 (admin)') + + req.checkQuery('visibility') + .optional() + .trim() + .isIn(['PUBLIC']) + .withMessage('visibility is invalid. Allowed value: PUBLIC') + + req.checkQuery('status') + .optional() + .trim() + .isIn([common.ACTIVE_STATUS, common.INACTIVE_STATUS]) + .withMessage(`status must be either ${common.ACTIVE_STATUS} or ${common.INACTIVE_STATUS} when provided`) - default: (req) => { - const allowedVariables = ['title', 'user_type', 'visibility', 'organization_id', 'status'] - validateList(req, allowedVariables) + req.checkQuery('organization_id') + .optional() + .trim() + .matches(/^[0-9]+$/) + .withMessage('organization_id is invalid. Must be numeric') }, } diff --git a/src/validators/v1/user.js b/src/validators/v1/user.js index 2dd1ea68..0e257526 100644 --- a/src/validators/v1/user.js +++ b/src/validators/v1/user.js @@ -39,4 +39,60 @@ module.exports = { .isString() .withMessage('preferred_language must be string') }, + profileById: (req) => { + // id (numeric only) + req.checkParams('id') + .optional() + .trim() + .matches(/^[0-9]+$/) + .withMessage('id is invalid. Must be numeric') + + // email + req.checkQuery('email') + .optional() + .trim() + .isEmail() + .withMessage('email is invalid. Must be a valid email format') + // username + req.checkQuery('username') + .optional() + .trim() + .matches(/^(?:[a-z0-9_-]{3,40}|[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,})$/) //accept random string (min 3 max 40) of smaller case letters _ - and numbers OR email in lowercase as username + .withMessage('username is invalid') + // phone + req.checkQuery('phone') + .optional() + .trim() + .matches(/^[0-9]{7,15}$/) + .withMessage('phone is invalid. Must be digits only, length 7–15') + + // phone_code + req.checkQuery('phone_code') + .optional() + .trim() + .matches(/^\+[0-9]{1,4}$/) + .withMessage('phone_code is invalid. Must start with + and contain 1–4 digits') + + // tenant_code + req.checkQuery('tenant_code') + .trim() + .matches(/^[A-Za-z0-9_-]+$/) + .withMessage('tenant_code is invalid. Only letters, numbers, underscore, and hyphen allowed') + + if (!req.params.id) { + req.checkQuery(['email', 'username', 'phone', 'phone_code']).custom(() => { + const { email, username, phone, phone_code } = req.query + + if (!email && !username && !phone) { + throw new Error('At least one of id, email, username, or phone must be provided') + } + + if (phone && !phone_code) { + throw new Error('phone_code is required when phone is provided') + } + + return true + }) + } + }, }