diff --git a/.github/workflows/brac-dev-deployment.yaml b/.github/workflows/brac-dev-deployment.yaml new file mode 100644 index 000000000..57cbee0a4 --- /dev/null +++ b/.github/workflows/brac-dev-deployment.yaml @@ -0,0 +1,86 @@ +name: Dev Build & Deploy User Service (BRAC) + +on: + push: + branches: + - develop + +env: + AWS_REGION: ${{ secrets.AWS_REGION }} + ECR_REPOSITORY: ${{ secrets.ECR_REPOSITORY_BRAC }} + AWS_ACCOUNT_ID: ${{ secrets.AWS_ACCOUNT_ID }} + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + + permissions: + contents: read + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + # ========================= + # AWS Authentication + # ========================= + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ env.AWS_REGION }} + + # ========================= + # Login to Amazon ECR + # ========================= + - name: Login to Amazon ECR + uses: aws-actions/amazon-ecr-login@v2 + + # ========================= + # Build & Push Image + # ========================= + - name: Build and Push Docker Image to ECR + uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: | + ${{ env.AWS_ACCOUNT_ID }}.dkr.ecr.${{ env.AWS_REGION }}.amazonaws.com/${{ env.ECR_REPOSITORY }}:latest-brac + ${{ env.AWS_ACCOUNT_ID }}.dkr.ecr.${{ env.AWS_REGION }}.amazonaws.com/${{ env.ECR_REPOSITORY }}:${{ github.sha }} + # ========================= + # Deploy on Server + # ========================= + - name: Deploy Stack + uses: appleboy/ssh-action@master + with: + host: ${{ secrets.HOST_NAME_DEV }} + username: ${{ secrets.USERNAME }} + key: ${{ secrets.SSH_KEY }} + port: ${{ secrets.PORT }} + script: | + set -e + + # Export AWS variables + export AWS_REGION="${{ secrets.AWS_REGION }}" + export AWS_ACCOUNT_ID="${{ secrets.AWS_ACCOUNT_ID }}" + #export aws-access-key-id="${{ secrets.AWS_ACCESS_KEY_ID }}" + #export aws-secret-access-key="${{ secrets.AWS_SECRET_ACCESS_KEY }}" + cd ${{ secrets.TARGET_DIR_DEV }} + + # Backup old env if exists + if [ -f .env ]; then + mv .env .env-bkp + fi + + # Write env safely (MULTILINE SAFE) + cat << 'EOF' > .env + ${{ secrets.DEV_ENV_BRAC }} + EOF + + # Login to ECR (non-interactive) + aws ecr get-login-password --region "$AWS_REGION" \ + | docker login --username AWS \ + --password-stdin "$AWS_ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com" + + ./deploy.sh diff --git a/.github/workflows/brac-qa-deplyment.yaml b/.github/workflows/brac-qa-deplyment.yaml new file mode 100644 index 000000000..3cd4f440e --- /dev/null +++ b/.github/workflows/brac-qa-deplyment.yaml @@ -0,0 +1,87 @@ +name: Tag Build & Deploy User Service (BRAC) + +on: + push: + tags: + - "v*" + +env: + AWS_REGION: ${{ secrets.AWS_REGION }} + AWS_ACCOUNT_ID: ${{ secrets.AWS_ACCOUNT_ID }} + ECR_REPOSITORY: ${{ secrets.ECR_REPOSITORY_BRAC }} + TAG: ${{ github.ref_name }} + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + + permissions: + contents: read + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + # ========================= + # AWS Authentication + # ========================= + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ env.AWS_REGION }} + + # ========================= + # Login to Amazon ECR + # ========================= + - name: Login to Amazon ECR + uses: aws-actions/amazon-ecr-login@v2 + + # ========================= + # Build & Push Docker Image + # ========================= + - name: Build and Push Docker Image to ECR + uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: | + ${{ env.AWS_ACCOUNT_ID }}.dkr.ecr.${{ env.AWS_REGION }}.amazonaws.com/${{ env.ECR_REPOSITORY }}:${{ env.TAG }} + + # ========================= + # Deploy on QA Server + # ========================= + - name: Deploy Stack to QA + uses: appleboy/ssh-action@master + with: + host: ${{ secrets.HOST_NAME_QA }} + username: ${{ secrets.USERNAME }} + key: ${{ secrets.EC2_KEY }} + port: ${{ secrets.PORT }} + script: | + set -e + + export AWS_ACCESS_KEY_ID=${{ secrets.AWS_ACCESS_KEY_ID }} + export AWS_SECRET_ACCESS_KEY=${{ secrets.AWS_SECRET_ACCESS_KEY }} + export AWS_REGION=${{ env.AWS_REGION }} + + cd ${{ secrets.TARGET_DIR_QA }} + + # Backup old env if exists + if [ -f .env ]; then + mv .env .env-bkp + fi + + # Write env safely (MULTILINE SAFE) + cat << 'EOF' > .env + ${{ secrets.QA_ENV_BRAC }} + EOF + + aws ecr get-login-password --region ${AWS_REGION} \ + | docker login \ + --username AWS \ + --password-stdin \ + ${{ env.AWS_ACCOUNT_ID }}.dkr.ecr.${AWS_REGION}.amazonaws.com + + ./deploy.sh ${{ env.TAG }} diff --git a/src/api-doc/bulkUser.md b/src/api-doc/bulkUser.md new file mode 100644 index 000000000..1a9c9762e --- /dev/null +++ b/src/api-doc/bulkUser.md @@ -0,0 +1,182 @@ +# Bulk User Creation Guide + +This guide provides step-by-step instructions for performing bulk user creation in the Elevate User Service. + +## Prerequisites + +- Valid JWT token with admin privileges +- CSV file containing user data in the required format +- Access to the API endpoints + +## CSV Format + +Your CSV file must include the following columns (case-sensitive): + +``` +name,email,phone_code,phone,username,password,roles,province,district,local_municipality,linkageChampion,supervisor +``` + +### Sample CSV Content + +``` +name,email,phone_code,phone,username,password,roles,province,district,local_municipality,linkageChampion,supervisor +Farabi Ahmedullah,farabi.ahmedullah@yopmail.com,91,7012345499,farabi,Password@123,session_manager,SA-EC,SA-EC-ALFR,SA-EC-ALFR-MATA,,amolp +Carol Miranda,carol.miranda@yopmail.com,91,7012345599,carol,Password@123,session_manager,SA-EC,SA-EC-ALFR,SA-EC-ALFR-MATA,,amolp +Amol Patil,amol,patil@yopmail.com,91,7012345699,amolp,Password@123,org_admin,SA-EC,SA-EC-ALFR,SA-EC-ALFR-MATA,, +Suvarna Kale,suvarnak@yopmail.com,91,7012345699,suvarna,Password@123,user,SA-EC,SA-EC-ALFR,SA-EC-ALFR-MATA,,farabi +``` + +### Field Descriptions + +- `name`: User's full name (required) +- `email`: User's email address (required if phone not provided) +- `phone_code`: Country code for phone (e.g., 91 for India) +- `phone`: User's phone number (required if email not provided) +- `username`: Desired username (optional, system will generate if not provided) +- `password`: User's password (required for direct creation, not for invitations) +- `roles`: Comma-separated list of roles (e.g., "session_manager,org_admin") +- Additional columns like `province`, `district`, etc., are for metadata + +## Step-by-Step Process + +### Step 1: Get Signed URL for File Upload + +First, obtain a signed URL to upload your CSV file to cloud storage. + +**Endpoint:** `GET /v1/cloud-services/file/getSignedUrl` + +**Query Parameters:** + +- `fileName`: Name of your CSV file (e.g., `bulk_users.csv`) + +**Headers:** + +- `X-auth-token`: Your JWT token + +**Example Request:** + +```bash +curl --location '{{baseURL}}user/v1/cloud-services/file/getSignedUrl?fileName=bulk_users.csv' \ +--header 'X-auth-token: YOUR_JWT_TOKEN' +``` + +**Response:** + +```json +{ + "success": true, + "message": "SIGNED_URL_GENERATED_SUCCESSFULLY", + "result": { + "signedUrl": "https://your-cloud-storage-url...", + "filePath": "users/YOUR_USER_ID-TIMESTAMP-bulk_users.csv", + "destFilePath": "users/YOUR_USER_ID-TIMESTAMP-bulk_users.csv" + } +} +``` + +### Step 2: Upload CSV File + +Upload your CSV file to the signed URL obtained in Step 1. + +**Example Request:** + +```bash +curl -X PUT -T /path/to/your/bulk_users.csv 'SIGNED_URL_FROM_STEP_1' +``` + +**Note:** Replace `/path/to/your/bulk_users.csv` with the actual path to your CSV file, and use single quotes around the signed URL to prevent shell interpretation of special characters. + +### Step 3: Perform Bulk User Creation + +Call the bulk user creation endpoint with the file path from Step 1. + +**Endpoint:** `POST /v1/tenant/bulkUserCreate` + +**Headers:** + +- `X-auth-token`: Your JWT token +- Organization code header (configurable via `ORG_CODE_HEADER_NAME` env var, defaults to `x-org-code`): Your organization code (e.g., `brac_gbl`) +- Tenant code header (configurable via `TENANT_CODE_HEADER_NAME` env var, defaults to `x-tenant-code`): Your tenant code (e.g., `brac`) +- `Content-Type`: `application/json` + +**Note on Headers:** The header names for organization and tenant codes are configurable through environment variables: + +- `ORG_CODE_HEADER_NAME=organization` (current setting) +- `TENANT_CODE_HEADER_NAME=tenant` (current setting) + +If these are not set, the defaults are `x-org-code` and `x-tenant-code`. Use the appropriate header names based on your environment configuration. + +**Request Body:** + +```json +{ + "file_path": "users/YOUR_USER_ID-TIMESTAMP-bulk_users.csv", + "editable_fields": ["name", "email"], + "upload_type": "CREATE" +} +``` + +**Example Request:** + +```bash +curl --location 'http://localhost:3567/user/v1/tenant/bulkUserCreate' \ +--header 'Content-Type: application/json' \ +--header 'X-auth-token: YOUR_JWT_TOKEN' \ +--header 'organization: brac_gbl' \ +--header 'tenant: brac' \ +--data '{ + "file_path" : "users/YOUR_USER_ID-TIMESTAMP-bulk_users.csv", + "editable_fields" : ["name"], + "upload_type": "CREATE" +}' +``` + +**Note:** The header names `organization` and `tenant` match the current environment variable settings. If your environment uses different header names (e.g., `x-org-code`, `x-tenant-code`), update the curl command accordingly. + +**Response:** + +```json +{ + "success": true, + "message": "USER_CSV_UPLOADED", + "result": { + "id": 123, + "name": "bulk_users.csv", + "input_path": "users/YOUR_USER_ID-TIMESTAMP-bulk_users.csv", + "type": "CSV", + "organization_id": 66, + "created_by": 3074, + "tenant_code": "brac", + "uploadType": "CREATE", + "status": "PENDING", + "created_at": "2025-12-26T06:31:24.000Z", + "updated_at": "2025-12-26T06:31:24.000Z" + } +} +``` + +## Processing and Results + +- The bulk upload is processed asynchronously via a background queue. +- You will receive an email notification with a download link to the results CSV once processing is complete. +- The results CSV will contain the status of each user creation/update attempt. + +## Upload Types + +- `"CREATE"`: Directly creates user accounts with provided passwords +- `"UPLOAD"`: Creates users and sends invitation emails +- `"INVITE"`: Sends invitation emails without creating accounts + +## Troubleshooting + +- **404 Error on Download**: Ensure the CSV file was successfully uploaded to the signed URL in Step 2. +- **Validation Errors**: Check that your CSV format matches the sample and all required fields are present. +- **Permission Denied**: Ensure your JWT token has admin privileges for the specified tenant and organization. +- **Expired Signed URL**: Signed URLs expire after 15 minutes. If expired, repeat Step 1. + +## Additional Notes + +- The process supports up to 1000 users per CSV file. +- Duplicate emails/phones will be handled based on existing user records. +- System-generated usernames will be assigned if not provided or if conflicts occur. +- All operations are logged and can be audited. diff --git a/src/controllers/v1/tenant.js b/src/controllers/v1/tenant.js index 0e628ed79..c0db673b1 100644 --- a/src/controllers/v1/tenant.js +++ b/src/controllers/v1/tenant.js @@ -11,6 +11,8 @@ const utilsHelper = require('@generics/utils') const common = require('@constants/common') const httpStatusCode = require('@generics/http-status') const responses = require('@helpers/responses') +const accountService = require('@services/account') + module.exports = class Tenant { /** * Updates tenant data @@ -133,6 +135,7 @@ module.exports = class Tenant { return error } } + /** * List tenants * @method POST @@ -156,6 +159,29 @@ module.exports = class Tenant { return error } } + /** + * Create account user + * @method POST + * @name AccountCreate + * @param {Object} req -request data. + * @returns {JSON} - success or error message + */ + + async accountCreate(req) { + try { + let registerWithLogin = false + const usersRes = await accountService.create( + req.body, + {}, + '', + registerWithLogin, //set registerWithLogin to true, this will create user with login + req + ) + return usersRes + } catch (error) { + return error + } + } /** * Read tenant details for internal service calls diff --git a/src/database/queries/users.js b/src/database/queries/users.js index ed2e6d1c8..8bfade293 100644 --- a/src/database/queries/users.js +++ b/src/database/queries/users.js @@ -401,6 +401,8 @@ exports.searchUsersWithOrganization = async ({ emailIds, excluded_user_ids, tenantCode, + status, + metaFilters, }) => { try { const offset = (page - 1) * limit @@ -408,6 +410,10 @@ exports.searchUsersWithOrganization = async ({ // Base filter for user const userWhere = {} + if (status) { + userWhere.status = status.toUpperCase() + } + // Filter by userIds / exclude if (userIds && Array.isArray(userIds)) { userWhere.id = { [Op.in]: userIds } @@ -427,6 +433,20 @@ exports.searchUsersWithOrganization = async ({ userWhere.name = { [Op.iLike]: `%${search}%` } } + // Filter by meta fields (generic - supports any meta field) + if (metaFilters && typeof metaFilters === 'object' && Object.keys(metaFilters).length > 0) { + userWhere[Op.and] = userWhere[Op.and] || [] + for (const [key, value] of Object.entries(metaFilters)) { + if (value !== null && value !== undefined && value !== '') { + // Use Sequelize.where with JSONB operator for safe parameterized queries + // The key is validated to be alphanumeric/underscore only to prevent SQL injection + if (/^[a-zA-Z0-9_]+$/.test(key)) { + userWhere[Op.and].push(Sequelize.where(Sequelize.literal(`"User".meta->>'${key}'`), value)) + } + } + } + } + const users = await database.User.findAndCountAll({ where: userWhere, limit: limit, diff --git a/src/generics/utils.js b/src/generics/utils.js index e383ae26e..e45c4ab85 100644 --- a/src/generics/utils.js +++ b/src/generics/utils.js @@ -449,7 +449,7 @@ async function processDbResponse(responseBody, entityType) { // Move the key from responseBody.meta to responseBody root level responseBody[entityTypeValue] = responseBody.meta[entityTypeValue] // Delete the key from responseBody.meta - delete responseBody.meta[entityTypeValue] + //delete responseBody.meta[entityTypeValue] } else { const externalBaseUrl = process.env?.[`${entity.meta.service.toUpperCase()}_BASE_URL`] || @@ -589,7 +589,7 @@ async function processDbResponse(responseBody, entityType) { responseBody[entityTypeValue] = {} } } - delete responseBody.meta[entityTypeValue] + //delete responseBody.meta[entityTypeValue] } } }) @@ -1038,7 +1038,7 @@ function parseMetaData(meta = {}, prunedEntities, feederData) { // find the entity type from the entities array with the value of the entity const findEntity = prunedEntities.find((entity) => entity.value == metaKey) // check the data type of the entity to loop in Array type entities - if (findEntity.data_type == 'ARRAY' || findEntity.data_type == 'ARRAY[STRING]') { + if (findEntity && (findEntity?.data_type == 'ARRAY' || findEntity?.data_type == 'ARRAY[STRING]')) { metaData[metaKey] = meta?.[metaKey].map((entity) => { const id = getId(entity) // get the id from the input const find = Object.values(feederData).find( diff --git a/src/helpers/userInvite.js b/src/helpers/userInvite.js index 3f11a31cd..282f8075c 100644 --- a/src/helpers/userInvite.js +++ b/src/helpers/userInvite.js @@ -338,13 +338,61 @@ module.exports = class UserInviteHelper { : [], } - delete row.block - delete row.state - delete row.school - delete row.cluster - delete row.district - delete row.professional_role - delete row.professional_subroles + // 2. Handle dynamic entityFields (skipping what we already did) + const alreadyProcessed = [ + 'block', + 'state', + 'school', + 'cluster', + 'district', + 'professional_role', + 'professional_subroles', + ] + + entityFields.forEach((field) => { + // 1. Skip if already handled in the hardcoded block + if (alreadyProcessed.includes(field)) return + + // 2. Only process if the row actually has data for this field + if (row[field]) { + // Find the definition to check if it's an ARRAY type + const entityDef = validationData.find((e) => e.value === field) + const cleanField = field.replaceAll(/\s+/g, '').toLowerCase() + + if ( + entityDef && + (entityDef.data_type === 'ARRAY' || entityDef.data_type === 'ARRAY[STRING]') + ) { + // Handle ARRAY types: split by comma, clean each value, and map to IDs + row.meta[field] = row[field] + .split(',') + .map((val) => { + const cleanVal = val.trim().replaceAll(/\s+/g, '').toLowerCase() + const lookupKey = `${cleanVal}${cleanField}` + return externalEntityNameIdMap?.[lookupKey]?._id + }) + .filter(Boolean) // Removes null/undefined if an ID isn't found + } else { + // Handle Single value types (Standard logic) + const cleanVal = String(row[field]).replaceAll(/\s+/g, '').toLowerCase() + const lookupKey = `${cleanVal}${cleanField}` + + const matchedId = externalEntityNameIdMap?.[lookupKey]?._id + + if (matchedId !== undefined && matchedId !== null) { + row.meta[field] = matchedId + } else { + row.meta[field] = row[field] + } + } + } + }) + + // 3. Delete all processed fields from the root row + const allFieldsToDelete = [...alreadyProcessed, ...entityFields] + allFieldsToDelete.forEach((field) => { + delete row[field] + }) // Handle password field if exists if (row.password) { @@ -1028,6 +1076,7 @@ module.exports = class UserInviteHelper { } inviteeData.email = encryptedEmail + inviteeData.phone = encryptedPhoneNumber || null if ( !inviteeData?.username || alreadyTakenUserNames.includes(inviteeData?.username) || @@ -1248,6 +1297,7 @@ module.exports = class UserInviteHelper { invitation_code, } inviteeData.email = encryptedEmail + inviteeData.phone = encryptedPhoneNumber || null inviteeData.username = inviteeData?.username ? inviteeData?.username : await generateUniqueUsername(inviteeData?.name) diff --git a/src/locales/en.json b/src/locales/en.json index 5a864343b..c22b2a5ad 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -152,6 +152,9 @@ "TENANT_DOMAIN_NOT_FOUND_PING_ADMIN": "Tenant domain not found. Please contact your administrator for assistance.", "TENANT_NOT_FOUND_PING_ADMIN": "Tenant not found. Please contact your administrator for assistance.", "EMAIL_OR_PHONE_REQUIRED": "Either an email address or phone number is required.", + "PHONE_CODE_REQUIRED": "Phone code is required when a phone number is provided.", + "PHONE_ALREADY_EXISTS": "This phone number is already registered with another user.", + "EMAIL_ALREADY_EXISTS": "This email address is already registered with another user.", "INVALID_ORG_REGISTRATION_CODE": "The organization registration code you entered is invalid. Please check and try again.", "IDENTIFIER_OR_PASSWORD_INVALID": "Invalid identifier or password. Please check your login credentials.", "IDENTIFIER_REQUIRED": "An identifier (email or phone) is required to proceed.", diff --git a/src/services/account.js b/src/services/account.js index 33c8afe1b..c0f586e47 100644 --- a/src/services/account.js +++ b/src/services/account.js @@ -39,6 +39,7 @@ const UserTransformDTO = require('@dtos/userDTO') const notificationUtils = require('@utils/notification') const userHelper = require('@helpers/userHelper') const { broadcastEvent } = require('@helpers/eventBroadcasterMain') +const { use } = require('i18next') module.exports = class AccountHelper { /** @@ -54,7 +55,8 @@ module.exports = class AccountHelper { * @returns {JSON} - returns account creation details. */ - static async create(bodyData, deviceInfo, domain) { + static async create(bodyData, deviceInfo, domain, registerWithLogin = true, req = {}) { + let tenantId const projection = ['password'] let isInvitedUserId = false @@ -65,18 +67,24 @@ module.exports = class AccountHelper { statusCode: httpStatusCode.not_acceptable, responseCode: 'CLIENT_ERROR', }) + let tenantDomain - const tenantDomain = await tenantDomainQueries.findOne({ domain }) - - if (!tenantDomain) { - return notFoundResponse('TENANT_DOMAIN_NOT_FOUND_PING_ADMIN') + if (domain) { + tenantDomain = await tenantDomainQueries.findOne({ domain }) + + if (!tenantDomain) { + return notFoundResponse('TENANT_DOMAIN_NOT_FOUND_PING_ADMIN') + } + } else if (req.headers?.[common.TENANT_CODE_HEADER]) { + tenantId = req.headers?.[common.TENANT_CODE_HEADER] + tenantDomain = { tenant_code: tenantId } } const tenantDetail = await tenantQueries.findOne({ code: tenantDomain.tenant_code, status: common.ACTIVE_STATUS, }) - + if (!tenantDetail) { return notFoundResponse('TENANT_NOT_FOUND_PING_ADMIN') } @@ -314,7 +322,7 @@ module.exports = class AccountHelper { role = await roleQueries.findAll( { title: { - [Op.in]: process.env.DEFAULT_ROLE.split(','), + [Op.in]: req.body.roles ? req.body.roles.split(',') : process.env.DEFAULT_ROLE.split(','), }, tenant_code: tenantDetail.code, }, @@ -373,6 +381,8 @@ module.exports = class AccountHelper { }), userQueries.getColumns(), ]) + console.log('validationData', validationData) + console.log('userModel', userModel) const prunedEntities = removeDefaultOrgEntityTypes(validationData, userOrgId) @@ -460,6 +470,7 @@ module.exports = class AccountHelper { } else { userCredentials = await UserCredentialQueries.create(userCredentialsBody) } + /* FLOW STARTED: user login after registration */ user = await userQueries.findUserWithOrganization( { id: insertedUser.id, tenant_code: tenantDetail.code }, @@ -471,43 +482,71 @@ module.exports = class AccountHelper { ) const roleData = user.organizations[0].roles + let tokenDetail = {} + let result = { user } + if (registerWithLogin) { + /** + * create user session entry and add session_id to token data + * Entry should be created first, the session_id has to be added to token creation data + */ + const userSessionDetails = await userSessionsService.createUserSession( + user.id, // userid + '', // refresh token + '', // Access token + deviceInfo, + user.tenant_code + ) - /** - * create user session entry and add session_id to token data - * Entry should be created first, the session_id has to be added to token creation data - */ - const userSessionDetails = await userSessionsService.createUserSession( - user.id, // userid - '', // refresh token - '', // Access token - deviceInfo, - user.tenant_code - ) + /** + * Based on user organisation id get user org parent Id value + * If parent org id is present then set it to tenant of user + * if not then set user organisation id to tenant + */ - /** - * Based on user organisation id get user org parent Id value - * If parent org id is present then set it to tenant of user - * if not then set user organisation id to tenant - */ + /* let tenantDetails = await organizationQueries.findOne( + { id: user.organization_id }, + { attributes: ['related_orgs'] } + ) - /* let tenantDetails = await organizationQueries.findOne( - { id: user.organization_id }, - { attributes: ['related_orgs'] } - ) + const tenant_id = + tenantDetails && tenantDetails.parent_id !== null ? tenantDetails.parent_id : user.organization_id */ - const tenant_id = - tenantDetails && tenantDetails.parent_id !== null ? tenantDetails.parent_id : user.organization_id */ + tokenDetail = { + data: { + id: user.id, + name: user.name, + session_id: userSessionDetails.result.id, + organization_ids: user.organizations.map((org) => String(org.id)), // Convert to string + organization_codes: user.organizations.map((org) => String(org.code)), // Convert to string + tenant_code: tenantDetail.code, + organizations: user.organizations, + }, + } - const tokenDetail = { - data: { - id: user.id, - name: user.name, - session_id: userSessionDetails.result.id, - organization_ids: user.organizations.map((org) => String(org.id)), // Convert to string - organization_codes: user.organizations.map((org) => String(org.code)), // Convert to string - tenant_code: tenantDetail.code, - organizations: user.organizations, - }, + const accessToken = utilsHelper.generateToken( + tokenDetail, + process.env.ACCESS_TOKEN_SECRET, + common.accessTokenExpiry + ) + + const refreshToken = utilsHelper.generateToken( + tokenDetail, + process.env.REFRESH_TOKEN_SECRET, + common.refreshTokenExpiry + ) + + /** + * This function call will do below things + * 1: create redis entry for the session + * 2: update user-session with token and refresh_token + */ + await userSessionsService.updateUserSessionAndsetRedisData( + userSessionDetails.result.id, + accessToken, + refreshToken + ) + + result = { access_token: accessToken, refresh_token: refreshToken } } // user.user_roles = roleData @@ -529,29 +568,6 @@ module.exports = class AccountHelper { .join(' and ') : '' - const accessToken = utilsHelper.generateToken( - tokenDetail, - process.env.ACCESS_TOKEN_SECRET, - common.accessTokenExpiry - ) - - const refreshToken = utilsHelper.generateToken( - tokenDetail, - process.env.REFRESH_TOKEN_SECRET, - common.refreshTokenExpiry - ) - - /** - * This function call will do below things - * 1: create redis entry for the session - * 2: update user-session with token and refresh_token - */ - await userSessionsService.updateUserSessionAndsetRedisData( - userSessionDetails.result.id, - accessToken, - refreshToken - ) - // Delete Redis OTP entries if (encryptedEmailId) await utilsHelper.redisDel(encryptedEmailId) if (encryptedPhoneNumber) await utilsHelper.redisDel(bodyData.phone_code + encryptedPhoneNumber) @@ -570,8 +586,6 @@ module.exports = class AccountHelper { ) } - const result = { access_token: accessToken, refresh_token: refreshToken, user } - if (plaintextEmailId) { notificationUtils.sendEmailNotification({ emailId: plaintextEmailId, @@ -635,7 +649,6 @@ module.exports = class AccountHelper { broadcastEvent('userEvents', { requestBody: eventBody, isInternal: true }) - return responses.successResponse({ statusCode: httpStatusCode.created, message: 'USER_CREATED_SUCCESSFULLY', @@ -1632,7 +1645,7 @@ module.exports = class AccountHelper { let foundKeys = {} let result = [] - /* Required to resolve all promises first before preparing response object else sometime + /* Required to resolve all promises first before preparing response object else sometime it will push unresolved promise object if you put this logic in below for loop */ await Promise.all( @@ -1714,8 +1727,7 @@ module.exports = class AccountHelper { // Clear Redis cache asynchronously (fire and forget) const redisUserKey = `${common.redisUserPrefix}${tenantCode}_${userId}` - utilsHelper.redisDel(redisUserKey).catch((err) => { - }) + utilsHelper.redisDel(redisUserKey).catch((err) => {}) return responses.successResponse({ statusCode: httpStatusCode.ok, @@ -1856,6 +1868,9 @@ module.exports = class AccountHelper { * @param {Object} [params.body] - POST body parameters. * @param {Array} [params.body.user_ids] - Specific user IDs to include in search. * @param {Array} [params.body.excluded_user_ids] - User IDs to exclude from search. + * @param {Object} [params.query.meta] - Meta field filters (JSON string or object) to filter users by custom meta fields. + * @param {Object} [params.body.meta] - Meta field filters (object) to filter users by custom meta fields. + * Example: { "province": "6952163ae83c1c00147132a8", "district": "6952163ae83c1c00147132bb" } * * @returns {Promise} JSON response with user list and count. */ @@ -1889,6 +1904,23 @@ module.exports = class AccountHelper { } }) + // Extract meta filters from query or body (supports any meta field) + const metaFilters = {} + if (params.query?.meta) { + try { + // If meta is a JSON string, parse it + const meta = + typeof params.query.meta === 'string' ? JSON.parse(params.query.meta) : params.query.meta + Object.assign(metaFilters, meta) + } catch (e) { + // If parsing fails, ignore meta filter + } + } + // Also check body for meta filters + if (params.body?.meta && typeof params.body.meta === 'object') { + Object.assign(metaFilters, params.body.meta) + } + let users = await userQueries.searchUsersWithOrganization({ roleIds, organization_id: params.query.organization_id, @@ -1899,6 +1931,8 @@ module.exports = class AccountHelper { emailIds: emailIds.length > 0 ? emailIds : false, excluded_user_ids: params.body?.excluded_user_ids || false, tenantCode: params.query.tenant_code, + status: params.query.status || false, + metaFilters: Object.keys(metaFilters).length > 0 ? metaFilters : undefined, }) if (users.count == 0) { @@ -1912,7 +1946,7 @@ module.exports = class AccountHelper { }) } - /* Required to resolve all promises first before preparing response object else sometime + /* Required to resolve all promises first before preparing response object else sometime it will push unresolved promise object if you put this logic in below for loop */ // Decrypt email and add image URL await Promise.all( @@ -1924,10 +1958,38 @@ module.exports = class AccountHelper { if (user.email) { user.email = emailEncryption.decrypt(user.email) } + if (user.phone) { + user.phone = emailEncryption.decrypt(user.phone) + } return user }) ) + const defaultOrganizationCode = process.env.DEFAULT_ORGANISATION_CODE + + for (let i = 0; i < users.data.length; i++) { + let user = users.data[i] + + // Convert Sequelize instance to plain object + if (user.toJSON) { + user = user.toJSON() + users.data[i] = user + } + + let userOrg = user.user_organizations[0].organization_code + let validationData = await entityTypeQueries.findUserEntityTypesAndEntities({ + status: 'ACTIVE', + organization_code: { + [Op.in]: [userOrg, defaultOrganizationCode], + }, + tenant_code: params.query.tenant_code, + model_names: { [Op.contains]: [await userQueries.getModelName()] }, + }) + const prunedEntities = removeDefaultOrgEntityTypes(validationData, user.user_organizations[0].id) + const processedUser = await utils.processDbResponse(user, prunedEntities) + Object.assign(user, processedUser) + } + return responses.successResponse({ statusCode: httpStatusCode.ok, message: 'USER_LIST', diff --git a/src/services/admin.js b/src/services/admin.js index 13fc47a62..28af1f01e 100644 --- a/src/services/admin.js +++ b/src/services/admin.js @@ -475,9 +475,12 @@ module.exports = class AdminHelper { return failure('IDENTIFIER_OR_PASSWORD_INVALID', httpStatusCode.bad_request) } - // Check for admin role + // Check for admin role or tenant admin role const hasAdminRole = user.user_organizations?.some((org) => - org.roles?.some((r) => r.role?.title?.toLowerCase() === common.ADMIN_ROLE) + org.roles?.some((r) => { + const roleTitle = r.role?.title?.toLowerCase() + return roleTitle === common.ADMIN_ROLE || roleTitle === common.TENANT_ADMIN_ROLE + }) ) if (!hasAdminRole) { diff --git a/src/services/org-admin.js b/src/services/org-admin.js index 3b4fd57cf..d4b10b6f6 100644 --- a/src/services/org-admin.js +++ b/src/services/org-admin.js @@ -581,21 +581,47 @@ module.exports = class OrgAdminHelper { } /** - * @description - Update user API for org-admin to assign role to user + * @description - Update user API for org-admin to assign role to user and/or update profile * @method * @name - updateUser - * @param {number} userId - User ID to which role is assigned - * @param {object} bodyData - It will contain organization id and roles array + * @param {number} userId - User ID to which role is assigned or profile is updated + * @param {object} bodyData - It will contain organization id, roles array, and/or profile fields (province, site, address, etc.) * @param {object} tokenInformation - user token information * @returns {Promise} - A Promise that resolves to a response object. */ static async updateUser(userId, bodyData, tokenInformation) { try { - if (bodyData.organization_id == tokenInformation.organization_id) { + // let checkUser = await userQueries.findOne({ id: userId }) + // if (!checkUser) { + // return responses.failureResponse({ + // responseCode: 'CLIENT_ERROR', + // statusCode: httpStatusCode.bad_request, + // message: 'USER_NOT_FOUND', + // }) + // } + + // Check if user is admin + const isAdmin = utils.validateRoleAccess(tokenInformation.roles, [common.ADMIN_ROLE]) + + // If not admin and trying to update roles, reject + if (!isAdmin && bodyData.roles && Array.isArray(bodyData.roles) && bodyData.roles.length > 0) { + return responses.failureResponse({ + responseCode: 'CLIENT_ERROR', + statusCode: httpStatusCode.forbidden, + message: 'YOU_DONT_HAVE_ACCESS_TO_UPDATE_ROLES', + }) + } + + const updateData = {} + let hasRoleUpdate = false + let hasProfileUpdate = false + + // Handle role updates (only for admin) + if (isAdmin && bodyData.roles && Array.isArray(bodyData.roles) && bodyData.roles.length > 0) { let roles = _.without(bodyData.roles, common.ADMIN_ROLE) let getRoleIds = await roleQueries.findAll({ title: roles }, { attributes: ['id'] }) - if (!getRoleIds) { + if (!getRoleIds || getRoleIds.length === 0) { return responses.failureResponse({ message: 'INVALID_ROLE_ASSIGNMENTS', statusCode: httpStatusCode.bad_request, @@ -603,26 +629,64 @@ module.exports = class OrgAdminHelper { }) } let roleIds = getRoleIds.map((roleId) => roleId.id) - let checkUser = await userQueries.findOne({ id: userId }) - if (!checkUser) { - return responses.failureResponse({ - responseCode: 'CLIENT_ERROR', - statusCode: httpStatusCode.bad_request, - message: 'USER_NOT_FOUND', - }) + updateData.roles = roleIds + hasRoleUpdate = true + } + + // Handle profile updates + // Extract profile fields (everything except organization_id and roles) + let profileFields = { ...bodyData } + delete profileFields.organization_id + delete profileFields.roles + + if (Object.keys(profileFields).length > 0) { + // Use userService.update logic for profile updates + const userService = require('@services/user') + + const orgCode = tokenInformation.organization_code + const tenantCode = tokenInformation.tenant_code + + // For PATCH operations from org-admin, skip required field validation + // Only update the fields that are sent (province, site, location) + const skipRequiredValidation = true + + // Call userService.update to handle profile validation and update + const profileUpdateResult = await userService.update( + profileFields, + userId, + orgCode, + tenantCode, + skipRequiredValidation + ) + + if ( + profileUpdateResult.responseCode !== 'OK' && + profileUpdateResult.statusCode !== httpStatusCode.ok.status + ) { + return profileUpdateResult } - await userQueries.updateUser({ id: userId }, { roles: roleIds }) - return responses.successResponse({ - statusCode: httpStatusCode.ok, - message: 'USER_ROLE_UPDATE_SUCCESSFUL', - }) - } else { - return responses.failureResponse({ - responseCode: 'CLIENT_ERROR', - statusCode: httpStatusCode.bad_request, - message: 'YOU_DONT_HAVE_ACCESS_TO_UPDATE_ROLES', - }) + hasProfileUpdate = true } + + // Update roles if provided (only for admin) + if (hasRoleUpdate) { + await userQueries.updateUser({ id: userId }, updateData) + } + + // Determine success message + let message = 'USER_UPDATE_SUCCESSFUL' + if (hasRoleUpdate && hasProfileUpdate) { + message = 'USER_ROLE_AND_PROFILE_UPDATE_SUCCESSFUL' + } else if (hasRoleUpdate) { + message = 'USER_ROLE_UPDATE_SUCCESSFUL' + } else if (hasProfileUpdate) { + message = 'USER_PROFILE_UPDATE_SUCCESSFUL' + } + + return responses.successResponse({ + statusCode: httpStatusCode.ok, + message: message, + }) } catch (error) { console.log(error) return error diff --git a/src/services/user.js b/src/services/user.js index 929f6fed3..fd02d18fc 100644 --- a/src/services/user.js +++ b/src/services/user.js @@ -31,20 +31,14 @@ module.exports = class UserHelper { * @method * @name update * @param {Object} bodyData - it contains user infomration - * @param {string} pageSize -request data. - * @param {string} searchText - search text. + * @param {string} id - user id + * @param {string} orgCode - organization code + * @param {string} tenantCode - tenant code + * @param {boolean} skipRequiredValidation - skip required field validation (for PATCH operations) * @returns {JSON} - update user response */ - static async update(bodyData, id, orgCode, tenantCode) { + static async update(bodyData, id, orgCode, tenantCode, skipRequiredValidation = false) { try { - if (bodyData.hasOwnProperty('email')) { - return responses.failureResponse({ - message: 'EMAIL_UPDATE_FAILED', - statusCode: httpStatusCode.bad_request, - responseCode: 'CLIENT_ERROR', - }) - } - const user = await userQueries.findOne({ id: id, tenant_code: tenantCode, @@ -78,7 +72,7 @@ module.exports = class UserHelper { bodyData, prunedEntities, await userQueries.getModelName(), - false, + skipRequiredValidation, tenantCode ) if (!res.success) { @@ -90,6 +84,56 @@ module.exports = class UserHelper { }) } + // Encrypt email before it is persisted, same treatment as phone below. Unlike phone, + // users.email has no DB-level unique constraint, so this pre-check is the only thing + // preventing two users in the same tenant ending up with the same email. + if (bodyData.email) { + bodyData.email = emailEncryption.encrypt(String(bodyData.email).toLowerCase()) + + const existingEmailUser = await userQueries.findOne( + { + email: bodyData.email, + tenant_code: tenantCode, + id: { [Op.ne]: id }, + }, + { attributes: ['id'] } + ) + if (existingEmailUser) { + return responses.failureResponse({ + message: 'EMAIL_ALREADY_EXISTS', + statusCode: httpStatusCode.bad_request, + responseCode: 'CLIENT_ERROR', + }) + } + } + + // Encrypt phone before it is persisted. Must run after validateInput (which checks the + // raw phone format) and before restructureBody/updateUser write it to the DB, otherwise + // this column is stored as plaintext and later crashes any read path that decrypts it. + if (bodyData.phone) { + bodyData.phone = emailEncryption.encrypt(String(bodyData.phone)) + + // users.phone has a unique_phone_per_tenant DB constraint. Since encryption uses a + // fixed key/IV, the same plaintext phone always produces the same ciphertext, so a + // pre-check here catches collisions with a clean error instead of an unhandled + // Sequelize UniqueConstraintError. + const existingPhoneUser = await userQueries.findOne( + { + phone: bodyData.phone, + tenant_code: tenantCode, + id: { [Op.ne]: id }, + }, + { attributes: ['id'] } + ) + if (existingPhoneUser) { + return responses.failureResponse({ + message: 'PHONE_ALREADY_EXISTS', + statusCode: httpStatusCode.bad_request, + responseCode: 'CLIENT_ERROR', + }) + } + } + let userModel = await userQueries.getColumns() bodyData.updated_at = new Date().getTime() bodyData = utils.restructureBody(bodyData, validationData, userModel) diff --git a/src/utils/emailEncryption.js b/src/utils/emailEncryption.js index 772ce56a2..05e1251c6 100644 --- a/src/utils/emailEncryption.js +++ b/src/utils/emailEncryption.js @@ -17,6 +17,9 @@ const encrypt = (plainTextEmail) => { const decrypt = (encryptedEmail) => { try { + if (typeof encryptedEmail !== 'string' || encryptedEmail.trim() === '') { + return encryptedEmail + } const decipher = crypto.createDecipheriv(algorithm, secretKey, fixedIV) return decipher.update(encryptedEmail, 'hex', 'utf-8') + decipher.final('utf-8') } catch (err) { diff --git a/src/validators/v1/account.js b/src/validators/v1/account.js index 04badf548..34708e478 100644 --- a/src/validators/v1/account.js +++ b/src/validators/v1/account.js @@ -8,6 +8,41 @@ const common = require('@constants/common') const filterRequestBody = require('../common') const { account } = require('@constants/blacklistConfig') +// Shared across account/tenant/org-admin/user validators - see create()/update() below and +// v1/user.js, v1/org-admin.js, v1/tenant.js, which all reuse these instead of duplicating them. +// create() requires name; update() is a partial update, so name is only validated if sent. +const validateName = (req, { optional = false } = {}) => { + let validator = req.checkBody('name') + + if (optional) { + validator = validator.optional() + } + + validator + .trim() + .notEmpty() + .withMessage('name field is empty') + .matches(/^[A-Za-z ]+$/) + .withMessage('This field can only contain alphabets') +} + +const validatePhoneWithCode = (req) => { + // Numbers only, no length restriction (digit-count enforcement left to the UI) + req.checkBody('phone') + .optional() + .trim() + .matches(/^[0-9]+$/) + .withMessage('phone must contain only numbers') + + // phone is only ever encrypted/stored together with phone_code + req.checkBody(['phone', 'phone_code']).custom(() => { + if (req.body.phone && !req.body.phone_code) { + throw new Error('phone_code is required when phone is provided') + } + return true + }) +} + const emailArrayValidation = (emailIds) => { if (!Array.isArray(emailIds)) { throw new Error('Email must be an array') @@ -37,13 +72,7 @@ module.exports = { req.body = filterRequestBody(req.body, account.create) req.body.username = req?.body?.username ? req?.body?.username.toLowerCase() : req?.body?.username - // Validate name - req.checkBody('name') - .trim() - .notEmpty() - .withMessage('name field is empty') - .matches(/^[A-Za-z ]+$/) - .withMessage('This field can only contain alphabets') + validateName(req) // Validate email (optional) req.checkBody('email') @@ -62,19 +91,7 @@ module.exports = { .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') - // Validate phone (optional) - req.checkBody('phone') - .optional() - .trim() - .matches(/^[0-9]{7,15}$/) - .withMessage('phone must be a valid number between 7 and 15 digits') - - // Validate phone_code (required if phone is provided) - req.checkBody('phone_code') - .optional({ checkFalsy: true }) - .trim() - .isLength({ min: 2, max: 4 }) // Length between 2 and 4 characters - .withMessage('Phone code must be between 2 and 4 characters') + validatePhoneWithCode(req) // Validate password req.checkBody('password') @@ -90,23 +107,32 @@ module.exports = { req.checkBody('role').trim().not().isIn([common.ADMIN_ROLE]).withMessage("User doesn't have admin access") } - req.checkBody(['email', 'phone', 'phone_code']).custom(() => { - const phone = req.body.phone - const phone_code = req.body.phone_code - const email = req.body.email - - if (!email && !phone) { + // phone_code-required-when-phone is already enforced by validatePhoneWithCode above - + // this only needs to check the create()-specific "at least one identifier" rule. + req.checkBody(['email', 'phone']).custom(() => { + if (!req.body.email && !req.body.phone) { throw new Error('At least one of email or phone must be provided') } - - if (phone && !phone_code) { - throw new Error('phone_code is required when phone is provided') - } - return true }) }, + // Shared "update a user's profile" validation - used here, and reused as-is by + // v1/user.js (update), v1/org-admin.js (updateUser), and v1/tenant.js (accountCreate, + // since that route funnels into the same accountService.create() but can't safely reuse + // create()'s email/username/password rules - see PR discussion). + update: (req) => { + validateName(req, { optional: true }) + validatePhoneWithCode(req) + + req.checkBody('has_accepted_terms_and_conditions') + .optional() + .isBoolean() + .withMessage('has_accepted_terms_and_conditions field is invalid') + req.checkBody('languages').optional().isArray().withMessage('languages is invalid') + req.checkBody('image').optional().isString().withMessage('image field must be string only') + }, + login: (req) => { req.body = filterRequestBody(req.body, account.login) diff --git a/src/validators/v1/org-admin.js b/src/validators/v1/org-admin.js index fe76893d4..37e298994 100644 --- a/src/validators/v1/org-admin.js +++ b/src/validators/v1/org-admin.js @@ -5,6 +5,7 @@ * Description : Validations of admin controller */ const filterRequestBody = require('../common') +const accountValidators = require('./account') const common = require('@constants/common') const { orgAdmin } = require('@constants/blacklistConfig') @@ -58,4 +59,7 @@ module.exports = { req.checkBody('entity_type_value').notEmpty().withMessage('entity_type_value field is empty') req.checkBody('target_entity_type_label').notEmpty().withMessage('target_entity_type_label field is empty') }, + updateUser: (req) => { + accountValidators.update(req) + }, } diff --git a/src/validators/v1/tenant.js b/src/validators/v1/tenant.js index 2a448edf4..5060e9202 100644 --- a/src/validators/v1/tenant.js +++ b/src/validators/v1/tenant.js @@ -6,6 +6,7 @@ */ const common = require('@constants/common') const filterRequestBody = require('../common') +const accountValidators = require('./account') const { tenant } = require('@constants/blacklistConfig') module.exports = { @@ -138,4 +139,13 @@ module.exports = { .custom((value) => allowedTypes.includes(value.toUpperCase())) .withMessage(`upload_type must be one of: ${allowedTypes.join(', ')}`) }, + + accountCreate: (req) => { + // This route calls the same accountService.create() as account.js's create(), which + // previously had no validator entry at all. Reuses account.js's shared update() + // validation (name + phone/phone_code) rather than create() itself, since create()'s + // username/email/password rules don't match what this route actually receives (e.g. + // dotted usernames like "first.last2" are valid here but rejected by create()'s regex). + accountValidators.update(req) + }, } diff --git a/src/validators/v1/user.js b/src/validators/v1/user.js index 0e2575265..d3613e78e 100644 --- a/src/validators/v1/user.js +++ b/src/validators/v1/user.js @@ -5,27 +5,17 @@ * Description : Validations of user controller */ const filterRequestBody = require('../common') +const accountValidators = require('./account') const { user } = require('@constants/blacklistConfig') module.exports = { update: (req) => { - req.checkBody('name') - .trim() - .notEmpty() - .withMessage('name field is empty') - .matches(/^[A-Za-z ]+$/) - .withMessage('This field can only contain alphabets') + accountValidators.update(req) req.checkBody('about') .trim() .notEmpty() .withMessage('about field is empty') .matches(/^[a-zA-Z0-9\-.,\s]+$/) .withMessage('invalid about') - req.checkBody('has_accepted_terms_and_conditions') - .optional() - .isBoolean() - .withMessage('has_accepted_terms_and_conditions field is invalid') - req.checkBody('languages').optional().isArray().withMessage('languages is invalid') - req.checkBody('image').optional().isString().withMessage('image field must be string only') }, share: (req) => { req.checkParams('id').notEmpty().withMessage('id param is empty')