Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
08bd39d
@coderabbitai
adithyadinesh0412 Sep 16, 2025
bd62e19
fix: improve validation messages and add query parameter checks for u…
nevil-mathew Sep 24, 2025
4531441
feat: add profileById validation for user queries
nevil-mathew Sep 24, 2025
6643cfd
bulk upload changed from name to external id
adithyadinesh0412 Sep 24, 2025
6ad81de
add: create migration for unique user roles index with soft-delete ha…
nevil-mathew Sep 25, 2025
62ba5f5
sample csv fix
adithyadinesh0412 Sep 26, 2025
bc01cc7
feat: add organization deactivation validation and error messages
nevil-mathew Sep 26, 2025
049ac72
fix: simplify organization deactivation check in deactivateOrg method
nevil-mathew Sep 26, 2025
e391e82
Merge pull request #822 from adithyadinesh0412/entity-type-fix-in-bul…
nevil-mathew Sep 29, 2025
3ee4c02
@coderabbitai
adithyadinesh0412 Sep 29, 2025
e8b0e94
fix: enhance label validation and status checks in user role validation
nevil-mathew Sep 29, 2025
bddc326
fix: update status validation to ensure optional check is applied cor…
nevil-mathew Sep 29, 2025
f055147
Merge pull request #831 from ELEVATE-Project/org-deactivate-validation
aks30 Sep 29, 2025
00e09ab
Merge pull request #829 from ELEVATE-Project/user-role-unique
aks30 Sep 29, 2025
0c72001
Merge pull request #827 from ELEVATE-Project/user-profile-read-by-id
aks30 Sep 29, 2025
2dc3075
Merge pull request #826 from ELEVATE-Project/user-role-validation
aks30 Sep 29, 2025
88c5f3a
Merge pull request #832 from adithyadinesh0412/org-name-validator-fix
nevil-mathew Sep 29, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions src/database/migrations/20250925122507-update-unique-user-roles.js
Original file line number Diff line number Diff line change
@@ -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',
})
},
}
8 changes: 6 additions & 2 deletions src/generics/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 }
}
Expand Down
46 changes: 37 additions & 9 deletions src/helpers/userInvite.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
) || []
: [],
}
Expand Down
6 changes: 5 additions & 1 deletion src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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!"
}
6 changes: 3 additions & 3 deletions src/sample.csv
Original file line number Diff line number Diff line change
@@ -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"
24 changes: 22 additions & 2 deletions src/services/admin.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(
{
Expand All @@ -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',
})
Expand Down
14 changes: 14 additions & 0 deletions src/validators/v1/admin.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.')
},
}
4 changes: 2 additions & 2 deletions src/validators/v1/organization.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand All @@ -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')
Expand Down
98 changes: 64 additions & 34 deletions src/validators/v1/user-role.js
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -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')
},
}
Loading