From 1723fc4e8d35f83b4f87f2abc08e3a0d5164a140 Mon Sep 17 00:00:00 2001 From: Sachintechjoomla <92356209+Sachintechjoomla@users.noreply.github.com> Date: Thu, 18 Dec 2025 14:44:50 +0530 Subject: [PATCH 1/8] Issue#251228 Feat: Program User Status Mapping --- controllers/v1/programUsers.js | 448 +++++++++++- databaseQueries/programUsers.js | 393 +++++++++-- document/programUsers/README.md | 430 +++++++++++ document/programUsers/api-response.json | 172 +++++ generics/constants/api-responses.js | 23 + generics/middleware/authenticator.js | 18 +- models/programUsers.js | 166 ++++- module/programUsers/helper.js | 902 +++++++++++++++++++++++- module/programUsers/validator/v1.js | 240 +++++++ 9 files changed, 2690 insertions(+), 102 deletions(-) create mode 100644 document/programUsers/README.md create mode 100644 document/programUsers/api-response.json create mode 100644 module/programUsers/validator/v1.js diff --git a/controllers/v1/programUsers.js b/controllers/v1/programUsers.js index 35d8ef63..c9e2fbda 100644 --- a/controllers/v1/programUsers.js +++ b/controllers/v1/programUsers.js @@ -2,21 +2,447 @@ * name : programUsers.js * author : Ankit Shahu * created-date : 9-Jan-2023 - * Description : PII data related controller. -*/ + * Description : Program Users Controller - CRUD operations for program-user mappings. + */ + +// Dependencies +const programUsersHelper = require(MODULES_BASE_PATH + '/programUsers/helper') /** - * programUsers - * @class + * ProgramUsers + * @class */ module.exports = class ProgramUsers extends Abstract { - constructor() { - super("programUsers"); - } + constructor() { + super('programUsers') + } + + static get name() { + return 'programUsers' + } + + /** + * @api {post} /project/v1/programUsers/create + * @apiVersion 1.0.0 + * @apiName create + * @apiGroup ProgramUsers + * @apiHeader {String} x-auth-token Authenticity token + * @apiSampleRequest /project/v1/programUsers/create + * @apiUse successBody + * @apiUse errorBody + * @apiParamExample {json} Request-Body: + * { + * "programId": "60a5e5d8f1b2c3d4e5f6a7b8", + * "userId": "user-uuid-123", + * "userProfile": { + * "firstName": "John", + * "lastName": "Doe", + * "email": "john@example.com" + * }, + * "userRoleInformation": { + * "role": "teacher" + * }, + * "status": "NOT_ONBOARDED", + * "metadata": {} + * } + * @apiParamExample {json} Response: + * { + * "message": "Program user created successfully", + * "status": 200, + * "result": { + * "_id": "60a5e5d8f1b2c3d4e5f6a7b9", + * "programId": "60a5e5d8f1b2c3d4e5f6a7b8", + * "userId": "user-uuid-123", + * "status": "NOT_ONBOARDED" + * } + * } + */ + + /** + * Create a new program user mapping. + * @method + * @name create + * @param {Object} req - request object. + * @param {Object} req.body - request body data. + * @param {Object} req.userDetails - logged in user details. + * @returns {JSON} - program user creation response. + */ + async create(req) { + return new Promise(async (resolve, reject) => { + try { + const result = await programUsersHelper.create(req.body, req.userDetails) + return resolve(result) + } catch (error) { + return resolve({ + status: error.status || HTTP_STATUS_CODE.internal_server_error.status, + message: error.message || HTTP_STATUS_CODE.internal_server_error.message, + errorObject: error, + }) + } + }) + } + + /** + * @api {patch} /project/v1/programUsers/update/:_id + * @apiVersion 1.0.0 + * @apiName update + * @apiGroup ProgramUsers + * @apiHeader {String} x-auth-token Authenticity token + * @apiSampleRequest /project/v1/programUsers/update/60a5e5d8f1b2c3d4e5f6a7b9 + * @apiUse successBody + * @apiUse errorBody + * @apiParamExample {json} Request-Body: + * { + * "status": "ONBOARDED", + * "userRoleInformation": { + * "role": "mentor" + * } + * } + * @apiParamExample {json} Response: + * { + * "message": "Program user updated successfully", + * "status": 200, + * "result": { + * "_id": "60a5e5d8f1b2c3d4e5f6a7b9", + * "status": "ONBOARDED" + * } + * } + */ + + /** + * Update a program user mapping. + * @method + * @name update + * @param {Object} req - request object. + * @param {String} req.params._id - program user id. + * @param {Object} req.body - request body data. + * @param {Object} req.userDetails - logged in user details. + * @returns {JSON} - program user update response. + */ + async update(req) { + return new Promise(async (resolve, reject) => { + try { + const result = await programUsersHelper.update(req.params._id, req.body, req.userDetails) + return resolve(result) + } catch (error) { + return resolve({ + status: error.status || HTTP_STATUS_CODE.internal_server_error.status, + message: error.message || HTTP_STATUS_CODE.internal_server_error.message, + errorObject: error, + }) + } + }) + } + + /** + * @api {get} /project/v1/programUsers/read/:_id + * @apiVersion 1.0.0 + * @apiName read + * @apiGroup ProgramUsers + * @apiHeader {String} x-auth-token Authenticity token + * @apiSampleRequest /project/v1/programUsers/read/60a5e5d8f1b2c3d4e5f6a7b9 + * @apiUse successBody + * @apiUse errorBody + * @apiParamExample {json} Response: + * { + * "message": "Program user fetched successfully", + * "status": 200, + * "result": { + * "_id": "60a5e5d8f1b2c3d4e5f6a7b9", + * "programId": "60a5e5d8f1b2c3d4e5f6a7b8", + * "userId": "user-uuid-123", + * "status": "NOT_ONBOARDED" + * } + * } + */ + + /** + * Get program user details by ID or by program ID and user ID. + * Handles two patterns: + * - /read/:_id - Get by program user ID + * - /read/:programId/:userId - Get by program ID and user ID + * @method + * @name read + * @param {Object} req - request object. + * @param {String} req.params._id - program user id (pattern 1). + * @param {String} req.params.programId - program id (pattern 2). + * @param {String} req.params.userId - user id (pattern 2). + * @param {Object} req.userDetails - logged in user details. + * @returns {JSON} - program user details. + */ + async read(req) { + return new Promise(async (resolve, reject) => { + try { + // Check if it's a programId:userId pattern + if (req.params.programId && req.params.userId && !req.params._id) { + // Pattern: /read/:programId/:userId + const result = await programUsersHelper.readByProgramAndUserId( + req.params.programId, + req.params.userId, + req.userDetails + ) + return resolve(result) + } else if (req.params._id) { + // Pattern: /read/:_id + const result = await programUsersHelper.read(req.params._id, req.userDetails) + return resolve(result) + } else { + throw { + status: HTTP_STATUS_CODE.bad_request.status, + message: 'Invalid parameters. Use either /:_id or /:programId/:userId', + } + } + } catch (error) { + return resolve({ + status: error.status || HTTP_STATUS_CODE.internal_server_error.status, + message: error.message || HTTP_STATUS_CODE.internal_server_error.message, + errorObject: error, + }) + } + }) + } + + /** + * @api {post} /project/v1/programUsers/list + * @apiVersion 1.0.0 + * @apiName list + * @apiGroup ProgramUsers + * @apiHeader {String} x-auth-token Authenticity token + * @apiSampleRequest /project/v1/programUsers/list?page=1&limit=10&programId=60a5e5d8f1b2c3d4e5f6a7b8 + * @apiUse successBody + * @apiUse errorBody + * @apiParamExample {json} Request-Body: + * { + * "status": "ONBOARDED" + * } + * @apiParamExample {json} Response: + * { + * "message": "Program users fetched successfully", + * "status": 200, + * "result": [...], + * "count": 100, + * "page": 1, + * "limit": 10, + * "totalPages": 10 + * } + */ + + /** + * List program users with filters and pagination. + * @method + * @name list + * @param {Object} req - request object. + * @param {Object} req.query - query parameters. + * @param {Object} req.body - request body for additional filters. + * @param {Object} req.userDetails - logged in user details. + * @returns {JSON} - paginated list of program users. + */ + async list(req) { + return new Promise(async (resolve, reject) => { + try { + const result = await programUsersHelper.list(req.query, req.body, req.userDetails) + return resolve(result) + } catch (error) { + return resolve({ + status: error.status || HTTP_STATUS_CODE.internal_server_error.status, + message: error.message || HTTP_STATUS_CODE.internal_server_error.message, + errorObject: error, + }) + } + }) + } + + /** + * @api {delete} /project/v1/programUsers/delete/:_id + * @apiVersion 1.0.0 + * @apiName delete + * @apiGroup ProgramUsers + * @apiHeader {String} x-auth-token Authenticity token + * @apiSampleRequest /project/v1/programUsers/delete/60a5e5d8f1b2c3d4e5f6a7b9 + * @apiUse successBody + * @apiUse errorBody + * @apiParamExample {json} Response: + * { + * "message": "Program user deleted successfully", + * "status": 200, + * "result": { + * "_id": "60a5e5d8f1b2c3d4e5f6a7b9" + * } + * } + */ + + /** + * Delete a program user mapping. + * Supports both patterns: + * - /delete/:_id - Standard delete by ID + * - DELETE /:_id - Standard REST DELETE + * @method + * @name delete + * @param {Object} req - request object. + * @param {String} req.params._id - program user id. + * @param {Object} req.userDetails - logged in user details. + * @returns {JSON} - deletion response. + */ + async delete(req) { + return new Promise(async (resolve, reject) => { + try { + const result = await programUsersHelper.deleteResource(req.params._id, req.userDetails) + return resolve(result) + } catch (error) { + return resolve({ + status: error.status || HTTP_STATUS_CODE.internal_server_error.status, + message: error.message || HTTP_STATUS_CODE.internal_server_error.message, + errorObject: error, + }) + } + }) + } + + /** + * @api {patch} /project/v1/programUsers/updateStatus/:_id + * @apiVersion 1.0.0 + * @apiName updateStatus + * @apiGroup ProgramUsers + * @apiHeader {String} x-auth-token Authenticity token + * @apiSampleRequest /project/v1/programUsers/updateStatus/60a5e5d8f1b2c3d4e5f6a7b9 + * @apiUse successBody + * @apiUse errorBody + * @apiParamExample {json} Request-Body: + * { + * "status": "GRADUATED", + * "statusReason": "Completed all requirements" + * } + * @apiParamExample {json} Response: + * { + * "message": "Program user status updated successfully", + * "status": 200, + * "result": { + * "_id": "60a5e5d8f1b2c3d4e5f6a7b9", + * "status": "GRADUATED", + * "prevStatus": "COMPLETED" + * } + * } + */ + + /** + * Update program user status. + * @method + * @name updateStatus + * @param {Object} req - request object. + * @param {String} req.params._id - program user id. + * @param {Object} req.body - request body with status. + * @param {Object} req.userDetails - logged in user details. + * @returns {JSON} - status update response. + */ + async updateStatus(req) { + return new Promise(async (resolve, reject) => { + try { + const result = await programUsersHelper.updateStatus(req.params._id, req.body, req.userDetails) + return resolve(result) + } catch (error) { + return resolve({ + status: error.status || HTTP_STATUS_CODE.internal_server_error.status, + message: error.message || HTTP_STATUS_CODE.internal_server_error.message, + errorObject: error, + }) + } + }) + } + + /** + * @api {patch} /project/v1/programUsers/updateMetadata/:_id + * @apiVersion 1.0.0 + * @apiName updateMetadata + * @apiGroup ProgramUsers + * @apiHeader {String} x-auth-token Authenticity token + * @apiSampleRequest /project/v1/programUsers/updateMetadata/60a5e5d8f1b2c3d4e5f6a7b9 + * @apiUse successBody + * @apiUse errorBody + * @apiParamExample {json} Request-Body: + * { + * "metadata": { + * "externalIdOfBoardingCompletionCategory": { + * "templateExternalId": "template-123", + * "tasks": [{ "taskId": "task1", "completed": true }] + * } + * } + * } + * @apiParamExample {json} Response: + * { + * "message": "Program user metadata updated successfully", + * "status": 200, + * "result": {...} + * } + */ - static get name() { - return "programUsers"; - } + /** + * Update program user metadata. + * @method + * @name updateMetadata + * @param {Object} req - request object. + * @param {String} req.params._id - program user id. + * @param {Object} req.body - request body with metadata. + * @param {Object} req.userDetails - logged in user details. + * @returns {JSON} - metadata update response. + */ + async updateMetadata(req) { + return new Promise(async (resolve, reject) => { + try { + const result = await programUsersHelper.updateMetadata(req.params._id, req.body, req.userDetails) + return resolve(result) + } catch (error) { + return resolve({ + status: error.status || HTTP_STATUS_CODE.internal_server_error.status, + message: error.message || HTTP_STATUS_CODE.internal_server_error.message, + errorObject: error, + }) + } + }) + } -}; + /** + * @api {get} /project/v1/programUsers/getByProgramId/:_id + * @apiVersion 1.0.0 + * @apiName getByProgramId + * @apiGroup ProgramUsers + * @apiHeader {String} x-auth-token Authenticity token + * @apiSampleRequest /project/v1/programUsers/getByProgramId/60a5e5d8f1b2c3d4e5f6a7b8?page=1&limit=10&status=ONBOARDED + * @apiUse successBody + * @apiUse errorBody + * @apiParamExample {json} Response: + * { + * "message": "Program users fetched successfully", + * "status": 200, + * "result": [...], + * "count": 50, + * "page": 1, + * "limit": 10, + * "totalPages": 5 + * } + */ + /** + * Get program users by program ID. + * @method + * @name getByProgramId + * @param {Object} req - request object. + * @param {String} req.params._id - program id. + * @param {Object} req.query - query parameters. + * @param {Object} req.userDetails - logged in user details. + * @returns {JSON} - list of program users for a program. + */ + async getByProgramId(req) { + return new Promise(async (resolve, reject) => { + try { + const result = await programUsersHelper.getByProgramId(req.params._id, req.query, req.userDetails) + return resolve(result) + } catch (error) { + return resolve({ + status: error.status || HTTP_STATUS_CODE.internal_server_error.status, + message: error.message || HTTP_STATUS_CODE.internal_server_error.message, + errorObject: error, + }) + } + }) + } +} diff --git a/databaseQueries/programUsers.js b/databaseQueries/programUsers.js index 479b8b8e..d72cbf67 100644 --- a/databaseQueries/programUsers.js +++ b/databaseQueries/programUsers.js @@ -4,49 +4,350 @@ * created-date : 07-04-2023 * Description : program users helper for DB interactions. */ -module.exports = class programUsers { - - /** - * program users details. - * @method - * @name programUsersDocument - * @param {Array} [filterData = "all"] - program users filter query. - * @param {Array} [fieldsArray = "all"] - projected fields. - * @param {Array} [skipFields = "none"] - field not to include - * @returns {Array} program users details. - */ - - static programUsersDocument( - filterData = "all", - fieldsArray = "all", - skipFields = "none" - ) { - return new Promise(async (resolve, reject) => { - try { - - let queryObject = (filterData != "all") ? filterData : {}; - let projection = {} - - if (fieldsArray != "all") { - fieldsArray.forEach(field => { - projection[field] = 1; - }); - } - - if( skipFields !== "none" ) { - skipFields.forEach(field=>{ - projection[field] = 0; - }); - } - - let programJoinedData = await database.models.programUsers - .find(queryObject, projection) - .lean(); - return resolve(programJoinedData); - } catch (error) { - return reject(error); - } - }); - } - -}; + +module.exports = class ProgramUsers { + /** + * Get program users documents. + * @method + * @name programUsersDocument + * @param {Object} [filterData = "all"] - program users filter query. + * @param {Array} [fieldsArray = "all"] - projected fields. + * @param {Array} [skipFields = "none"] - fields not to include. + * @returns {Array} program users details. + */ + static programUsersDocument(filterData = 'all', fieldsArray = 'all', skipFields = 'none') { + return new Promise(async (resolve, reject) => { + try { + let queryObject = filterData != 'all' ? filterData : {} + let projection = {} + + if (fieldsArray != 'all') { + fieldsArray.forEach((field) => { + projection[field] = 1 + }) + } + + if (skipFields !== 'none') { + skipFields.forEach((field) => { + projection[field] = 0 + }) + } + + let programJoinedData = await database.models.programUsers.find(queryObject, projection).lean() + return resolve(programJoinedData) + } catch (error) { + return reject(error) + } + }) + } + + /** + * Create a new program user document. + * @method + * @name create + * @param {Object} data - program user data to create. + * @returns {Object} newly created program user document. + */ + static create(data) { + return new Promise(async (resolve, reject) => { + try { + let programUserDocument = await database.models.programUsers.create(data) + return resolve(programUserDocument) + } catch (error) { + return reject(error) + } + }) + } + + /** + * Find a single program user document. + * @method + * @name findOne + * @param {Object} [filterData = "all"] - program users filter query. + * @param {Array} [fieldsArray = "all"] - projected fields. + * @param {Array} [skipFields = "none"] - fields not to include. + * @returns {Object} program user details. + */ + static findOne(filterData = 'all', fieldsArray = 'all', skipFields = 'none') { + return new Promise(async (resolve, reject) => { + try { + let queryObject = filterData != 'all' ? filterData : {} + let projection = {} + + if (fieldsArray != 'all') { + fieldsArray.forEach((field) => { + projection[field] = 1 + }) + } + + if (skipFields !== 'none') { + skipFields.forEach((field) => { + projection[field] = 0 + }) + } + + let programUserData = await database.models.programUsers.findOne(queryObject, projection).lean() + return resolve(programUserData) + } catch (error) { + return reject(error) + } + }) + } + + /** + * Update a single program user document. + * @method + * @name findOneAndUpdate + * @param {Object} filterData - filter query. + * @param {Object} updateData - data to update. + * @param {Object} [options = { new: true }] - options for the update. + * @returns {Object} updated program user document. + */ + static findOneAndUpdate(filterData, updateData, options = { new: true }) { + return new Promise(async (resolve, reject) => { + try { + let updatedDocument = await database.models.programUsers + .findOneAndUpdate(filterData, updateData, options) + .lean() + return resolve(updatedDocument) + } catch (error) { + return reject(error) + } + }) + } + + /** + * Update multiple program user documents. + * @method + * @name updateMany + * @param {Object} filterData - filter query. + * @param {Object} updateData - data to update. + * @returns {Object} update result. + */ + static updateMany(filterData, updateData) { + return new Promise(async (resolve, reject) => { + try { + let updateResult = await database.models.programUsers.updateMany(filterData, updateData) + return resolve(updateResult) + } catch (error) { + return reject(error) + } + }) + } + + /** + * Delete a single program user document. + * @method + * @name deleteOne + * @param {Object} filterData - filter query. + * @returns {Object} deletion result. + */ + static deleteOne(filterData) { + return new Promise(async (resolve, reject) => { + try { + let deleteResult = await database.models.programUsers.deleteOne(filterData) + return resolve(deleteResult) + } catch (error) { + return reject(error) + } + }) + } + + /** + * Delete multiple program user documents. + * @method + * @name deleteMany + * @param {Object} filterData - filter query. + * @returns {Object} deletion result. + */ + static deleteMany(filterData) { + return new Promise(async (resolve, reject) => { + try { + let deleteResult = await database.models.programUsers.deleteMany(filterData) + return resolve(deleteResult) + } catch (error) { + return reject(error) + } + }) + } + + /** + * Count program user documents. + * @method + * @name count + * @param {Object} [filterData = {}] - filter query. + * @returns {Number} count of documents. + */ + static count(filterData = {}) { + return new Promise(async (resolve, reject) => { + try { + let count = await database.models.programUsers.countDocuments(filterData) + return resolve(count) + } catch (error) { + return reject(error) + } + }) + } + + /** + * Get program users with pagination. + * @method + * @name list + * @param {Object} [filterData = {}] - filter query. + * @param {Array} [fieldsArray = "all"] - projected fields. + * @param {Array} [skipFields = "none"] - fields not to include. + * @param {Number} [page = 1] - page number. + * @param {Number} [limit = 10] - records per page. + * @param {Object} [sortData = { createdAt: -1 }] - sort criteria. + * @returns {Object} paginated program users data with count. + */ + static list( + filterData = {}, + fieldsArray = 'all', + skipFields = 'none', + page = 1, + limit = 10, + sortData = { createdAt: -1 } + ) { + return new Promise(async (resolve, reject) => { + try { + let projection = {} + + if (fieldsArray != 'all') { + fieldsArray.forEach((field) => { + projection[field] = 1 + }) + } + + if (skipFields !== 'none') { + skipFields.forEach((field) => { + projection[field] = 0 + }) + } + + const skip = (page - 1) * limit + + const [data, totalCount] = await Promise.all([ + database.models.programUsers + .find(filterData, projection) + .sort(sortData) + .skip(skip) + .limit(limit) + .lean(), + database.models.programUsers.countDocuments(filterData), + ]) + + return resolve({ + data, + totalCount, + page, + limit, + totalPages: Math.ceil(totalCount / limit), + }) + } catch (error) { + return reject(error) + } + }) + } + + /** + * Get paginated program users using offset-based pagination. + * @method + * @name listWithOffset + * @param {Object} [filterData = {}] - filter query. + * @param {Array} [fieldsArray = "all"] - projected fields. + * @param {Array} [skipFields = "none"] - fields not to include. + * @param {Number} [offset = 0] - number of documents to skip. + * @param {Number} [limit = 10] - number of documents to return. + * @param {Object} [sortData = { createdAt: -1 }] - sort criteria. + * @returns {Object} paginated program users data with count. + */ + static listWithOffset( + filterData = {}, + fieldsArray = 'all', + skipFields = 'none', + offset = 0, + limit = 10, + sortData = { createdAt: -1 } + ) { + return new Promise(async (resolve, reject) => { + try { + let projection = {} + + if (fieldsArray != 'all') { + fieldsArray.forEach((field) => { + projection[field] = 1 + }) + } + + if (skipFields !== 'none') { + skipFields.forEach((field) => { + projection[field] = 0 + }) + } + + const [data, totalCount] = await Promise.all([ + database.models.programUsers + .find(filterData, projection) + .sort(sortData) + .skip(offset) + .limit(limit) + .lean(), + database.models.programUsers.countDocuments(filterData), + ]) + + // Calculate page info for response + const page = Math.floor(offset / limit) + 1 + const totalPages = Math.ceil(totalCount / limit) + + return resolve({ + data, + totalCount, + page, + limit, + offset, + totalPages, + }) + } catch (error) { + return reject(error) + } + }) + } + + /** + * Aggregate program users. + * @method + * @name aggregate + * @param {Array} pipeline - aggregation pipeline. + * @returns {Array} aggregation result. + */ + static aggregate(pipeline) { + return new Promise(async (resolve, reject) => { + try { + let result = await database.models.programUsers.aggregate(pipeline) + return resolve(result) + } catch (error) { + return reject(error) + } + }) + } + + /** + * Bulk write operations for program users. + * @method + * @name bulkWrite + * @param {Array} operations - array of bulk write operations. + * @param {Object} [options = {}] - bulk write options. + * @returns {Object} bulk write result. + */ + static bulkWrite(operations, options = {}) { + return new Promise(async (resolve, reject) => { + try { + let result = await database.models.programUsers.bulkWrite(operations, options) + return resolve(result) + } catch (error) { + return reject(error) + } + }) + } +} diff --git a/document/programUsers/README.md b/document/programUsers/README.md new file mode 100644 index 00000000..b1499d02 --- /dev/null +++ b/document/programUsers/README.md @@ -0,0 +1,430 @@ +# Program Users API Documentation + +## Overview + +The Program Users module provides APIs to manage user-program mappings with status tracking, metadata storage, and hierarchical category tracking. This module is designed to track a user's journey through a program from onboarding to graduation. + +## Table of Contents + +1. [Schema](#schema) +2. [Status Flow](#status-flow) +3. [API Endpoints](#api-endpoints) +4. [Authentication](#authentication) +5. [Sample Requests](#sample-requests) +6. [Error Handling](#error-handling) + +--- + +## Schema + +### Collection: `programUsers` + +| Field | Type | Required | Description | +| --------------------- | -------- | -------- | ------------------------------------------------------------------------- | +| `programId` | ObjectId | Yes | Reference to the program | +| `userId` | String | Yes | User identifier | +| `userProfile` | Object | Yes | User profile information | +| `userRoleInformation` | Object | No | User's role in the program | +| `metadata` | Object | No | Hierarchical category/template/task tracking (optional, empty by default) | +| `appInformation` | Object | No | Application details | +| `consentShared` | Boolean | No | Whether consent is shared (default: false) | +| `resourcesStarted` | Boolean | No | Whether resources are started (default: false) | +| `status` | String | No | User's status in program (default: NOT_ONBOARDED) | +| `prevStatus` | String | No | Previous status for transition tracking | +| `statusReason` | String | No | Reason for status change | +| `tenantId` | String | Yes | Tenant identifier (from token) | +| `orgId` | String | Yes | Organization identifier (from token) | +| `createdBy` | String | Yes | Created by user ID (from token) | +| `updatedBy` | String | Yes | Updated by user ID (from token) | + +### Indexes + +| Index | Type | Purpose | +| ------------------------------ | -------- | ---------------------------- | +| `(userId, programId)` | Unique | Prevent duplicate mappings | +| `(programId, status)` | Compound | Filter by program and status | +| `(tenantId, orgId)` | Compound | Multi-tenancy filtering | +| `(tenantId, orgId, programId)` | Compound | Combined filtering | +| `(createdBy, programId)` | Compound | Filter by creator | +| `(userId, status)` | Compound | Filter user by status | + +--- + +## Status Flow + +### Available Statuses + +``` +NOT_ONBOARDED → ONBOARDED → IN_PROGRESS → COMPLETED → GRADUATED + ↓ ↓ ↓ + DROPPED_OUT DROPPED_OUT DROPPED_OUT +``` + +### Status Values + +| Status | Description | +| --------------- | -------------------------------------------- | +| `NOT_ONBOARDED` | Initial state when user is mapped to program | +| `ONBOARDED` | User has completed onboarding | +| `IN_PROGRESS` | User is actively working on the program | +| `COMPLETED` | User has completed all requirements | +| `GRADUATED` | User has graduated (terminal state) | +| `DROPPED_OUT` | User has dropped out (terminal state) | + +### Transition Rules + +1. **Sequential Progress**: Status must follow the order: `NOT_ONBOARDED → ONBOARDED → IN_PROGRESS → COMPLETED → GRADUATED` +2. **No Rollback**: Cannot transition to a previous status +3. **No Skipping**: Cannot skip intermediate statuses +4. **DROPPED_OUT**: Can transition to `DROPPED_OUT` from any status except `GRADUATED` +5. **Terminal States**: `GRADUATED` and `DROPPED_OUT` are terminal - no further transitions allowed + +### Valid Transitions + +| Current Status | Valid Next Statuses | +| -------------- | ------------------------ | +| NOT_ONBOARDED | ONBOARDED, DROPPED_OUT | +| ONBOARDED | IN_PROGRESS, DROPPED_OUT | +| IN_PROGRESS | COMPLETED, DROPPED_OUT | +| COMPLETED | GRADUATED, DROPPED_OUT | +| GRADUATED | (none - terminal) | +| DROPPED_OUT | (none - terminal) | + +--- + +## API Endpoints + +### Base URL + +``` +/project/v1/programUsers +``` + +### Authentication + +All endpoints require authentication via token in header: + +``` +x-auth-token: +``` + +The token is decoded to extract: + +- `userId` - User identifier +- `tenantId` - Tenant identifier +- `organizationId` (orgId) - Organization identifier + +### Endpoints Summary + +| Method | Endpoint | Description | +| ------ | -------------------------- | --------------------------------------------------- | +| POST | `/create` | Create new program user | +| PATCH | `/update/:_id` | Update program user | +| GET | `/read/:_id` | Get program user by ID | +| GET | `/read/:programId/:userId` | Read particular program user by program and user ID | +| POST | `/list` | List program users with filters | +| DELETE | `/delete/:_id` | Delete program user | +| PATCH | `/updateStatus/:_id` | Update status with validation | +| PATCH | `/updateMetadata/:_id` | Update metadata | +| GET | `/getByProgramId/:_id` | Get users by program ID | + +--- + +## Sample Requests + +### 1. Create Program User + +```bash +POST /project/v1/programUsers/create +Headers: + x-auth-token: + Content-Type: application/json + +Body: +{ + "programId": "507f1f77bcf86cd799439012", + "userId": "user-uuid-123", // Optional - uses token userId if not provided + "userProfile": { + "firstName": "John", + "lastName": "Doe", + "email": "john@example.com" + }, + "userRoleInformation": { + "role": "teacher" + }, + "status": "NOT_ONBOARDED", // Optional - defaults to NOT_ONBOARDED + "metadata": {} // Optional - empty by default +} + +Response: +{ + "success": true, + "message": "Program user created successfully", + "result": { + "_id": "507f1f77bcf86cd799439011", + "programId": "507f1f77bcf86cd799439012", + "userId": "user-uuid-123", + "status": "NOT_ONBOARDED", + ... + } +} +``` + +### 2. Update Status + +```bash +PATCH /project/v1/programUsers/updateStatus/507f1f77bcf86cd799439011 +Headers: + x-auth-token: + Content-Type: application/json + +Body: +{ + "status": "ONBOARDED", + "statusReason": "Completed onboarding process" +} + +Response (Success): +{ + "success": true, + "message": "Program user status updated successfully", + "result": { + "_id": "507f1f77bcf86cd799439011", + "status": "ONBOARDED", + "prevStatus": "NOT_ONBOARDED", + "validNextStatuses": ["IN_PROGRESS", "DROPPED_OUT"], + ... + } +} + +Response (Invalid Transition): +{ + "success": false, + "message": "Cannot skip status. Current: NOT_ONBOARDED, Expected next: ONBOARDED, Attempted: COMPLETED", + "data": { + "currentStatus": "NOT_ONBOARDED", + "attemptedStatus": "COMPLETED", + "validNextStatuses": ["ONBOARDED", "DROPPED_OUT"], + "statusFlow": "NOT_ONBOARDED → ONBOARDED → IN_PROGRESS → COMPLETED → GRADUATED (DROPPED_OUT from any except GRADUATED)" + } +} +``` + +### 3. List Program Users + +This works for offset also + +```bash +POST /project/v1/programUsers/list?page=1&limit=10&programId=xxx&status=ONBOARDED +Headers: + x-auth-token: + +Query Parameters: + - page: Page number (default: 1) + - limit: Items per page (default: 10, max: 100) + - programId: Filter by program ID + - userId: Filter by user ID + - status: Filter by status + - createdBy: Filter by creator + - orgId: Filter by organization + +Response: +{ + "success": true, + "message": "Program users fetched successfully", + "result": [...], + "count": 100, + "totalCount": 100, + "page": 1, + "limit": 10, + "totalPages": 10 +} +``` + +### 4. Update Metadata + +```bash +PATCH /project/v1/programUsers/updateMetadata/507f1f77bcf86cd799439011 +Headers: + x-auth-token: + Content-Type: application/json + +Body: +{ + "metadata": { + "externalIdOfBoardingCompletionCategory": { + "templateExternalId": "onboarding-template-001", + "tasks": [ + { "taskId": "task-001", "completed": true, "completedAt": "2024-12-18T10:00:00Z" } + ] + }, + "observationInfo": { + "midlineSurveyCount": 1, + "midlineSurveyComplete": true + } + } +} + +Response: +{ + "success": true, + "message": "Program user metadata updated successfully", + "result": {...} +} +``` + +### 5. Delete Program User (Standard DELETE Pattern) + +```bash +DELETE /project/v1/programUsers/delete/507f1f77bcf86cd799439011 +Headers: + x-auth-token: + +Path Parameters: + - _id: Program user ID to delete + +Response (Success): +{ + "success": true, + "message": "Program user resource deleted successfully", + "result": { + "_id": "507f1f77bcf86cd799439011", + "deletedAt": "2024-12-18T10:30:00Z" + } +} + +Response (Not Found): +{ + "success": false, + "status": 404, + "message": "Program user not found or already deleted" +} +``` + +--- + +## Metadata Structure + +The `metadata` field supports hierarchical tracking of categories, templates, and tasks: + +```javascript +{ + "metadata": { + // Level 0 Category - Onboarding + "externalIdOfBoardingCompletionCategory": { + "templateExternalId": "template-123", + "tasks": [ + { "taskId": "ObjectId", "completed": true, "completedAt": "ISO Date" } + ] + }, + + // Level 0 Category - Pathways (with nested subcategories) + "externalIdPathwayCategory": { + "templateExternalId": "template-456", + "tasks": [...], + + // Level 1 - Social Empowerment + "externalIdSocialEmpowermentCategory": { + "templateExternalId": "template-789", + "tasks": [...] + }, + + // Level 1 - Livelihoods (with nested) + "externalIdLivelihoodsCategory": { + "templateExternalId": "template-abc", + "tasks": [...], + + // Level 2 - Agriculture + "externalIdAgricultureCategory": { + "templateExternalId": "template-def", + "tasks": [...], + + // Level 3 - Crop Farming + "externalIdCropFarmingCategory": { + "templateExternalId": "template-ghi", + "tasks": [...] + } + } + } + }, + + // Observation Info + "observationInfo": { + "midlineSurveyCount": 0, + "midlineSurveyComplete": false, + "graduationReadinessSurveyComplete": false, + "endlineSurveyComplete": false + }, + + // Certificate Info + "certificateInfo": { + "certificateIssued": false, + "certificateIssuedDate": null + } + } +} +``` + +--- + +## Error Handling + +### Common Error Responses + +| Status Code | Message | Description | +| ----------- | ----------------------------------- | ---------------------------- | +| 400 | Program user ID is required | Missing \_id parameter | +| 400 | User already exists in this program | Duplicate userId + programId | +| 400 | Invalid status transition | Status rules violated | +| 404 | Program user not found | No matching document | +| 500 | Internal server error | Unexpected error | + +### Status Transition Errors + +```json +{ + "success": false, + "status": 400, + "message": "Rollback not allowed. Cannot transition from ONBOARDED to NOT_ONBOARDED. Status must progress forward.", + "data": { + "currentStatus": "ONBOARDED", + "attemptedStatus": "NOT_ONBOARDED", + "validNextStatuses": ["IN_PROGRESS", "DROPPED_OUT"] + } +} +``` + +--- + +## Files Structure + +``` +project-service/ +├── models/ +│ └── programUsers.js # Schema definition +├── databaseQueries/ +│ └── programUsers.js # Database operations +├── module/ +│ └── programUsers/ +│ ├── helper.js # Business logic +│ └── validator/ +│ └── v1.js # Request validation +├── controllers/ +│ └── v1/ +│ └── programUsers.js # API endpoints +├── generics/ +│ └── constants/ +│ └── api-responses.js # Response messages +└── document/ + └── programUsers/ + └── README.md # This documentation +``` + +--- + +## Version History + +| Version | Date | Changes | +| ------- | ---------- | --------------------------------------------------------------------- | +| 1.1.0 | 2024-12-18 | Added readByProgramAndUserId and deleteResource APIs | +| 1.0.0 | 2024-12-18 | Initial implementation with CRUD, status validation, metadata support | diff --git a/document/programUsers/api-response.json b/document/programUsers/api-response.json new file mode 100644 index 00000000..08c7f6fd --- /dev/null +++ b/document/programUsers/api-response.json @@ -0,0 +1,172 @@ +{ + "info": { + "name": "ProgramUsers", + "_postman_id": "23240481-6fd873d2-34ce-4587-8f77-749fc33bf587", + "description": "", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + }, + "item": [ + { + "name": "Create", + "request": { + "method": "POST", + "header": [ + { + "key": "x-auth-token", + "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJkYXRhIjp7ImlkIjoyMDAzLCJuYW1lIjoidGFuZnVuY29mZmljaWFsIHNsZGlyZWN0b3IiLCJzZXNzaW9uX2lkIjoyMjcxMCwib3JnYW5pemF0aW9uX2lkcyI6WyIzMyJdLCJvcmdhbml6YXRpb25fY29kZXMiOlsidGFuOTAiXSwidGVuYW50X2NvZGUiOiJzaGlrc2hhbG9rYW0iLCJvcmdhbml6YXRpb25zIjpbeyJpZCI6MzMsIm5hbWUiOiJ0YW45MCIsImNvZGUiOiJ0YW45MCIsImRlc2NyaXB0aW9uIjoiVGFuOTAgc3BlY2lhbGl6ZXMgaW4gcHJvdmlkaW5nIGVkdWNhdGlvbmFsIFNURUFNIiwic3RhdHVzIjoiQUNUSVZFIiwicmVsYXRlZF9vcmdzIjpbMzRdLCJ0ZW5hbnRfY29kZSI6InNoaWtzaGFsb2thbSIsIm1ldGEiOm51bGwsImNyZWF0ZWRfYnkiOjEsInVwZGF0ZWRfYnkiOjE3MDksInJvbGVzIjpbeyJpZCI6MjMsInRpdGxlIjoibWVudGVlIiwibGFiZWwiOiJtZW50ZWUiLCJ1c2VyX3R5cGUiOjAsInN0YXR1cyI6IkFDVElWRSIsIm9yZ2FuaXphdGlvbl9pZCI6MTAsInZpc2liaWxpdHkiOiJQVUJMSUMiLCJ0ZW5hbnRfY29kZSI6InNoaWtzaGFsb2thbSIsInRyYW5zbGF0aW9ucyI6bnVsbH1dfV19LCJpYXQiOjE3NjYwMzk5MDcsImV4cCI6MTc2NjEyNjMwN30.l5Iawv9_Qky-qxFIWNl8BQMvE_RR3niAYxxHOpWYTeQ", + "type": "text" + }, + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"programId\": \"507f1f77bcf86cd799439012\",\n // Optional - uses token userId if not provided\n \"userProfile\": {\n \"firstName\": \"John\",\n \"lastName\": \"Doe\",\n \"email\": \"john@example.com\"\n },\n \"userRoleInformation\": {\n \"role\": \"teacher\"\n },\n \"status\": \"NOT_ONBOARDED\", // Optional - defaults to NOT_ONBOARDED\n \"metadata\": {} // Optional - empty by default\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}project/v1/programUsers/create", + "host": ["{{baseUrl}}project"], + "path": ["v1", "programUsers", "create"] + } + }, + "response": [] + }, + { + "name": "updateStatus", + "request": { + "method": "POST", + "header": [ + { + "key": "x-auth-token", + "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJkYXRhIjp7ImlkIjoyMDAzLCJuYW1lIjoidGFuZnVuY29mZmljaWFsIHNsZGlyZWN0b3IiLCJzZXNzaW9uX2lkIjoyMjcxMCwib3JnYW5pemF0aW9uX2lkcyI6WyIzMyJdLCJvcmdhbml6YXRpb25fY29kZXMiOlsidGFuOTAiXSwidGVuYW50X2NvZGUiOiJzaGlrc2hhbG9rYW0iLCJvcmdhbml6YXRpb25zIjpbeyJpZCI6MzMsIm5hbWUiOiJ0YW45MCIsImNvZGUiOiJ0YW45MCIsImRlc2NyaXB0aW9uIjoiVGFuOTAgc3BlY2lhbGl6ZXMgaW4gcHJvdmlkaW5nIGVkdWNhdGlvbmFsIFNURUFNIiwic3RhdHVzIjoiQUNUSVZFIiwicmVsYXRlZF9vcmdzIjpbMzRdLCJ0ZW5hbnRfY29kZSI6InNoaWtzaGFsb2thbSIsIm1ldGEiOm51bGwsImNyZWF0ZWRfYnkiOjEsInVwZGF0ZWRfYnkiOjE3MDksInJvbGVzIjpbeyJpZCI6MjMsInRpdGxlIjoibWVudGVlIiwibGFiZWwiOiJtZW50ZWUiLCJ1c2VyX3R5cGUiOjAsInN0YXR1cyI6IkFDVElWRSIsIm9yZ2FuaXphdGlvbl9pZCI6MTAsInZpc2liaWxpdHkiOiJQVUJMSUMiLCJ0ZW5hbnRfY29kZSI6InNoaWtzaGFsb2thbSIsInRyYW5zbGF0aW9ucyI6bnVsbH1dfV19LCJpYXQiOjE3NjYwMzk5MDcsImV4cCI6MTc2NjEyNjMwN30.l5Iawv9_Qky-qxFIWNl8BQMvE_RR3niAYxxHOpWYTeQ", + "type": "text" + }, + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"status\": \"ONBOARDED\",\n \"statusReason\": \"Completed onboarding process\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}project/v1/programUsers/updateStatus/6943aad3ccd64511455af78f", + "host": ["{{baseUrl}}project"], + "path": ["v1", "programUsers", "updateStatus", "6943aad3ccd64511455af78f"] + } + }, + "response": [] + }, + { + "name": "List", + "request": { + "method": "POST", + "header": [ + { + "key": "x-auth-token", + "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJkYXRhIjp7ImlkIjoyMDAzLCJuYW1lIjoidGFuZnVuY29mZmljaWFsIHNsZGlyZWN0b3IiLCJzZXNzaW9uX2lkIjoyMjcxMCwib3JnYW5pemF0aW9uX2lkcyI6WyIzMyJdLCJvcmdhbml6YXRpb25fY29kZXMiOlsidGFuOTAiXSwidGVuYW50X2NvZGUiOiJzaGlrc2hhbG9rYW0iLCJvcmdhbml6YXRpb25zIjpbeyJpZCI6MzMsIm5hbWUiOiJ0YW45MCIsImNvZGUiOiJ0YW45MCIsImRlc2NyaXB0aW9uIjoiVGFuOTAgc3BlY2lhbGl6ZXMgaW4gcHJvdmlkaW5nIGVkdWNhdGlvbmFsIFNURUFNIiwic3RhdHVzIjoiQUNUSVZFIiwicmVsYXRlZF9vcmdzIjpbMzRdLCJ0ZW5hbnRfY29kZSI6InNoaWtzaGFsb2thbSIsIm1ldGEiOm51bGwsImNyZWF0ZWRfYnkiOjEsInVwZGF0ZWRfYnkiOjE3MDksInJvbGVzIjpbeyJpZCI6MjMsInRpdGxlIjoibWVudGVlIiwibGFiZWwiOiJtZW50ZWUiLCJ1c2VyX3R5cGUiOjAsInN0YXR1cyI6IkFDVElWRSIsIm9yZ2FuaXphdGlvbl9pZCI6MTAsInZpc2liaWxpdHkiOiJQVUJMSUMiLCJ0ZW5hbnRfY29kZSI6InNoaWtzaGFsb2thbSIsInRyYW5zbGF0aW9ucyI6bnVsbH1dfV19LCJpYXQiOjE3NjYwMzk5MDcsImV4cCI6MTc2NjEyNjMwN30.l5Iawv9_Qky-qxFIWNl8BQMvE_RR3niAYxxHOpWYTeQ", + "type": "text" + }, + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "http://localhost:5003/project/v1/programUsers/list?offset=0&limit=10&userId=2003", + "protocol": "http", + "host": ["localhost"], + "port": "5003", + "path": ["project", "v1", "programUsers", "list"], + "query": [ + { + "key": "offset", + "value": "0" + }, + { + "key": "limit", + "value": "10" + }, + { + "key": "userId", + "value": "2003" + } + ] + } + }, + "response": [] + }, + { + "name": "Read", + "request": { + "method": "POST", + "header": [ + { + "key": "x-auth-token", + "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJkYXRhIjp7ImlkIjoyMDAzLCJuYW1lIjoidGFuZnVuY29mZmljaWFsIHNsZGlyZWN0b3IiLCJzZXNzaW9uX2lkIjoyMjcxMCwib3JnYW5pemF0aW9uX2lkcyI6WyIzMyJdLCJvcmdhbml6YXRpb25fY29kZXMiOlsidGFuOTAiXSwidGVuYW50X2NvZGUiOiJzaGlrc2hhbG9rYW0iLCJvcmdhbml6YXRpb25zIjpbeyJpZCI6MzMsIm5hbWUiOiJ0YW45MCIsImNvZGUiOiJ0YW45MCIsImRlc2NyaXB0aW9uIjoiVGFuOTAgc3BlY2lhbGl6ZXMgaW4gcHJvdmlkaW5nIGVkdWNhdGlvbmFsIFNURUFNIiwic3RhdHVzIjoiQUNUSVZFIiwicmVsYXRlZF9vcmdzIjpbMzRdLCJ0ZW5hbnRfY29kZSI6InNoaWtzaGFsb2thbSIsIm1ldGEiOm51bGwsImNyZWF0ZWRfYnkiOjEsInVwZGF0ZWRfYnkiOjE3MDksInJvbGVzIjpbeyJpZCI6MjMsInRpdGxlIjoibWVudGVlIiwibGFiZWwiOiJtZW50ZWUiLCJ1c2VyX3R5cGUiOjAsInN0YXR1cyI6IkFDVElWRSIsIm9yZ2FuaXphdGlvbl9pZCI6MTAsInZpc2liaWxpdHkiOiJQVUJMSUMiLCJ0ZW5hbnRfY29kZSI6InNoaWtzaGFsb2thbSIsInRyYW5zbGF0aW9ucyI6bnVsbH1dfV19LCJpYXQiOjE3NjYwMzk5MDcsImV4cCI6MTc2NjEyNjMwN30.l5Iawv9_Qky-qxFIWNl8BQMvE_RR3niAYxxHOpWYTeQ", + "type": "text" + }, + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "http://localhost:5003/project/v1/programUsers/read/6943aad3ccd64511455af78f", + "protocol": "http", + "host": ["localhost"], + "port": "5003", + "path": ["project", "v1", "programUsers", "read", "6943aad3ccd64511455af78f"] + } + }, + "response": [ + { + "name": "New Request", + "originalRequest": { + "method": "POST", + "header": [ + { + "key": "x-auth-token", + "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJkYXRhIjp7ImlkIjoyMDAzLCJuYW1lIjoidGFuZnVuY29mZmljaWFsIHNsZGlyZWN0b3IiLCJzZXNzaW9uX2lkIjoyMjcxMCwib3JnYW5pemF0aW9uX2lkcyI6WyIzMyJdLCJvcmdhbml6YXRpb25fY29kZXMiOlsidGFuOTAiXSwidGVuYW50X2NvZGUiOiJzaGlrc2hhbG9rYW0iLCJvcmdhbml6YXRpb25zIjpbeyJpZCI6MzMsIm5hbWUiOiJ0YW45MCIsImNvZGUiOiJ0YW45MCIsImRlc2NyaXB0aW9uIjoiVGFuOTAgc3BlY2lhbGl6ZXMgaW4gcHJvdmlkaW5nIGVkdWNhdGlvbmFsIFNURUFNIiwic3RhdHVzIjoiQUNUSVZFIiwicmVsYXRlZF9vcmdzIjpbMzRdLCJ0ZW5hbnRfY29kZSI6InNoaWtzaGFsb2thbSIsIm1ldGEiOm51bGwsImNyZWF0ZWRfYnkiOjEsInVwZGF0ZWRfYnkiOjE3MDksInJvbGVzIjpbeyJpZCI6MjMsInRpdGxlIjoibWVudGVlIiwibGFiZWwiOiJtZW50ZWUiLCJ1c2VyX3R5cGUiOjAsInN0YXR1cyI6IkFDVElWRSIsIm9yZ2FuaXphdGlvbl9pZCI6MTAsInZpc2liaWxpdHkiOiJQVUJMSUMiLCJ0ZW5hbnRfY29kZSI6InNoaWtzaGFsb2thbSIsInRyYW5zbGF0aW9ucyI6bnVsbH1dfV19LCJpYXQiOjE3NjYwMzk5MDcsImV4cCI6MTc2NjEyNjMwN30.l5Iawv9_Qky-qxFIWNl8BQMvE_RR3niAYxxHOpWYTeQ", + "type": "text" + }, + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "url": { + "raw": "http://localhost:5003/project/v1/programUsers/read/6943aad3ccd64511455af78f", + "protocol": "http", + "host": ["localhost"], + "port": "5003", + "path": ["project", "v1", "programUsers", "read", "6943aad3ccd64511455af78f"] + } + }, + "_postman_previewlanguage": "json", + "header": [], + "cookie": [], + "body": "" + } + ] + } + ] +} diff --git a/generics/constants/api-responses.js b/generics/constants/api-responses.js index 216e3eb3..11942d46 100644 --- a/generics/constants/api-responses.js +++ b/generics/constants/api-responses.js @@ -322,4 +322,27 @@ module.exports = { ACCESS_TOKEN_EXPIRED: 'Access Token Expired!! Please Login Again.', USER_SERVICE_DOWN_CODE: 'USER_SERVICE_DOWN', USER_SERVICE_DOWN: 'User service is down', + + // Program Users API Responses + PROGRAM_USER_CREATED: 'Program user created successfully', + PROGRAM_USER_NOT_CREATED: 'Failed to create program user', + PROGRAM_USER_UPDATED: 'Program user updated successfully', + PROGRAM_USER_NOT_UPDATED: 'Failed to update program user', + PROGRAM_USER_FETCHED: 'Program user fetched successfully', + PROGRAM_USER_NOT_FOUND: 'Program user not found', + PROGRAM_USER_DELETED: 'Program user deleted successfully', + PROGRAM_USER_NOT_DELETED: 'Failed to delete program user', + PROGRAM_USERS_FETCHED: 'Program users fetched successfully', + PROGRAM_USER_ALREADY_EXISTS: 'User already exists in this program', + PROGRAM_USER_ID_REQUIRED: 'Program user ID is required', + PROGRAM_ID_REQUIRED: 'Program ID is required', + STATUS_REQUIRED: 'Status is required', + METADATA_REQUIRED: 'Metadata is required', + PROGRAM_USER_STATUS_UPDATED: 'Program user status updated successfully', + PROGRAM_USER_STATUS_NOT_UPDATED: 'Failed to update program user status', + PROGRAM_USER_METADATA_UPDATED: 'Program user metadata updated successfully', + PROGRAM_USER_METADATA_NOT_UPDATED: 'Failed to update program user metadata', + STATUS_FLOW_FETCHED: 'Status flow fetched successfully', + MOCK_DATA_GENERATED: 'Mock data generated successfully', + INVALID_STATUS_TRANSITION: 'Invalid status transition', } diff --git a/generics/middleware/authenticator.js b/generics/middleware/authenticator.js index 33b9a9a8..d5aaf0d3 100644 --- a/generics/middleware/authenticator.js +++ b/generics/middleware/authenticator.js @@ -173,9 +173,21 @@ module.exports = async function (req, res, next, token = '') { // If using native authentication, verify the JWT using the secret key decodedToken = jwt.verify(token, process.env.ACCESS_TOKEN_SECRET) } catch (err) { - // If verification fails, send an unauthorized response - rspObj.errCode = CONSTANTS.apiResponses.TOKEN_MISSING_CODE - rspObj.errMsg = CONSTANTS.apiResponses.TOKEN_MISSING_MESSAGE + // If verification fails, send an unauthorized response with clear error message + // Set appropriate error message based on error type + if (err.name === 'TokenExpiredError') { + rspObj.errCode = 'ERR_TOKEN_EXPIRED' + rspObj.errMsg = 'Token has expired. Please login again to get a new token.' + } else if (err.name === 'JsonWebTokenError') { + rspObj.errCode = 'ERR_TOKEN_INVALID' + rspObj.errMsg = `Invalid token: ${err.message}. Please check ACCESS_TOKEN_SECRET matches the secret used to sign this token.` + } else if (err.name === 'NotBeforeError') { + rspObj.errCode = 'ERR_TOKEN_NOT_ACTIVE' + rspObj.errMsg = 'Token is not yet active.' + } else { + rspObj.errCode = 'ERR_TOKEN_VERIFICATION_FAILED' + rspObj.errMsg = `Token verification failed: ${err.message}` + } rspObj.responseCode = HTTP_STATUS_CODE['unauthorized'].status return res.status(HTTP_STATUS_CODE['unauthorized'].status).send(respUtil(rspObj)) } diff --git a/models/programUsers.js b/models/programUsers.js index b276a3b8..09a42e82 100644 --- a/models/programUsers.js +++ b/models/programUsers.js @@ -1,38 +1,130 @@ +/** + * name : programUsers.js + * author : Ankit Shahu + * created-date : 9-Jan-2023 + * Description : Program users schema with status tracking and hierarchical metadata + */ + module.exports = { - name: "programUsers", - schema: { - programId: { - type : "ObjectId", - required: true, - index: true - }, - userId: { - type: String, - index: true, - required: true, - }, - resourcesStarted: { - type: Boolean, - index: true, - default: false - }, - userProfile: { - type : Object, - required: true - }, - userRoleInformation: Object, - appInformation: Object, - consentShared: { - type: Boolean, - default: false - } - }, - compoundIndex: [ - { - "name" :{ userId: 1, programId: 1 }, - "indexType" : { unique: true } - } - ] -}; - - \ No newline at end of file + name: 'programUsers', + schema: { + programId: { + type: 'ObjectId', + required: true, + index: true, + }, + userId: { + type: String, + required: true, + index: true, + }, + resourcesStarted: { + type: Boolean, + default: false, + index: true, + }, + userProfile: { + type: Object, + required: true, + }, + userRoleInformation: { + type: Object, + }, + + // Unified hierarchical metadata structure (project categories → templates → tasks) + // Optional: If passed during creation, use it; otherwise keep empty + metadata: { + type: Object, + default: {}, + }, + + appInformation: { + type: Object, + }, + consentShared: { + type: Boolean, + default: false, + }, + + // User status in the program + status: { + type: String, + enum: ['NOT_ONBOARDED', 'ONBOARDED', 'IN_PROGRESS', 'COMPLETED', 'GRADUATED', 'DROPPED_OUT'], + default: 'NOT_ONBOARDED', + index: true, + }, + + // Previous status for tracking status transitions + prevStatus: { + type: String, + default: null, + }, + + // Reason for status change (e.g., dropout reason) + statusReason: { + type: String, + default: null, + }, + + // Created by user ID + createdBy: { + type: String, + required: true, + index: true, + }, + + // Updated by user ID + updatedBy: { + type: String, + required: true, + index: true, + }, + + // Tenant ID for multi-tenancy + tenantId: { + type: String, + required: true, + index: true, + }, + + // Organization ID + orgId: { + type: String, + required: true, + index: true, + }, + }, + + compoundIndex: [ + // Unique constraint on userId + programId combination + { + name: { userId: 1, programId: 1 }, + indexType: { unique: true }, + }, + // For filtering by program and status + { + name: { programId: 1, status: 1 }, + indexType: {}, + }, + // For filtering by tenant and org + { + name: { tenantId: 1, orgId: 1 }, + indexType: {}, + }, + // For filtering by tenant, org and program + { + name: { tenantId: 1, orgId: 1, programId: 1 }, + indexType: {}, + }, + // For filtering by createdBy + { + name: { createdBy: 1, programId: 1 }, + indexType: {}, + }, + // For filtering by userId and status + { + name: { userId: 1, status: 1 }, + indexType: {}, + }, + ], +} diff --git a/module/programUsers/helper.js b/module/programUsers/helper.js index 809a496f..41adb134 100644 --- a/module/programUsers/helper.js +++ b/module/programUsers/helper.js @@ -6,24 +6,126 @@ */ // Dependencies +const programUsersQueries = require(DB_QUERY_BASE_PATH + '/programUsers') +const ObjectId = require('mongodb').ObjectID + +/** + * Status order for validation + * Status can only progress forward, no rollback allowed + * DROPPED_OUT can happen from any state except GRADUATED + */ +const STATUS_ORDER = ['NOT_ONBOARDED', 'ONBOARDED', 'IN_PROGRESS', 'COMPLETED', 'GRADUATED', 'DROPPED_OUT'] /** * ProgramUsersHelper * @class */ -const programUsersQueries = require(DB_QUERY_BASE_PATH + '/programUsers') - module.exports = class ProgramUsersHelper { /** - * check if user joined a program or not and consentShared + * Validate status transition + * Rules: + * 1. Status must follow order: NOT_ONBOARDED → ONBOARDED → IN_PROGRESS → COMPLETED → GRADUATED + * 2. No rollback allowed (can't go backwards) + * 3. Can't skip states (e.g., NOT_ONBOARDED can't go directly to COMPLETED) + * 4. DROPPED_OUT can happen from any state except GRADUATED and DROPPED_OUT + * 5. Once GRADUATED or DROPPED_OUT, no further transitions allowed + * @method + * @name validateStatusTransition + * @param {String} currentStatus - Current status + * @param {String} newStatus - New status to transition to + * @returns {Object} { valid: boolean, message: string } + */ + static validateStatusTransition(currentStatus, newStatus) { + // If same status, no transition needed + if (currentStatus === newStatus) { + return { valid: true, message: 'No status change' } + } + + const currentIndex = STATUS_ORDER.indexOf(currentStatus) + const newIndex = STATUS_ORDER.indexOf(newStatus) + + // Invalid status values + if (currentIndex === -1 || newIndex === -1) { + return { valid: false, message: 'Invalid status value' } + } + + // DROPPED_OUT and GRADUATED are terminal states - no further transitions + if (currentStatus === 'DROPPED_OUT') { + return { valid: false, message: 'Cannot transition from DROPPED_OUT. It is a terminal state.' } + } + + if (currentStatus === 'GRADUATED') { + return { valid: false, message: 'Cannot transition from GRADUATED. It is a terminal state.' } + } + + // Allow DROPPED_OUT from any state except GRADUATED (already handled above) + if (newStatus === 'DROPPED_OUT') { + return { valid: true, message: 'Status transition to DROPPED_OUT is valid' } + } + + // Cannot transition to DROPPED_OUT as it's handled above, so check normal flow + // Exclude DROPPED_OUT from normal flow checks + const normalFlowStatuses = STATUS_ORDER.slice(0, 5) // Exclude DROPPED_OUT + const currentNormalIndex = normalFlowStatuses.indexOf(currentStatus) + const newNormalIndex = normalFlowStatuses.indexOf(newStatus) + + // Rollback not allowed + if (newNormalIndex < currentNormalIndex) { + return { + valid: false, + message: `Rollback not allowed. Cannot transition from ${currentStatus} to ${newStatus}. Status must progress forward.`, + } + } + + // Skip not allowed - must be exactly next status + if (newNormalIndex !== currentNormalIndex + 1) { + const expectedNextStatus = normalFlowStatuses[currentNormalIndex + 1] + return { + valid: false, + message: `Cannot skip status. Current: ${currentStatus}, Expected next: ${expectedNextStatus}, Attempted: ${newStatus}`, + } + } + + return { valid: true, message: 'Status transition is valid' } + } + + /** + * Get valid next statuses for a given current status + * @method + * @name getValidNextStatuses + * @param {String} currentStatus - Current status + * @returns {Array} Array of valid next statuses + */ + static getValidNextStatuses(currentStatus) { + const normalFlowStatuses = STATUS_ORDER.slice(0, 5) // Exclude DROPPED_OUT + const currentIndex = normalFlowStatuses.indexOf(currentStatus) + + if (currentStatus === 'DROPPED_OUT' || currentStatus === 'GRADUATED') { + return [] // Terminal states + } + + const validStatuses = [] + + // Next status in normal flow + if (currentIndex < normalFlowStatuses.length - 1) { + validStatuses.push(normalFlowStatuses[currentIndex + 1]) + } + + // DROPPED_OUT is always valid (except from GRADUATED which is handled above) + validStatuses.push('DROPPED_OUT') + + return validStatuses + } + + /** + * Check if user joined a program or not and consentShared * @method * @name checkForUserJoinedProgramAndConsentShared * @param {String} programId - Program Id. * @param {String} userId - User Id * @returns {Object} result. */ - static checkForUserJoinedProgramAndConsentShared(programId, userId) { return new Promise(async (resolve, reject) => { try { @@ -33,7 +135,7 @@ module.exports = class ProgramUsersHelper { programId: programId, } - //Check data present in programUsers collection. + // Check data present in programUsers collection. let programUsers = await programUsersQueries.programUsersDocument(query, ['_id', 'consentShared']) result.joinProgram = programUsers.length > 0 ? true : false result.consentShared = programUsers.length > 0 ? programUsers[0].consentShared : false @@ -43,4 +145,794 @@ module.exports = class ProgramUsersHelper { } }) } + + /** + * Create a new program user mapping. + * userId, tenantId, orgId are extracted from the decoded token (userDetails) + * @method + * @name create + * @param {Object} bodyData - request body data. + * @param {Object} userDetails - logged in user details (from decoded token). + * @returns {Object} created program user document. + */ + static create(bodyData, userDetails) { + return new Promise(async (resolve, reject) => { + try { + // Extract userId, tenantId, orgId from decoded token + const tokenUserId = userDetails.userInformation.userId + const tenantId = userDetails.userInformation.tenantId + const orgId = userDetails.userInformation.organizationId + + // Use userId from body if provided, else use from token + const userId = bodyData.userId || tokenUserId + + // Prepare program user data + const programUserData = { + programId: ObjectId(bodyData.programId), + userId: userId, + userProfile: bodyData.userProfile, + userRoleInformation: bodyData.userRoleInformation || {}, + appInformation: bodyData.appInformation || {}, + consentShared: bodyData.consentShared || false, + resourcesStarted: bodyData.resourcesStarted || false, + status: bodyData.status || 'NOT_ONBOARDED', + prevStatus: null, + statusReason: bodyData.statusReason || null, + // Metadata is optional - if passed use it, else keep empty + metadata: bodyData.metadata || {}, + tenantId: tenantId, + orgId: orgId, + createdBy: tokenUserId, + updatedBy: tokenUserId, + } + + // Check if user already exists in this program + const existingUser = await programUsersQueries.findOne({ + userId: userId, + programId: ObjectId(bodyData.programId), + }) + + if (existingUser && existingUser._id) { + return resolve({ + success: false, + status: HTTP_STATUS_CODE.bad_request.status, + message: CONSTANTS.apiResponses.PROGRAM_USER_ALREADY_EXISTS, + }) + } + + // Create the program user document + const createdProgramUser = await programUsersQueries.create(programUserData) + + if (!createdProgramUser || !createdProgramUser._id) { + return resolve({ + success: false, + status: HTTP_STATUS_CODE.bad_request.status, + message: CONSTANTS.apiResponses.PROGRAM_USER_NOT_CREATED, + }) + } + + return resolve({ + success: true, + message: CONSTANTS.apiResponses.PROGRAM_USER_CREATED, + data: createdProgramUser, + result: createdProgramUser, + }) + } catch (error) { + return resolve({ + success: false, + message: error.message, + status: HTTP_STATUS_CODE.internal_server_error.status, + }) + } + }) + } + + /** + * Update a program user mapping. + * @method + * @name update + * @param {String} _id - program user id. + * @param {Object} bodyData - request body data. + * @param {Object} userDetails - logged in user details (from decoded token). + * @returns {Object} updated program user document. + */ + static update(_id, bodyData, userDetails) { + return new Promise(async (resolve, reject) => { + try { + // Validate _id field + _id = _id === ':_id' ? null : _id + + if (!_id) { + return resolve({ + success: false, + status: HTTP_STATUS_CODE.bad_request.status, + message: CONSTANTS.apiResponses.PROGRAM_USER_ID_REQUIRED, + }) + } + + // Extract from decoded token + const tokenUserId = userDetails.userInformation.userId + const tenantId = userDetails.userInformation.tenantId + + // Build filter + const filter = { + _id: ObjectId(_id), + tenantId: tenantId, + } + + // Get current program user for status transition tracking + const currentProgramUser = await programUsersQueries.findOne(filter) + + if (!currentProgramUser || !currentProgramUser._id) { + return resolve({ + success: false, + status: HTTP_STATUS_CODE.not_found.status, + message: CONSTANTS.apiResponses.PROGRAM_USER_NOT_FOUND, + }) + } + + // Validate status transition if status is being changed + if (bodyData.status && bodyData.status !== currentProgramUser.status) { + const validation = this.validateStatusTransition(currentProgramUser.status, bodyData.status) + if (!validation.valid) { + const validNextStatuses = this.getValidNextStatuses(currentProgramUser.status) + return resolve({ + success: false, + status: HTTP_STATUS_CODE.bad_request.status, + message: validation.message, + data: { + currentStatus: currentProgramUser.status, + attemptedStatus: bodyData.status, + validNextStatuses: validNextStatuses, + }, + }) + } + } + + // Prepare update data + const updateData = { $set: {} } + + // Prevent updating protected fields + delete bodyData.tenantId + delete bodyData.orgId + delete bodyData.createdBy + delete bodyData.programId + delete bodyData.userId + + // If status is being changed, track the previous status + if (bodyData.status && bodyData.status !== currentProgramUser.status) { + updateData.$set.prevStatus = currentProgramUser.status + } + + // Set updatedBy from token + updateData.$set.updatedBy = tokenUserId + + // Add remaining fields to update + Object.keys(bodyData).forEach((key) => { + updateData.$set[key] = bodyData[key] + }) + + const updatedProgramUser = await programUsersQueries.findOneAndUpdate(filter, updateData, { new: true }) + + if (!updatedProgramUser || !updatedProgramUser._id) { + return resolve({ + success: false, + status: HTTP_STATUS_CODE.bad_request.status, + message: CONSTANTS.apiResponses.PROGRAM_USER_NOT_UPDATED, + }) + } + + return resolve({ + success: true, + message: CONSTANTS.apiResponses.PROGRAM_USER_UPDATED, + data: updatedProgramUser, + result: updatedProgramUser, + }) + } catch (error) { + return resolve({ + success: false, + message: error.message, + status: HTTP_STATUS_CODE.internal_server_error.status, + }) + } + }) + } + + /** + * Get program user details by ID. + * @method + * @name read + * @param {String} _id - program user id. + * @param {Object} userDetails - logged in user details (from decoded token). + * @returns {Object} program user document. + */ + static read(_id, userDetails) { + return new Promise(async (resolve, reject) => { + try { + // Validate _id field + _id = _id === ':_id' ? null : _id + + if (!_id) { + return resolve({ + success: false, + status: HTTP_STATUS_CODE.bad_request.status, + message: CONSTANTS.apiResponses.PROGRAM_USER_ID_REQUIRED, + }) + } + + const tenantId = userDetails.userInformation.tenantId + + const filter = { + _id: ObjectId(_id), + tenantId: tenantId, + } + + const programUser = await programUsersQueries.findOne(filter) + + if (!programUser || !programUser._id) { + return resolve({ + success: false, + status: HTTP_STATUS_CODE.not_found.status, + message: CONSTANTS.apiResponses.PROGRAM_USER_NOT_FOUND, + }) + } + + // Add valid next statuses to response + programUser.validNextStatuses = this.getValidNextStatuses(programUser.status) + + return resolve({ + success: true, + message: CONSTANTS.apiResponses.PROGRAM_USER_FETCHED, + data: programUser, + result: programUser, + }) + } catch (error) { + return resolve({ + success: false, + message: error.message, + status: HTTP_STATUS_CODE.internal_server_error.status, + }) + } + }) + } + + /** + * List program users with filters and pagination. + * @method + * @name list + * @param {Object} queryParams - query parameters for filtering. + * @param {Object} bodyData - request body data for additional filters. + * @param {Object} userDetails - logged in user details (from decoded token). + * @returns {Object} paginated list of program users. + */ + static list(queryParams, bodyData, userDetails) { + return new Promise(async (resolve, reject) => { + try { + const tenantId = userDetails.userInformation.tenantId + + // Build filter query + const filter = { + tenantId: tenantId, + } + + // Add optional filters from query params + if (queryParams.programId) { + filter.programId = ObjectId(queryParams.programId) + } + + if (queryParams.userId) { + filter.userId = queryParams.userId + } + + if (queryParams.status) { + filter.status = queryParams.status + } + + if (queryParams.createdBy) { + filter.createdBy = queryParams.createdBy + } + + if (queryParams.orgId) { + filter.orgId = queryParams.orgId + } + + // Add filters from body if provided + if (bodyData && Object.keys(bodyData).length > 0) { + Object.keys(bodyData).forEach((key) => { + if (key === 'programId') { + filter.programId = ObjectId(bodyData.programId) + } else if (!['page', 'limit'].includes(key)) { + filter[key] = bodyData[key] + } + }) + } + + // Pagination - support both page/limit and offset/limit formats + let page, limit, offset + + // Get limit (required for both formats) + limit = parseInt(queryParams.limit) || parseInt(bodyData?.limit) || 10 + + // Check if offset is provided (offset/limit format) + if (queryParams.offset !== undefined || bodyData?.offset !== undefined) { + offset = parseInt(queryParams.offset) || parseInt(bodyData?.offset) || 0 + page = Math.floor(offset / limit) + 1 + } else { + // Use page/limit format (default) + page = parseInt(queryParams.page) || parseInt(bodyData?.page) || 1 + offset = (page - 1) * limit + } + + // Sort + const sortData = { createdAt: -1 } + + // Get paginated results using offset-based pagination + const result = await programUsersQueries.listWithOffset(filter, 'all', 'none', offset, limit, sortData) + + return resolve({ + success: true, + message: CONSTANTS.apiResponses.PROGRAM_USERS_FETCHED, + data: result.data, + result: result.data, + count: result.totalCount, + totalCount: result.totalCount, + page: result.page, + limit: result.limit, + totalPages: result.totalPages, + }) + } catch (error) { + return resolve({ + success: false, + message: error.message, + status: HTTP_STATUS_CODE.internal_server_error.status, + }) + } + }) + } + + /** + * Delete a program user mapping. + * @method + * @name delete + * @param {String} _id - program user id. + * @param {Object} userDetails - logged in user details (from decoded token). + * @returns {Object} deletion result. + */ + static delete(_id, userDetails) { + return new Promise(async (resolve, reject) => { + try { + // Validate _id field + _id = _id === ':_id' ? null : _id + + if (!_id) { + return resolve({ + success: false, + status: HTTP_STATUS_CODE.bad_request.status, + message: CONSTANTS.apiResponses.PROGRAM_USER_ID_REQUIRED, + }) + } + + const tenantId = userDetails.userInformation.tenantId + + const filter = { + _id: ObjectId(_id), + tenantId: tenantId, + } + + // Check if the program user exists + const programUser = await programUsersQueries.findOne(filter) + + if (!programUser || !programUser._id) { + return resolve({ + success: false, + status: HTTP_STATUS_CODE.not_found.status, + message: CONSTANTS.apiResponses.PROGRAM_USER_NOT_FOUND, + }) + } + + // Delete the program user + const deleteResult = await programUsersQueries.deleteOne(filter) + + if (deleteResult.deletedCount === 0) { + return resolve({ + success: false, + status: HTTP_STATUS_CODE.bad_request.status, + message: CONSTANTS.apiResponses.PROGRAM_USER_NOT_DELETED, + }) + } + + return resolve({ + success: true, + message: CONSTANTS.apiResponses.PROGRAM_USER_DELETED, + data: { _id: _id }, + }) + } catch (error) { + return resolve({ + success: false, + message: error.message, + status: HTTP_STATUS_CODE.internal_server_error.status, + }) + } + }) + } + + /** + * Update program user status with validation. + * Status must follow order: NOT_ONBOARDED → ONBOARDED → IN_PROGRESS → COMPLETED → GRADUATED + * DROPPED_OUT can happen from any state except GRADUATED + * No rollback allowed + * @method + * @name updateStatus + * @param {String} _id - program user id. + * @param {Object} bodyData - request body with new status. + * @param {Object} userDetails - logged in user details (from decoded token). + * @returns {Object} updated program user document. + */ + static updateStatus(_id, bodyData, userDetails) { + return new Promise(async (resolve, reject) => { + try { + // Validate _id field + _id = _id === ':_id' ? null : _id + + if (!_id) { + return resolve({ + success: false, + status: HTTP_STATUS_CODE.bad_request.status, + message: CONSTANTS.apiResponses.PROGRAM_USER_ID_REQUIRED, + }) + } + + if (!bodyData.status) { + return resolve({ + success: false, + status: HTTP_STATUS_CODE.bad_request.status, + message: CONSTANTS.apiResponses.STATUS_REQUIRED, + }) + } + + const tokenUserId = userDetails.userInformation.userId + const tenantId = userDetails.userInformation.tenantId + + const filter = { + _id: ObjectId(_id), + tenantId: tenantId, + } + + // Get current program user + const currentProgramUser = await programUsersQueries.findOne(filter) + + if (!currentProgramUser || !currentProgramUser._id) { + return resolve({ + success: false, + status: HTTP_STATUS_CODE.not_found.status, + message: CONSTANTS.apiResponses.PROGRAM_USER_NOT_FOUND, + }) + } + + // Validate status transition + const validation = this.validateStatusTransition(currentProgramUser.status, bodyData.status) + if (!validation.valid) { + const validNextStatuses = this.getValidNextStatuses(currentProgramUser.status) + return resolve({ + success: false, + status: HTTP_STATUS_CODE.bad_request.status, + message: validation.message, + data: { + currentStatus: currentProgramUser.status, + attemptedStatus: bodyData.status, + validNextStatuses: validNextStatuses, + statusFlow: + STATUS_ORDER.slice(0, 5).join(' → ') + ' (DROPPED_OUT from any except GRADUATED)', + }, + }) + } + + // Prepare update data + const updateData = { + $set: { + prevStatus: currentProgramUser.status, + status: bodyData.status, + statusReason: bodyData.statusReason || null, + updatedBy: tokenUserId, + }, + } + + const updatedProgramUser = await programUsersQueries.findOneAndUpdate(filter, updateData, { new: true }) + + if (!updatedProgramUser || !updatedProgramUser._id) { + return resolve({ + success: false, + status: HTTP_STATUS_CODE.bad_request.status, + message: CONSTANTS.apiResponses.PROGRAM_USER_STATUS_NOT_UPDATED, + }) + } + + // Add valid next statuses to response + updatedProgramUser.validNextStatuses = this.getValidNextStatuses(updatedProgramUser.status) + + return resolve({ + success: true, + message: CONSTANTS.apiResponses.PROGRAM_USER_STATUS_UPDATED, + data: updatedProgramUser, + result: updatedProgramUser, + }) + } catch (error) { + return resolve({ + success: false, + message: error.message, + status: HTTP_STATUS_CODE.internal_server_error.status, + }) + } + }) + } + + /** + * Update program user metadata. + * @method + * @name updateMetadata + * @param {String} _id - program user id. + * @param {Object} bodyData - request body with metadata updates. + * @param {Object} userDetails - logged in user details (from decoded token). + * @returns {Object} updated program user document. + */ + static updateMetadata(_id, bodyData, userDetails) { + return new Promise(async (resolve, reject) => { + try { + // Validate _id field + _id = _id === ':_id' ? null : _id + + if (!_id) { + return resolve({ + success: false, + status: HTTP_STATUS_CODE.bad_request.status, + message: CONSTANTS.apiResponses.PROGRAM_USER_ID_REQUIRED, + }) + } + + if (!bodyData.metadata) { + return resolve({ + success: false, + status: HTTP_STATUS_CODE.bad_request.status, + message: CONSTANTS.apiResponses.METADATA_REQUIRED, + }) + } + + const tokenUserId = userDetails.userInformation.userId + const tenantId = userDetails.userInformation.tenantId + + const filter = { + _id: ObjectId(_id), + tenantId: tenantId, + } + + // Check if program user exists + const programUser = await programUsersQueries.findOne(filter) + + if (!programUser || !programUser._id) { + return resolve({ + success: false, + status: HTTP_STATUS_CODE.not_found.status, + message: CONSTANTS.apiResponses.PROGRAM_USER_NOT_FOUND, + }) + } + + // Merge metadata with existing metadata + const updatedMetadata = { + ...programUser.metadata, + ...bodyData.metadata, + } + + const updateData = { + $set: { + metadata: updatedMetadata, + updatedBy: tokenUserId, + }, + } + + const updatedProgramUser = await programUsersQueries.findOneAndUpdate(filter, updateData, { new: true }) + + if (!updatedProgramUser || !updatedProgramUser._id) { + return resolve({ + success: false, + status: HTTP_STATUS_CODE.bad_request.status, + message: CONSTANTS.apiResponses.PROGRAM_USER_METADATA_NOT_UPDATED, + }) + } + + return resolve({ + success: true, + message: CONSTANTS.apiResponses.PROGRAM_USER_METADATA_UPDATED, + data: updatedProgramUser, + result: updatedProgramUser, + }) + } catch (error) { + return resolve({ + success: false, + message: error.message, + status: HTTP_STATUS_CODE.internal_server_error.status, + }) + } + }) + } + + /** + * Get program users by program ID. + * @method + * @name getByProgramId + * @param {String} programId - program id. + * @param {Object} queryParams - query parameters. + * @param {Object} userDetails - logged in user details (from decoded token). + * @returns {Object} list of program users. + */ + static getByProgramId(programId, queryParams, userDetails) { + return new Promise(async (resolve, reject) => { + try { + if (!programId || programId === ':_id') { + return resolve({ + success: false, + status: HTTP_STATUS_CODE.bad_request.status, + message: CONSTANTS.apiResponses.PROGRAM_ID_REQUIRED, + }) + } + + const tenantId = userDetails.userInformation.tenantId + + const filter = { + programId: ObjectId(programId), + tenantId: tenantId, + } + + // Add optional status filter + if (queryParams.status) { + filter.status = queryParams.status + } + + // Pagination + const page = parseInt(queryParams.page) || 1 + const limit = parseInt(queryParams.limit) || 10 + + const result = await programUsersQueries.list(filter, 'all', 'none', page, limit, { createdAt: -1 }) + + return resolve({ + success: true, + message: CONSTANTS.apiResponses.PROGRAM_USERS_FETCHED, + data: result.data, + result: result.data, + count: result.totalCount, + totalCount: result.totalCount, + page: result.page, + limit: result.limit, + totalPages: result.totalPages, + }) + } catch (error) { + return resolve({ + success: false, + message: error.message, + status: HTTP_STATUS_CODE.internal_server_error.status, + }) + } + }) + } + + /** + * Read a program user by program ID and user ID + * @method + * @name readByProgramAndUserId + * @param {String} programId - Program ID + * @param {String} userId - User ID + * @param {Object} userDetails - logged in user details + * @returns {JSON} - program user details + */ + static readByProgramAndUserId(programId, userId, userDetails) { + return new Promise(async (resolve, reject) => { + try { + if (!programId) { + throw { + status: HTTP_STATUS_CODE.bad_request.status, + message: 'Program ID is required', + } + } + + if (!userId) { + throw { + status: HTTP_STATUS_CODE.bad_request.status, + message: 'User ID is required', + } + } + + let filterData = { + programId: new ObjectId(programId), + userId: userId, + tenantId: userDetails.userInformation.tenantId, + orgId: userDetails.userInformation.organizationId, + } + + let programUser = await programUsersQueries.findOne(filterData) + + if (!programUser || !programUser._id) { + throw { + status: HTTP_STATUS_CODE.not_found.status, + message: 'Program user not found', + } + } + + return resolve({ + success: true, + message: CONSTANTS.apiResponses.PROGRAM_USER_FETCHED, + data: programUser, + result: programUser, + }) + } catch (error) { + return resolve({ + status: error.status || HTTP_STATUS_CODE.internal_server_error.status, + message: error.message || HTTP_STATUS_CODE.internal_server_error.message, + errorObject: error, + }) + } + }) + } + + /** + * Delete a program user resource + * @method + * @name deleteResource + * @param {String} _id - Program user ID to delete + * @param {Object} userDetails - logged in user details + * @returns {JSON} - deletion response + */ + static deleteResource(_id, userDetails) { + return new Promise(async (resolve, reject) => { + try { + if (!_id) { + throw { + status: HTTP_STATUS_CODE.bad_request.status, + message: 'Program user ID is required', + } + } + + let filterData = { + _id: new ObjectId(_id), + tenantId: userDetails.userInformation.tenantId, + orgId: userDetails.userInformation.organizationId, + } + + let programUser = await programUsersQueries.findOne(filterData) + + if (!programUser || !programUser._id) { + throw { + status: HTTP_STATUS_CODE.not_found.status, + message: 'Program user not found', + } + } + + let deleteResult = await programUsersQueries.deleteOne(filterData) + + if (deleteResult.deletedCount === 0) { + throw { + status: HTTP_STATUS_CODE.not_found.status, + message: 'Program user not found or already deleted', + } + } + + return resolve({ + success: true, + message: CONSTANTS.apiResponses.PROGRAM_USER_DELETED, + data: { + _id: _id, + deletedAt: new Date().toISOString(), + }, + result: { + _id: _id, + deletedAt: new Date().toISOString(), + }, + }) + } catch (error) { + return resolve({ + status: error.status || HTTP_STATUS_CODE.internal_server_error.status, + message: error.message || HTTP_STATUS_CODE.internal_server_error.message, + errorObject: error, + }) + } + }) + } } diff --git a/module/programUsers/validator/v1.js b/module/programUsers/validator/v1.js new file mode 100644 index 00000000..14e85184 --- /dev/null +++ b/module/programUsers/validator/v1.js @@ -0,0 +1,240 @@ +/** + * name : v1.js + * author : System + * created-date : 18-Dec-2024 + * Description : Validators for program users API endpoints. + */ + +module.exports = (req) => { + let programUsersValidator = { + /** + * Validator for create endpoint + */ + create: function () { + req.checkBody('programId') + .exists() + .withMessage('programId is required') + .notEmpty() + .withMessage('programId cannot be empty') + .isMongoId() + .withMessage('programId must be a valid MongoDB ObjectId') + + // userId is optional - if not provided, will use token userId + if (req.body.userId) { + req.checkBody('userId') + .notEmpty() + .withMessage('userId cannot be empty') + .isString() + .withMessage('userId must be a string') + } + + req.checkBody('userProfile') + .exists() + .withMessage('userProfile is required') + .notEmpty() + .withMessage('userProfile cannot be empty') + + // Status is optional but if provided must be valid + if (req.body.status) { + req.checkBody('status') + .isIn(['NOT_ONBOARDED', 'ONBOARDED', 'IN_PROGRESS', 'COMPLETED', 'GRADUATED', 'DROPPED_OUT']) + .withMessage( + 'status must be one of: NOT_ONBOARDED, ONBOARDED, IN_PROGRESS, COMPLETED, GRADUATED, DROPPED_OUT' + ) + } + + // Metadata is optional + if (req.body.metadata) { + // Instead of .isObject(), we use a custom check + req.checkBody('metadata') + .custom((val) => typeof val === 'object' && !Array.isArray(val) && val !== null) + .withMessage('metadata must be an object') + } + + // ConsentShared is optional but if provided must be boolean + if (req.body.consentShared !== undefined) { + req.checkBody('consentShared').isBoolean().withMessage('consentShared must be a boolean') + } + }, + + /** + * Validator for update endpoint + */ + update: function () { + req.checkParams('_id') + .exists() + .withMessage('_id is required') + .notEmpty() + .withMessage('_id cannot be empty') + .isMongoId() + .withMessage('_id must be a valid MongoDB ObjectId') + + // Status is optional but if provided must be valid + if (req.body.status) { + req.checkBody('status') + .isIn(['NOT_ONBOARDED', 'ONBOARDED', 'IN_PROGRESS', 'COMPLETED', 'GRADUATED', 'DROPPED_OUT']) + .withMessage( + 'status must be one of: NOT_ONBOARDED, ONBOARDED, IN_PROGRESS, COMPLETED, GRADUATED, DROPPED_OUT' + ) + } + + // Metadata is optional + if (req.body.metadata) { + req.checkBody('metadata').isObject().withMessage('metadata must be an object') + } + }, + + /** + * Validator for read endpoint + */ + read: function () { + req.checkParams('_id') + .exists() + .withMessage('_id is required') + .notEmpty() + .withMessage('_id cannot be empty') + .isMongoId() + .withMessage('_id must be a valid MongoDB ObjectId') + }, + + /** + * Validator for list endpoint + */ + list: function () { + // All query params are optional for list + if (req.query.programId) { + req.checkQuery('programId').isMongoId().withMessage('programId must be a valid MongoDB ObjectId') + } + + if (req.query.status) { + req.checkQuery('status') + .isIn(['NOT_ONBOARDED', 'ONBOARDED', 'IN_PROGRESS', 'COMPLETED', 'GRADUATED', 'DROPPED_OUT']) + .withMessage( + 'status must be one of: NOT_ONBOARDED, ONBOARDED, IN_PROGRESS, COMPLETED, GRADUATED, DROPPED_OUT' + ) + } + + if (req.query.page) { + req.checkQuery('page').isInt({ min: 1 }).withMessage('page must be a positive integer') + } + + if (req.query.limit) { + req.checkQuery('limit') + .isInt({ min: 1, max: 100 }) + .withMessage('limit must be a positive integer between 1 and 100') + } + }, + + /** + * Validator for delete endpoint + */ + delete: function () { + req.checkParams('_id') + .exists() + .withMessage('_id is required') + .notEmpty() + .withMessage('_id cannot be empty') + .isMongoId() + .withMessage('_id must be a valid MongoDB ObjectId') + }, + + /** + * Validator for updateStatus endpoint + */ + updateStatus: function () { + req.checkParams('_id') + .exists() + .withMessage('_id is required') + .notEmpty() + .withMessage('_id cannot be empty') + .isMongoId() + .withMessage('_id must be a valid MongoDB ObjectId') + + req.checkBody('status') + .exists() + .withMessage('status is required') + .notEmpty() + .withMessage('status cannot be empty') + .isIn(['NOT_ONBOARDED', 'ONBOARDED', 'IN_PROGRESS', 'COMPLETED', 'GRADUATED', 'DROPPED_OUT']) + .withMessage( + 'status must be one of: NOT_ONBOARDED, ONBOARDED, IN_PROGRESS, COMPLETED, GRADUATED, DROPPED_OUT' + ) + + // StatusReason is optional + if (req.body.statusReason) { + req.checkBody('statusReason').isString().withMessage('statusReason must be a string') + } + }, + + /** + * Validator for updateMetadata endpoint + */ + updateMetadata: function () { + req.checkParams('_id') + .exists() + .withMessage('_id is required') + .notEmpty() + .withMessage('_id cannot be empty') + .isMongoId() + .withMessage('_id must be a valid MongoDB ObjectId') + + req.checkBody('metadata') + .exists() + .withMessage('metadata is required') + .notEmpty() + .withMessage('metadata cannot be empty') + .isObject() + .withMessage('metadata must be an object') + }, + + /** + * Validator for getByProgramId endpoint + */ + getByProgramId: function () { + req.checkParams('_id') + .exists() + .withMessage('programId is required') + .notEmpty() + .withMessage('programId cannot be empty') + .isMongoId() + .withMessage('programId must be a valid MongoDB ObjectId') + + if (req.query.status) { + req.checkQuery('status') + .isIn(['NOT_ONBOARDED', 'ONBOARDED', 'IN_PROGRESS', 'COMPLETED', 'GRADUATED', 'DROPPED_OUT']) + .withMessage( + 'status must be one of: NOT_ONBOARDED, ONBOARDED, IN_PROGRESS, COMPLETED, GRADUATED, DROPPED_OUT' + ) + } + + if (req.query.page) { + req.checkQuery('page').isInt({ min: 1 }).withMessage('page must be a positive integer') + } + + if (req.query.limit) { + req.checkQuery('limit') + .isInt({ min: 1, max: 100 }) + .withMessage('limit must be a positive integer between 1 and 100') + } + }, + + /** + * Validator for getStatusFlow endpoint - no validation needed + */ + getStatusFlow: function () { + // No validation required for this endpoint + }, + + /** + * Validator for mock endpoint - no validation needed + */ + mock: function () { + // No validation required for this endpoint + }, + } + + // Execute the validator for the current method + if (programUsersValidator[req.params.method]) { + programUsersValidator[req.params.method]() + } +} From ef9f98a4323bf418ce6594d4c43678c46cbaae04 Mon Sep 17 00:00:00 2001 From: Sachintechjoomla <92356209+Sachintechjoomla@users.noreply.github.com> Date: Thu, 18 Dec 2025 14:47:00 +0530 Subject: [PATCH 2/8] Issue#251228 Feat: Program User Status Mapping --- document/programUsers/api-response.json | 172 ------------------------ 1 file changed, 172 deletions(-) delete mode 100644 document/programUsers/api-response.json diff --git a/document/programUsers/api-response.json b/document/programUsers/api-response.json deleted file mode 100644 index 08c7f6fd..00000000 --- a/document/programUsers/api-response.json +++ /dev/null @@ -1,172 +0,0 @@ -{ - "info": { - "name": "ProgramUsers", - "_postman_id": "23240481-6fd873d2-34ce-4587-8f77-749fc33bf587", - "description": "", - "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" - }, - "item": [ - { - "name": "Create", - "request": { - "method": "POST", - "header": [ - { - "key": "x-auth-token", - "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJkYXRhIjp7ImlkIjoyMDAzLCJuYW1lIjoidGFuZnVuY29mZmljaWFsIHNsZGlyZWN0b3IiLCJzZXNzaW9uX2lkIjoyMjcxMCwib3JnYW5pemF0aW9uX2lkcyI6WyIzMyJdLCJvcmdhbml6YXRpb25fY29kZXMiOlsidGFuOTAiXSwidGVuYW50X2NvZGUiOiJzaGlrc2hhbG9rYW0iLCJvcmdhbml6YXRpb25zIjpbeyJpZCI6MzMsIm5hbWUiOiJ0YW45MCIsImNvZGUiOiJ0YW45MCIsImRlc2NyaXB0aW9uIjoiVGFuOTAgc3BlY2lhbGl6ZXMgaW4gcHJvdmlkaW5nIGVkdWNhdGlvbmFsIFNURUFNIiwic3RhdHVzIjoiQUNUSVZFIiwicmVsYXRlZF9vcmdzIjpbMzRdLCJ0ZW5hbnRfY29kZSI6InNoaWtzaGFsb2thbSIsIm1ldGEiOm51bGwsImNyZWF0ZWRfYnkiOjEsInVwZGF0ZWRfYnkiOjE3MDksInJvbGVzIjpbeyJpZCI6MjMsInRpdGxlIjoibWVudGVlIiwibGFiZWwiOiJtZW50ZWUiLCJ1c2VyX3R5cGUiOjAsInN0YXR1cyI6IkFDVElWRSIsIm9yZ2FuaXphdGlvbl9pZCI6MTAsInZpc2liaWxpdHkiOiJQVUJMSUMiLCJ0ZW5hbnRfY29kZSI6InNoaWtzaGFsb2thbSIsInRyYW5zbGF0aW9ucyI6bnVsbH1dfV19LCJpYXQiOjE3NjYwMzk5MDcsImV4cCI6MTc2NjEyNjMwN30.l5Iawv9_Qky-qxFIWNl8BQMvE_RR3niAYxxHOpWYTeQ", - "type": "text" - }, - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"programId\": \"507f1f77bcf86cd799439012\",\n // Optional - uses token userId if not provided\n \"userProfile\": {\n \"firstName\": \"John\",\n \"lastName\": \"Doe\",\n \"email\": \"john@example.com\"\n },\n \"userRoleInformation\": {\n \"role\": \"teacher\"\n },\n \"status\": \"NOT_ONBOARDED\", // Optional - defaults to NOT_ONBOARDED\n \"metadata\": {} // Optional - empty by default\n}", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "{{baseUrl}}project/v1/programUsers/create", - "host": ["{{baseUrl}}project"], - "path": ["v1", "programUsers", "create"] - } - }, - "response": [] - }, - { - "name": "updateStatus", - "request": { - "method": "POST", - "header": [ - { - "key": "x-auth-token", - "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJkYXRhIjp7ImlkIjoyMDAzLCJuYW1lIjoidGFuZnVuY29mZmljaWFsIHNsZGlyZWN0b3IiLCJzZXNzaW9uX2lkIjoyMjcxMCwib3JnYW5pemF0aW9uX2lkcyI6WyIzMyJdLCJvcmdhbml6YXRpb25fY29kZXMiOlsidGFuOTAiXSwidGVuYW50X2NvZGUiOiJzaGlrc2hhbG9rYW0iLCJvcmdhbml6YXRpb25zIjpbeyJpZCI6MzMsIm5hbWUiOiJ0YW45MCIsImNvZGUiOiJ0YW45MCIsImRlc2NyaXB0aW9uIjoiVGFuOTAgc3BlY2lhbGl6ZXMgaW4gcHJvdmlkaW5nIGVkdWNhdGlvbmFsIFNURUFNIiwic3RhdHVzIjoiQUNUSVZFIiwicmVsYXRlZF9vcmdzIjpbMzRdLCJ0ZW5hbnRfY29kZSI6InNoaWtzaGFsb2thbSIsIm1ldGEiOm51bGwsImNyZWF0ZWRfYnkiOjEsInVwZGF0ZWRfYnkiOjE3MDksInJvbGVzIjpbeyJpZCI6MjMsInRpdGxlIjoibWVudGVlIiwibGFiZWwiOiJtZW50ZWUiLCJ1c2VyX3R5cGUiOjAsInN0YXR1cyI6IkFDVElWRSIsIm9yZ2FuaXphdGlvbl9pZCI6MTAsInZpc2liaWxpdHkiOiJQVUJMSUMiLCJ0ZW5hbnRfY29kZSI6InNoaWtzaGFsb2thbSIsInRyYW5zbGF0aW9ucyI6bnVsbH1dfV19LCJpYXQiOjE3NjYwMzk5MDcsImV4cCI6MTc2NjEyNjMwN30.l5Iawv9_Qky-qxFIWNl8BQMvE_RR3niAYxxHOpWYTeQ", - "type": "text" - }, - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"status\": \"ONBOARDED\",\n \"statusReason\": \"Completed onboarding process\"\n}", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "{{baseUrl}}project/v1/programUsers/updateStatus/6943aad3ccd64511455af78f", - "host": ["{{baseUrl}}project"], - "path": ["v1", "programUsers", "updateStatus", "6943aad3ccd64511455af78f"] - } - }, - "response": [] - }, - { - "name": "List", - "request": { - "method": "POST", - "header": [ - { - "key": "x-auth-token", - "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJkYXRhIjp7ImlkIjoyMDAzLCJuYW1lIjoidGFuZnVuY29mZmljaWFsIHNsZGlyZWN0b3IiLCJzZXNzaW9uX2lkIjoyMjcxMCwib3JnYW5pemF0aW9uX2lkcyI6WyIzMyJdLCJvcmdhbml6YXRpb25fY29kZXMiOlsidGFuOTAiXSwidGVuYW50X2NvZGUiOiJzaGlrc2hhbG9rYW0iLCJvcmdhbml6YXRpb25zIjpbeyJpZCI6MzMsIm5hbWUiOiJ0YW45MCIsImNvZGUiOiJ0YW45MCIsImRlc2NyaXB0aW9uIjoiVGFuOTAgc3BlY2lhbGl6ZXMgaW4gcHJvdmlkaW5nIGVkdWNhdGlvbmFsIFNURUFNIiwic3RhdHVzIjoiQUNUSVZFIiwicmVsYXRlZF9vcmdzIjpbMzRdLCJ0ZW5hbnRfY29kZSI6InNoaWtzaGFsb2thbSIsIm1ldGEiOm51bGwsImNyZWF0ZWRfYnkiOjEsInVwZGF0ZWRfYnkiOjE3MDksInJvbGVzIjpbeyJpZCI6MjMsInRpdGxlIjoibWVudGVlIiwibGFiZWwiOiJtZW50ZWUiLCJ1c2VyX3R5cGUiOjAsInN0YXR1cyI6IkFDVElWRSIsIm9yZ2FuaXphdGlvbl9pZCI6MTAsInZpc2liaWxpdHkiOiJQVUJMSUMiLCJ0ZW5hbnRfY29kZSI6InNoaWtzaGFsb2thbSIsInRyYW5zbGF0aW9ucyI6bnVsbH1dfV19LCJpYXQiOjE3NjYwMzk5MDcsImV4cCI6MTc2NjEyNjMwN30.l5Iawv9_Qky-qxFIWNl8BQMvE_RR3niAYxxHOpWYTeQ", - "type": "text" - }, - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "http://localhost:5003/project/v1/programUsers/list?offset=0&limit=10&userId=2003", - "protocol": "http", - "host": ["localhost"], - "port": "5003", - "path": ["project", "v1", "programUsers", "list"], - "query": [ - { - "key": "offset", - "value": "0" - }, - { - "key": "limit", - "value": "10" - }, - { - "key": "userId", - "value": "2003" - } - ] - } - }, - "response": [] - }, - { - "name": "Read", - "request": { - "method": "POST", - "header": [ - { - "key": "x-auth-token", - "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJkYXRhIjp7ImlkIjoyMDAzLCJuYW1lIjoidGFuZnVuY29mZmljaWFsIHNsZGlyZWN0b3IiLCJzZXNzaW9uX2lkIjoyMjcxMCwib3JnYW5pemF0aW9uX2lkcyI6WyIzMyJdLCJvcmdhbml6YXRpb25fY29kZXMiOlsidGFuOTAiXSwidGVuYW50X2NvZGUiOiJzaGlrc2hhbG9rYW0iLCJvcmdhbml6YXRpb25zIjpbeyJpZCI6MzMsIm5hbWUiOiJ0YW45MCIsImNvZGUiOiJ0YW45MCIsImRlc2NyaXB0aW9uIjoiVGFuOTAgc3BlY2lhbGl6ZXMgaW4gcHJvdmlkaW5nIGVkdWNhdGlvbmFsIFNURUFNIiwic3RhdHVzIjoiQUNUSVZFIiwicmVsYXRlZF9vcmdzIjpbMzRdLCJ0ZW5hbnRfY29kZSI6InNoaWtzaGFsb2thbSIsIm1ldGEiOm51bGwsImNyZWF0ZWRfYnkiOjEsInVwZGF0ZWRfYnkiOjE3MDksInJvbGVzIjpbeyJpZCI6MjMsInRpdGxlIjoibWVudGVlIiwibGFiZWwiOiJtZW50ZWUiLCJ1c2VyX3R5cGUiOjAsInN0YXR1cyI6IkFDVElWRSIsIm9yZ2FuaXphdGlvbl9pZCI6MTAsInZpc2liaWxpdHkiOiJQVUJMSUMiLCJ0ZW5hbnRfY29kZSI6InNoaWtzaGFsb2thbSIsInRyYW5zbGF0aW9ucyI6bnVsbH1dfV19LCJpYXQiOjE3NjYwMzk5MDcsImV4cCI6MTc2NjEyNjMwN30.l5Iawv9_Qky-qxFIWNl8BQMvE_RR3niAYxxHOpWYTeQ", - "type": "text" - }, - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "http://localhost:5003/project/v1/programUsers/read/6943aad3ccd64511455af78f", - "protocol": "http", - "host": ["localhost"], - "port": "5003", - "path": ["project", "v1", "programUsers", "read", "6943aad3ccd64511455af78f"] - } - }, - "response": [ - { - "name": "New Request", - "originalRequest": { - "method": "POST", - "header": [ - { - "key": "x-auth-token", - "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJkYXRhIjp7ImlkIjoyMDAzLCJuYW1lIjoidGFuZnVuY29mZmljaWFsIHNsZGlyZWN0b3IiLCJzZXNzaW9uX2lkIjoyMjcxMCwib3JnYW5pemF0aW9uX2lkcyI6WyIzMyJdLCJvcmdhbml6YXRpb25fY29kZXMiOlsidGFuOTAiXSwidGVuYW50X2NvZGUiOiJzaGlrc2hhbG9rYW0iLCJvcmdhbml6YXRpb25zIjpbeyJpZCI6MzMsIm5hbWUiOiJ0YW45MCIsImNvZGUiOiJ0YW45MCIsImRlc2NyaXB0aW9uIjoiVGFuOTAgc3BlY2lhbGl6ZXMgaW4gcHJvdmlkaW5nIGVkdWNhdGlvbmFsIFNURUFNIiwic3RhdHVzIjoiQUNUSVZFIiwicmVsYXRlZF9vcmdzIjpbMzRdLCJ0ZW5hbnRfY29kZSI6InNoaWtzaGFsb2thbSIsIm1ldGEiOm51bGwsImNyZWF0ZWRfYnkiOjEsInVwZGF0ZWRfYnkiOjE3MDksInJvbGVzIjpbeyJpZCI6MjMsInRpdGxlIjoibWVudGVlIiwibGFiZWwiOiJtZW50ZWUiLCJ1c2VyX3R5cGUiOjAsInN0YXR1cyI6IkFDVElWRSIsIm9yZ2FuaXphdGlvbl9pZCI6MTAsInZpc2liaWxpdHkiOiJQVUJMSUMiLCJ0ZW5hbnRfY29kZSI6InNoaWtzaGFsb2thbSIsInRyYW5zbGF0aW9ucyI6bnVsbH1dfV19LCJpYXQiOjE3NjYwMzk5MDcsImV4cCI6MTc2NjEyNjMwN30.l5Iawv9_Qky-qxFIWNl8BQMvE_RR3niAYxxHOpWYTeQ", - "type": "text" - }, - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "url": { - "raw": "http://localhost:5003/project/v1/programUsers/read/6943aad3ccd64511455af78f", - "protocol": "http", - "host": ["localhost"], - "port": "5003", - "path": ["project", "v1", "programUsers", "read", "6943aad3ccd64511455af78f"] - } - }, - "_postman_previewlanguage": "json", - "header": [], - "cookie": [], - "body": "" - } - ] - } - ] -} From 6eefd7276d30cc0a7d776e75e72bb0b4f276671f Mon Sep 17 00:00:00 2001 From: Sachintechjoomla <92356209+Sachintechjoomla@users.noreply.github.com> Date: Fri, 19 Dec 2025 12:31:40 +0530 Subject: [PATCH 3/8] Issue#251228 Fix: Filter by template external ID --- document/programUsers/README.md | 210 ++++++++++++++++++++++++++-- module/programUsers/helper.js | 141 ++++++++++++++----- module/programUsers/validator/v1.js | 9 +- 3 files changed, 312 insertions(+), 48 deletions(-) diff --git a/document/programUsers/README.md b/document/programUsers/README.md index b1499d02..43193b45 100644 --- a/document/programUsers/README.md +++ b/document/programUsers/README.md @@ -212,21 +212,63 @@ Response (Invalid Transition): ### 3. List Program Users -This works for offset also +Supports both page-based and offset-based pagination. + +#### Query Parameters (Pagination Only) + +- `page`: Page number (default: 1) - **use for page-based pagination** +- `limit`: Items per page (default: 10, max: 100) +- `offset`: Number of documents to skip (default: 0) - **use for offset-based pagination instead of page** + +#### Filter Parameters (Pass in POST Body) + +- `programId`: Filter by program ID +- `userId`: Filter by user ID +- `status`: Filter by status (supports single or multiple comma-separated values) +- `createdBy`: Filter by creator +- `templateExternalId`: Filter by template external ID (supports single or multiple comma-separated values) + +**Note:** `orgId` is automatically extracted from the authentication token and cannot be overridden. + +#### Basic List with Page-Based Pagination + +```bash +POST /project/v1/programUsers/list?page=1&limit=10 +Headers: + x-auth-token: + Content-Type: application/json + +Body: +{ + "programId": "507f1f77bcf86cd799439012", + "status": "ONBOARDED" +} + +Response: +{ + "success": true, + "message": "Program users fetched successfully", + "result": [...], + "count": 100, + "totalCount": 100, + "page": 1, + "limit": 10, + "totalPages": 10 +} +``` + +#### List with Offset-Based Pagination ```bash -POST /project/v1/programUsers/list?page=1&limit=10&programId=xxx&status=ONBOARDED +POST /project/v1/programUsers/list?offset=0&limit=10 Headers: x-auth-token: + Content-Type: application/json -Query Parameters: - - page: Page number (default: 1) - - limit: Items per page (default: 10, max: 100) - - programId: Filter by program ID - - userId: Filter by user ID - - status: Filter by status - - createdBy: Filter by creator - - orgId: Filter by organization +Body: +{ + "programId": "507f1f77bcf86cd799439012" +} Response: { @@ -237,10 +279,158 @@ Response: "totalCount": 100, "page": 1, "limit": 10, + "offset": 0, "totalPages": 10 } ``` +#### Filter by Single Status + +```bash +POST /project/v1/programUsers/list?page=1&limit=10 +Headers: + x-auth-token: + Content-Type: application/json + +Body: +{ + "programId": "507f1f77bcf86cd799439012", + "status": "ONBOARDED" +} +``` + +#### Filter by Multiple Statuses + +```bash +POST /project/v1/programUsers/list?page=1&limit=10 +Headers: + x-auth-token: + Content-Type: application/json + +Body: +{ + "programId": "507f1f77bcf86cd799439012", + "status": "ONBOARDED,IN_PROGRESS,COMPLETED" +} +``` + +#### Filter by Single templateExternalId + +The `templateExternalId` filter allows you to search for program users based on the templates they have in their metadata. Pass as an array or string format. + +**Array format (recommended):** + +```bash +POST /project/v1/programUsers/list?page=1&limit=10 +Headers: + x-auth-token: + Content-Type: application/json + +Body: +{ + "programId": "507f1f77bcf86cd799439012", + "templateExternalId": ["onboarding-template-002"] +} +``` + +**String format:** + +```bash +POST /project/v1/programUsers/list?page=1&limit=10 +Headers: + x-auth-token: + Content-Type: application/json + +Body: +{ + "programId": "507f1f77bcf86cd799439012", + "templateExternalId": "onboarding-template-002" +} +``` + +#### Filter by Multiple templateExternalIds + +**Array format (recommended):** + +```bash +POST /project/v1/programUsers/list?page=1&limit=10 +Headers: + x-auth-token: + Content-Type: application/json + +Body: +{ + "programId": "507f1f77bcf86cd799439012", + "templateExternalId": ["onboarding-template-002", "onboarding-template-003"] +} +``` + +**Comma-separated string format:** + +```bash +POST /project/v1/programUsers/list?page=1&limit=10 +Headers: + x-auth-token: + Content-Type: application/json + +Body: +{ + "programId": "507f1f77bcf86cd799439012", + "templateExternalId": "onboarding-template-002,onboarding-template-003" +} +``` + +#### Combination of Multiple Filters + +```bash +POST /project/v1/programUsers/list?page=1&limit=10 +Headers: + x-auth-token: + Content-Type: application/json + +Body: +{ + "programId": "507f1f77bcf86cd799439012", + "status": "ONBOARDED,IN_PROGRESS", + "templateExternalId": ["onboarding-template-002", "training-template-001"], + "userId": "user-uuid-1223" +} +``` + +#### Response Example + +```json +{ + "success": true, + "message": "Program users fetched successfully", + "result": [ + { + "_id": "6944e556ba76a72c3bb73003", + "programId": "507f1f77bcf86cd799439012", + "userId": "user-uuid-1223", + "status": "ONBOARDED", + "metadata": { + "externalIdOfBoardingCompletionCategory": { + "templateExternalId": "onboarding-template-002", + "tasks": [ + { + "taskId": "task-001", + "completed": true, + "completedAt": "2024-12-18T10:00:00Z" + } + ] + } + } + } + ], + "count": 50, + "totalCount": 50, + "page": 1, + "limit": 10, + "totalPages": 5 +} +``` + ### 4. Update Metadata ```bash diff --git a/module/programUsers/helper.js b/module/programUsers/helper.js index 41adb134..225d0f41 100644 --- a/module/programUsers/helper.js +++ b/module/programUsers/helper.js @@ -410,68 +410,138 @@ module.exports = class ProgramUsersHelper { try { const tenantId = userDetails.userInformation.tenantId - // Build filter query + // 1. Build base filter query const filter = { tenantId: tenantId, } - // Add optional filters from query params - if (queryParams.programId) { - filter.programId = ObjectId(queryParams.programId) - } - - if (queryParams.userId) { - filter.userId = queryParams.userId - } - - if (queryParams.status) { - filter.status = queryParams.status - } - - if (queryParams.createdBy) { - filter.createdBy = queryParams.createdBy - } - - if (queryParams.orgId) { - filter.orgId = queryParams.orgId - } - - // Add filters from body if provided + // 2. Add filters from body if (bodyData && Object.keys(bodyData).length > 0) { Object.keys(bodyData).forEach((key) => { + if (['page', 'limit', 'offset', 'templateExternalId'].includes(key)) { + return + } + if (key === 'programId') { filter.programId = ObjectId(bodyData.programId) - } else if (!['page', 'limit'].includes(key)) { + } else if (key === 'status') { + const statusValue = bodyData.status + const statuses = + typeof statusValue === 'string' + ? statusValue.split(',').map((s) => s.trim()) + : Array.isArray(statusValue) + ? statusValue + : [statusValue] + + filter.status = statuses.length === 1 ? statuses[0] : { $in: statuses } + } else if (['userId', 'createdBy', 'orgId'].includes(key)) { + filter[key] = bodyData[key] + } else if ( + ![ + 'userProfile', + 'userRoleInformation', + 'appInformation', + 'consentShared', + 'resourcesStarted', + 'metadata', + 'prevStatus', + 'statusReason', + 'deleted', + 'tenantId', + 'updatedBy', + 'createdAt', + 'updatedAt', + ].includes(key) + ) { filter[key] = bodyData[key] } }) } - // Pagination - support both page/limit and offset/limit formats - let page, limit, offset + // 3. Handle templateExternalId filter (The ObjectToArray Fix) + let templateExternalIds = [] + if (bodyData && bodyData.templateExternalId) { + const bodyValue = bodyData.templateExternalId + if (Array.isArray(bodyValue)) { + templateExternalIds = bodyValue.filter((id) => (id && id.trim ? id.trim() : id)).filter(Boolean) + } else if (typeof bodyValue === 'string') { + templateExternalIds = bodyValue + .split(',') + .map((id) => id.trim()) + .filter(Boolean) + } else { + templateExternalIds = [bodyValue] + } + } + + if (templateExternalIds.length > 0) { + /* Logic: Convert metadata object to array to ignore dynamic keys. + Matches templateExternalId at Level 1 or Level 2 of metadata. + */ + filter.$expr = { + $gt: [ + { + $size: { + $filter: { + input: { $objectToArray: { $ifNull: ['$metadata', {}] } }, + as: 'item', + cond: { + $or: [ + // Check Level 1: metadata.dynamicKey.templateExternalId + { $in: ['$$item.v.templateExternalId', templateExternalIds] }, + // Check Level 2: metadata.dynamicKey.subDynamicKey.templateExternalId + { + $anyElementTrue: { + $map: { + input: { + $cond: [ + { $eq: [{ $type: '$$item.v' }, 'object'] }, + { $objectToArray: '$$item.v' }, + [], + ], + }, + as: 'subItem', + in: { + $in: [ + '$$subItem.v.templateExternalId', + templateExternalIds, + ], + }, + }, + }, + }, + ], + }, + }, + }, + }, + 0, + ], + } + } + + console.log('Final filter for program users list:', JSON.stringify(filter)) - // Get limit (required for both formats) - limit = parseInt(queryParams.limit) || parseInt(bodyData?.limit) || 10 + // 4. Pagination Logic + let limit = parseInt(queryParams.limit) || parseInt(bodyData?.limit) || 10 + let offset = 0 + let page = 1 - // Check if offset is provided (offset/limit format) if (queryParams.offset !== undefined || bodyData?.offset !== undefined) { offset = parseInt(queryParams.offset) || parseInt(bodyData?.offset) || 0 page = Math.floor(offset / limit) + 1 } else { - // Use page/limit format (default) page = parseInt(queryParams.page) || parseInt(bodyData?.page) || 1 offset = (page - 1) * limit } - // Sort + // 5. Execute Query const sortData = { createdAt: -1 } - - // Get paginated results using offset-based pagination const result = await programUsersQueries.listWithOffset(filter, 'all', 'none', offset, limit, sortData) return resolve({ success: true, - message: CONSTANTS.apiResponses.PROGRAM_USERS_FETCHED, + message: 'Program users fetched successfully', data: result.data, result: result.data, count: result.totalCount, @@ -481,10 +551,11 @@ module.exports = class ProgramUsersHelper { totalPages: result.totalPages, }) } catch (error) { + console.error('List Error:', error) return resolve({ success: false, message: error.message, - status: HTTP_STATUS_CODE.internal_server_error.status, + status: 500, }) } }) diff --git a/module/programUsers/validator/v1.js b/module/programUsers/validator/v1.js index 14e85184..f29859e7 100644 --- a/module/programUsers/validator/v1.js +++ b/module/programUsers/validator/v1.js @@ -45,7 +45,6 @@ module.exports = (req) => { // Metadata is optional if (req.body.metadata) { - // Instead of .isObject(), we use a custom check req.checkBody('metadata') .custom((val) => typeof val === 'object' && !Array.isArray(val) && val !== null) .withMessage('metadata must be an object') @@ -80,7 +79,10 @@ module.exports = (req) => { // Metadata is optional if (req.body.metadata) { - req.checkBody('metadata').isObject().withMessage('metadata must be an object') + // FIXED: Replaced .isObject() with .custom() + req.checkBody('metadata') + .custom((val) => typeof val === 'object' && !Array.isArray(val) && val !== null) + .withMessage('metadata must be an object') } }, @@ -183,7 +185,8 @@ module.exports = (req) => { .withMessage('metadata is required') .notEmpty() .withMessage('metadata cannot be empty') - .isObject() + // FIXED: Replaced .isObject() with .custom() + .custom((val) => typeof val === 'object' && !Array.isArray(val) && val !== null) .withMessage('metadata must be an object') }, From 56f15e7473b9698feeba5e4205cfd8e251740b3a Mon Sep 17 00:00:00 2001 From: Sachintechjoomla <92356209+Sachintechjoomla@users.noreply.github.com> Date: Mon, 29 Dec 2025 18:36:59 +0530 Subject: [PATCH 4/8] Issue#251228 Feat: Program User Status Mapping --- controllers/v1/programUsers.js | 254 ++++++-------------- databaseQueries/programUsers.js | 349 +++++++++++++--------------- document/programUsers/README.md | 90 +++---- generics/constants/api-responses.js | 1 + module/programUsers/helper.js | 99 +++++--- module/programUsers/validator/v1.js | 53 ++--- 6 files changed, 349 insertions(+), 497 deletions(-) diff --git a/controllers/v1/programUsers.js b/controllers/v1/programUsers.js index c9e2fbda..cf82ed7f 100644 --- a/controllers/v1/programUsers.js +++ b/controllers/v1/programUsers.js @@ -68,18 +68,16 @@ module.exports = class ProgramUsers extends Abstract { * @returns {JSON} - program user creation response. */ async create(req) { - return new Promise(async (resolve, reject) => { - try { - const result = await programUsersHelper.create(req.body, req.userDetails) - return resolve(result) - } catch (error) { - return resolve({ - status: error.status || HTTP_STATUS_CODE.internal_server_error.status, - message: error.message || HTTP_STATUS_CODE.internal_server_error.message, - errorObject: error, - }) + try { + const result = await programUsersHelper.create(req.body, req.userDetails) + return result + } catch (error) { + return { + status: error.status || HTTP_STATUS_CODE.internal_server_error.status, + message: error.message || HTTP_STATUS_CODE.internal_server_error.message, + errorObject: error, } - }) + } } /** @@ -120,18 +118,16 @@ module.exports = class ProgramUsers extends Abstract { * @returns {JSON} - program user update response. */ async update(req) { - return new Promise(async (resolve, reject) => { - try { - const result = await programUsersHelper.update(req.params._id, req.body, req.userDetails) - return resolve(result) - } catch (error) { - return resolve({ - status: error.status || HTTP_STATUS_CODE.internal_server_error.status, - message: error.message || HTTP_STATUS_CODE.internal_server_error.message, - errorObject: error, - }) + try { + const result = await programUsersHelper.update(req.params._id, req.body, req.userDetails) + return result + } catch (error) { + return { + status: error.status || HTTP_STATUS_CODE.internal_server_error.status, + message: error.message || HTTP_STATUS_CODE.internal_server_error.message, + errorObject: error, } - }) + } } /** @@ -171,35 +167,33 @@ module.exports = class ProgramUsers extends Abstract { * @returns {JSON} - program user details. */ async read(req) { - return new Promise(async (resolve, reject) => { - try { - // Check if it's a programId:userId pattern - if (req.params.programId && req.params.userId && !req.params._id) { - // Pattern: /read/:programId/:userId - const result = await programUsersHelper.readByProgramAndUserId( - req.params.programId, - req.params.userId, - req.userDetails - ) - return resolve(result) - } else if (req.params._id) { - // Pattern: /read/:_id - const result = await programUsersHelper.read(req.params._id, req.userDetails) - return resolve(result) - } else { - throw { - status: HTTP_STATUS_CODE.bad_request.status, - message: 'Invalid parameters. Use either /:_id or /:programId/:userId', - } + try { + // Check if it's a programId:userId pattern + if (req.params.programId && req.params.userId && !req.params._id) { + // Pattern: /read/:programId/:userId + const result = await programUsersHelper.readByProgramAndUserId( + req.params.programId, + req.params.userId, + req.userDetails + ) + return result + } else if (req.params._id) { + // Pattern: /read/:_id + const result = await programUsersHelper.read(req.params._id, req.userDetails) + return result + } else { + throw { + status: HTTP_STATUS_CODE.bad_request.status, + message: 'Invalid parameters. Use either /:_id or /:programId/:userId', } - } catch (error) { - return resolve({ - status: error.status || HTTP_STATUS_CODE.internal_server_error.status, - message: error.message || HTTP_STATUS_CODE.internal_server_error.message, - errorObject: error, - }) } - }) + } catch (error) { + return { + status: error.status || HTTP_STATUS_CODE.internal_server_error.status, + message: error.message || HTTP_STATUS_CODE.internal_server_error.message, + errorObject: error, + } + } } /** @@ -238,18 +232,16 @@ module.exports = class ProgramUsers extends Abstract { * @returns {JSON} - paginated list of program users. */ async list(req) { - return new Promise(async (resolve, reject) => { - try { - const result = await programUsersHelper.list(req.query, req.body, req.userDetails) - return resolve(result) - } catch (error) { - return resolve({ - status: error.status || HTTP_STATUS_CODE.internal_server_error.status, - message: error.message || HTTP_STATUS_CODE.internal_server_error.message, - errorObject: error, - }) + try { + const result = await programUsersHelper.list(req) + return result + } catch (error) { + return { + status: error.status || HTTP_STATUS_CODE.internal_server_error.status, + message: error.message || HTTP_STATUS_CODE.internal_server_error.message, + errorObject: error, } - }) + } } /** @@ -284,121 +276,19 @@ module.exports = class ProgramUsers extends Abstract { * @returns {JSON} - deletion response. */ async delete(req) { - return new Promise(async (resolve, reject) => { - try { - const result = await programUsersHelper.deleteResource(req.params._id, req.userDetails) - return resolve(result) - } catch (error) { - return resolve({ - status: error.status || HTTP_STATUS_CODE.internal_server_error.status, - message: error.message || HTTP_STATUS_CODE.internal_server_error.message, - errorObject: error, - }) - } - }) - } - - /** - * @api {patch} /project/v1/programUsers/updateStatus/:_id - * @apiVersion 1.0.0 - * @apiName updateStatus - * @apiGroup ProgramUsers - * @apiHeader {String} x-auth-token Authenticity token - * @apiSampleRequest /project/v1/programUsers/updateStatus/60a5e5d8f1b2c3d4e5f6a7b9 - * @apiUse successBody - * @apiUse errorBody - * @apiParamExample {json} Request-Body: - * { - * "status": "GRADUATED", - * "statusReason": "Completed all requirements" - * } - * @apiParamExample {json} Response: - * { - * "message": "Program user status updated successfully", - * "status": 200, - * "result": { - * "_id": "60a5e5d8f1b2c3d4e5f6a7b9", - * "status": "GRADUATED", - * "prevStatus": "COMPLETED" - * } - * } - */ - - /** - * Update program user status. - * @method - * @name updateStatus - * @param {Object} req - request object. - * @param {String} req.params._id - program user id. - * @param {Object} req.body - request body with status. - * @param {Object} req.userDetails - logged in user details. - * @returns {JSON} - status update response. - */ - async updateStatus(req) { - return new Promise(async (resolve, reject) => { - try { - const result = await programUsersHelper.updateStatus(req.params._id, req.body, req.userDetails) - return resolve(result) - } catch (error) { - return resolve({ - status: error.status || HTTP_STATUS_CODE.internal_server_error.status, - message: error.message || HTTP_STATUS_CODE.internal_server_error.message, - errorObject: error, - }) + try { + const result = await programUsersHelper.deleteResource(req.params._id, req.userDetails) + return result + } catch (error) { + return { + status: error.status || HTTP_STATUS_CODE.internal_server_error.status, + message: error.message || HTTP_STATUS_CODE.internal_server_error.message, + errorObject: error, } - }) + } } - /** - * @api {patch} /project/v1/programUsers/updateMetadata/:_id - * @apiVersion 1.0.0 - * @apiName updateMetadata - * @apiGroup ProgramUsers - * @apiHeader {String} x-auth-token Authenticity token - * @apiSampleRequest /project/v1/programUsers/updateMetadata/60a5e5d8f1b2c3d4e5f6a7b9 - * @apiUse successBody - * @apiUse errorBody - * @apiParamExample {json} Request-Body: - * { - * "metadata": { - * "externalIdOfBoardingCompletionCategory": { - * "templateExternalId": "template-123", - * "tasks": [{ "taskId": "task1", "completed": true }] - * } - * } - * } - * @apiParamExample {json} Response: - * { - * "message": "Program user metadata updated successfully", - * "status": 200, - * "result": {...} - * } - */ - - /** - * Update program user metadata. - * @method - * @name updateMetadata - * @param {Object} req - request object. - * @param {String} req.params._id - program user id. - * @param {Object} req.body - request body with metadata. - * @param {Object} req.userDetails - logged in user details. - * @returns {JSON} - metadata update response. - */ - async updateMetadata(req) { - return new Promise(async (resolve, reject) => { - try { - const result = await programUsersHelper.updateMetadata(req.params._id, req.body, req.userDetails) - return resolve(result) - } catch (error) { - return resolve({ - status: error.status || HTTP_STATUS_CODE.internal_server_error.status, - message: error.message || HTTP_STATUS_CODE.internal_server_error.message, - errorObject: error, - }) - } - }) - } + // Consolidated: status and metadata updates are handled by the `update` endpoint /** * @api {get} /project/v1/programUsers/getByProgramId/:_id @@ -432,17 +322,15 @@ module.exports = class ProgramUsers extends Abstract { * @returns {JSON} - list of program users for a program. */ async getByProgramId(req) { - return new Promise(async (resolve, reject) => { - try { - const result = await programUsersHelper.getByProgramId(req.params._id, req.query, req.userDetails) - return resolve(result) - } catch (error) { - return resolve({ - status: error.status || HTTP_STATUS_CODE.internal_server_error.status, - message: error.message || HTTP_STATUS_CODE.internal_server_error.message, - errorObject: error, - }) + try { + const result = await programUsersHelper.getByProgramId(req.params._id, req.query, req.userDetails) + return result + } catch (error) { + return { + status: error.status || HTTP_STATUS_CODE.internal_server_error.status, + message: error.message || HTTP_STATUS_CODE.internal_server_error.message, + errorObject: error, } - }) + } } } diff --git a/databaseQueries/programUsers.js b/databaseQueries/programUsers.js index d72cbf67..a844c5ad 100644 --- a/databaseQueries/programUsers.js +++ b/databaseQueries/programUsers.js @@ -15,30 +15,28 @@ module.exports = class ProgramUsers { * @param {Array} [skipFields = "none"] - fields not to include. * @returns {Array} program users details. */ - static programUsersDocument(filterData = 'all', fieldsArray = 'all', skipFields = 'none') { - return new Promise(async (resolve, reject) => { - try { - let queryObject = filterData != 'all' ? filterData : {} - let projection = {} + static async programUsersDocument(filterData = 'all', fieldsArray = 'all', skipFields = 'none') { + try { + let queryObject = filterData != 'all' ? filterData : {} + let projection = {} - if (fieldsArray != 'all') { - fieldsArray.forEach((field) => { - projection[field] = 1 - }) - } - - if (skipFields !== 'none') { - skipFields.forEach((field) => { - projection[field] = 0 - }) - } + if (fieldsArray != 'all') { + fieldsArray.forEach((field) => { + projection[field] = 1 + }) + } - let programJoinedData = await database.models.programUsers.find(queryObject, projection).lean() - return resolve(programJoinedData) - } catch (error) { - return reject(error) + if (skipFields !== 'none') { + skipFields.forEach((field) => { + projection[field] = 0 + }) } - }) + + let programJoinedData = await database.models.programUsers.find(queryObject, projection).lean() + return programJoinedData + } catch (error) { + throw error + } } /** @@ -48,15 +46,13 @@ module.exports = class ProgramUsers { * @param {Object} data - program user data to create. * @returns {Object} newly created program user document. */ - static create(data) { - return new Promise(async (resolve, reject) => { - try { - let programUserDocument = await database.models.programUsers.create(data) - return resolve(programUserDocument) - } catch (error) { - return reject(error) - } - }) + static async create(data) { + try { + let programUserDocument = await database.models.programUsers.create(data) + return programUserDocument + } catch (error) { + throw error + } } /** @@ -68,30 +64,28 @@ module.exports = class ProgramUsers { * @param {Array} [skipFields = "none"] - fields not to include. * @returns {Object} program user details. */ - static findOne(filterData = 'all', fieldsArray = 'all', skipFields = 'none') { - return new Promise(async (resolve, reject) => { - try { - let queryObject = filterData != 'all' ? filterData : {} - let projection = {} + static async findOne(filterData = 'all', fieldsArray = 'all', skipFields = 'none') { + try { + let queryObject = filterData != 'all' ? filterData : {} + let projection = {} - if (fieldsArray != 'all') { - fieldsArray.forEach((field) => { - projection[field] = 1 - }) - } - - if (skipFields !== 'none') { - skipFields.forEach((field) => { - projection[field] = 0 - }) - } + if (fieldsArray != 'all') { + fieldsArray.forEach((field) => { + projection[field] = 1 + }) + } - let programUserData = await database.models.programUsers.findOne(queryObject, projection).lean() - return resolve(programUserData) - } catch (error) { - return reject(error) + if (skipFields !== 'none') { + skipFields.forEach((field) => { + projection[field] = 0 + }) } - }) + + let programUserData = await database.models.programUsers.findOne(queryObject, projection).lean() + return programUserData + } catch (error) { + throw error + } } /** @@ -103,17 +97,15 @@ module.exports = class ProgramUsers { * @param {Object} [options = { new: true }] - options for the update. * @returns {Object} updated program user document. */ - static findOneAndUpdate(filterData, updateData, options = { new: true }) { - return new Promise(async (resolve, reject) => { - try { - let updatedDocument = await database.models.programUsers - .findOneAndUpdate(filterData, updateData, options) - .lean() - return resolve(updatedDocument) - } catch (error) { - return reject(error) - } - }) + static async findOneAndUpdate(filterData, updateData, options = { new: true }) { + try { + let updatedDocument = await database.models.programUsers + .findOneAndUpdate(filterData, updateData, options) + .lean() + return updatedDocument + } catch (error) { + throw error + } } /** @@ -124,15 +116,13 @@ module.exports = class ProgramUsers { * @param {Object} updateData - data to update. * @returns {Object} update result. */ - static updateMany(filterData, updateData) { - return new Promise(async (resolve, reject) => { - try { - let updateResult = await database.models.programUsers.updateMany(filterData, updateData) - return resolve(updateResult) - } catch (error) { - return reject(error) - } - }) + static async updateMany(filterData, updateData) { + try { + let updateResult = await database.models.programUsers.updateMany(filterData, updateData) + return updateResult + } catch (error) { + throw error + } } /** @@ -142,15 +132,13 @@ module.exports = class ProgramUsers { * @param {Object} filterData - filter query. * @returns {Object} deletion result. */ - static deleteOne(filterData) { - return new Promise(async (resolve, reject) => { - try { - let deleteResult = await database.models.programUsers.deleteOne(filterData) - return resolve(deleteResult) - } catch (error) { - return reject(error) - } - }) + static async deleteOne(filterData) { + try { + let deleteResult = await database.models.programUsers.deleteOne(filterData) + return deleteResult + } catch (error) { + throw error + } } /** @@ -160,15 +148,13 @@ module.exports = class ProgramUsers { * @param {Object} filterData - filter query. * @returns {Object} deletion result. */ - static deleteMany(filterData) { - return new Promise(async (resolve, reject) => { - try { - let deleteResult = await database.models.programUsers.deleteMany(filterData) - return resolve(deleteResult) - } catch (error) { - return reject(error) - } - }) + static async deleteMany(filterData) { + try { + let deleteResult = await database.models.programUsers.deleteMany(filterData) + return deleteResult + } catch (error) { + throw error + } } /** @@ -178,15 +164,13 @@ module.exports = class ProgramUsers { * @param {Object} [filterData = {}] - filter query. * @returns {Number} count of documents. */ - static count(filterData = {}) { - return new Promise(async (resolve, reject) => { - try { - let count = await database.models.programUsers.countDocuments(filterData) - return resolve(count) - } catch (error) { - return reject(error) - } - }) + static async count(filterData = {}) { + try { + let count = await database.models.programUsers.countDocuments(filterData) + return count + } catch (error) { + throw error + } } /** @@ -197,57 +181,50 @@ module.exports = class ProgramUsers { * @param {Array} [fieldsArray = "all"] - projected fields. * @param {Array} [skipFields = "none"] - fields not to include. * @param {Number} [page = 1] - page number. - * @param {Number} [limit = 10] - records per page. + * @param {Number} [limit = 20] - records per page. * @param {Object} [sortData = { createdAt: -1 }] - sort criteria. * @returns {Object} paginated program users data with count. */ - static list( + static async list( filterData = {}, fieldsArray = 'all', skipFields = 'none', page = 1, - limit = 10, + limit = 20, sortData = { createdAt: -1 } ) { - return new Promise(async (resolve, reject) => { - try { - let projection = {} + try { + let projection = {} - if (fieldsArray != 'all') { - fieldsArray.forEach((field) => { - projection[field] = 1 - }) - } + if (fieldsArray != 'all') { + fieldsArray.forEach((field) => { + projection[field] = 1 + }) + } - if (skipFields !== 'none') { - skipFields.forEach((field) => { - projection[field] = 0 - }) - } + if (skipFields !== 'none') { + skipFields.forEach((field) => { + projection[field] = 0 + }) + } - const skip = (page - 1) * limit + const skip = (page - 1) * limit - const [data, totalCount] = await Promise.all([ - database.models.programUsers - .find(filterData, projection) - .sort(sortData) - .skip(skip) - .limit(limit) - .lean(), - database.models.programUsers.countDocuments(filterData), - ]) + const [data, totalCount] = await Promise.all([ + database.models.programUsers.find(filterData, projection).sort(sortData).skip(skip).limit(limit).lean(), + database.models.programUsers.countDocuments(filterData), + ]) - return resolve({ - data, - totalCount, - page, - limit, - totalPages: Math.ceil(totalCount / limit), - }) - } catch (error) { - return reject(error) + return { + data, + totalCount, + page, + limit, + totalPages: Math.ceil(totalCount / limit), } - }) + } catch (error) { + throw error + } } /** @@ -262,7 +239,7 @@ module.exports = class ProgramUsers { * @param {Object} [sortData = { createdAt: -1 }] - sort criteria. * @returns {Object} paginated program users data with count. */ - static listWithOffset( + static async listWithOffset( filterData = {}, fieldsArray = 'all', skipFields = 'none', @@ -270,48 +247,46 @@ module.exports = class ProgramUsers { limit = 10, sortData = { createdAt: -1 } ) { - return new Promise(async (resolve, reject) => { - try { - let projection = {} + try { + let projection = {} - if (fieldsArray != 'all') { - fieldsArray.forEach((field) => { - projection[field] = 1 - }) - } + if (fieldsArray != 'all') { + fieldsArray.forEach((field) => { + projection[field] = 1 + }) + } - if (skipFields !== 'none') { - skipFields.forEach((field) => { - projection[field] = 0 - }) - } + if (skipFields !== 'none') { + skipFields.forEach((field) => { + projection[field] = 0 + }) + } - const [data, totalCount] = await Promise.all([ - database.models.programUsers - .find(filterData, projection) - .sort(sortData) - .skip(offset) - .limit(limit) - .lean(), - database.models.programUsers.countDocuments(filterData), - ]) + const [data, totalCount] = await Promise.all([ + database.models.programUsers + .find(filterData, projection) + .sort(sortData) + .skip(offset) + .limit(limit) + .lean(), + database.models.programUsers.countDocuments(filterData), + ]) - // Calculate page info for response - const page = Math.floor(offset / limit) + 1 - const totalPages = Math.ceil(totalCount / limit) + // Calculate page info for response + const page = Math.floor(offset / limit) + 1 + const totalPages = Math.ceil(totalCount / limit) - return resolve({ - data, - totalCount, - page, - limit, - offset, - totalPages, - }) - } catch (error) { - return reject(error) + return { + data, + totalCount, + page, + limit, + offset, + totalPages, } - }) + } catch (error) { + throw error + } } /** @@ -321,15 +296,13 @@ module.exports = class ProgramUsers { * @param {Array} pipeline - aggregation pipeline. * @returns {Array} aggregation result. */ - static aggregate(pipeline) { - return new Promise(async (resolve, reject) => { - try { - let result = await database.models.programUsers.aggregate(pipeline) - return resolve(result) - } catch (error) { - return reject(error) - } - }) + static async aggregate(pipeline) { + try { + let result = await database.models.programUsers.aggregate(pipeline) + return result + } catch (error) { + throw error + } } /** @@ -340,14 +313,12 @@ module.exports = class ProgramUsers { * @param {Object} [options = {}] - bulk write options. * @returns {Object} bulk write result. */ - static bulkWrite(operations, options = {}) { - return new Promise(async (resolve, reject) => { - try { - let result = await database.models.programUsers.bulkWrite(operations, options) - return resolve(result) - } catch (error) { - return reject(error) - } - }) + static async bulkWrite(operations, options = {}) { + try { + let result = await database.models.programUsers.bulkWrite(operations, options) + return result + } catch (error) { + throw error + } } } diff --git a/document/programUsers/README.md b/document/programUsers/README.md index 43193b45..06e44f7a 100644 --- a/document/programUsers/README.md +++ b/document/programUsers/README.md @@ -116,17 +116,15 @@ The token is decoded to extract: ### Endpoints Summary -| Method | Endpoint | Description | -| ------ | -------------------------- | --------------------------------------------------- | -| POST | `/create` | Create new program user | -| PATCH | `/update/:_id` | Update program user | -| GET | `/read/:_id` | Get program user by ID | -| GET | `/read/:programId/:userId` | Read particular program user by program and user ID | -| POST | `/list` | List program users with filters | -| DELETE | `/delete/:_id` | Delete program user | -| PATCH | `/updateStatus/:_id` | Update status with validation | -| PATCH | `/updateMetadata/:_id` | Update metadata | -| GET | `/getByProgramId/:_id` | Get users by program ID | +| Method | Endpoint | Description | +| ------ | -------------------------- | ---------------------------------------------------- | +| POST | `/create` | Create new program user | +| PATCH | `/update/:_id` | Update program user (status, metadata, other fields) | +| GET | `/read/:_id` | Get program user by ID | +| GET | `/read/:programId/:userId` | Read particular program user by program and user ID | +| POST | `/list` | List program users with filters | +| DELETE | `/delete/:_id` | Delete program user | +| GET | `/getByProgramId/:_id` | Get users by program ID | --- @@ -170,44 +168,46 @@ Response: } ``` -### 2. Update Status +### 2. Update (Status or Metadata) + +Note: The separate `updateStatus` API has been removed. Use the consolidated `PATCH /project/v1/programUsers/update/:_id` endpoint for status and metadata updates. + +Use the single update endpoint to modify status, metadata, or other mutable fields. When changing `status`, the request MUST include a non-empty `statusReason` string explaining the reason for the change. ```bash -PATCH /project/v1/programUsers/updateStatus/507f1f77bcf86cd799439011 +PATCH /project/v1/programUsers/update/507f1f77bcf86cd799439011 Headers: x-auth-token: Content-Type: application/json -Body: +Body (update status): { "status": "ONBOARDED", "statusReason": "Completed onboarding process" } +Body (update metadata): +{ + "metadata": { + "externalIdOfBoardingCompletionCategory": { + "templateExternalId": "onboarding-template-001", + "tasks": [ + { "taskId": "task-001", "completed": true, "completedAt": "2024-12-18T10:00:00Z" } + ] + } + } +} + Response (Success): { "success": true, - "message": "Program user status updated successfully", + "message": "Program user updated successfully", "result": { "_id": "507f1f77bcf86cd799439011", "status": "ONBOARDED", - "prevStatus": "NOT_ONBOARDED", - "validNextStatuses": ["IN_PROGRESS", "DROPPED_OUT"], ... } } - -Response (Invalid Transition): -{ - "success": false, - "message": "Cannot skip status. Current: NOT_ONBOARDED, Expected next: ONBOARDED, Attempted: COMPLETED", - "data": { - "currentStatus": "NOT_ONBOARDED", - "attemptedStatus": "COMPLETED", - "validNextStatuses": ["ONBOARDED", "DROPPED_OUT"], - "statusFlow": "NOT_ONBOARDED → ONBOARDED → IN_PROGRESS → COMPLETED → GRADUATED (DROPPED_OUT from any except GRADUATED)" - } -} ``` ### 3. List Program Users @@ -431,38 +431,6 @@ Body: } ``` -### 4. Update Metadata - -```bash -PATCH /project/v1/programUsers/updateMetadata/507f1f77bcf86cd799439011 -Headers: - x-auth-token: - Content-Type: application/json - -Body: -{ - "metadata": { - "externalIdOfBoardingCompletionCategory": { - "templateExternalId": "onboarding-template-001", - "tasks": [ - { "taskId": "task-001", "completed": true, "completedAt": "2024-12-18T10:00:00Z" } - ] - }, - "observationInfo": { - "midlineSurveyCount": 1, - "midlineSurveyComplete": true - } - } -} - -Response: -{ - "success": true, - "message": "Program user metadata updated successfully", - "result": {...} -} -``` - ### 5. Delete Program User (Standard DELETE Pattern) ```bash diff --git a/generics/constants/api-responses.js b/generics/constants/api-responses.js index 11942d46..cab89fe1 100644 --- a/generics/constants/api-responses.js +++ b/generics/constants/api-responses.js @@ -337,6 +337,7 @@ module.exports = { PROGRAM_USER_ID_REQUIRED: 'Program user ID is required', PROGRAM_ID_REQUIRED: 'Program ID is required', STATUS_REQUIRED: 'Status is required', + STATUS_REASON_REQUIRED: 'Status reason is required', METADATA_REQUIRED: 'Metadata is required', PROGRAM_USER_STATUS_UPDATED: 'Program user status updated successfully', PROGRAM_USER_STATUS_NOT_UPDATED: 'Failed to update program user status', diff --git a/module/programUsers/helper.js b/module/programUsers/helper.js index 225d0f41..3d03d9e9 100644 --- a/module/programUsers/helper.js +++ b/module/programUsers/helper.js @@ -273,20 +273,45 @@ module.exports = class ProgramUsersHelper { // Validate status transition if status is being changed if (bodyData.status && bodyData.status !== currentProgramUser.status) { - const validation = this.validateStatusTransition(currentProgramUser.status, bodyData.status) - if (!validation.valid) { - const validNextStatuses = this.getValidNextStatuses(currentProgramUser.status) + // Ensure reason for status change is provided + if ( + !bodyData.statusReason || + (typeof bodyData.statusReason === 'string' && bodyData.statusReason.trim() === '') + ) { return resolve({ success: false, status: HTTP_STATUS_CODE.bad_request.status, - message: validation.message, - data: { - currentStatus: currentProgramUser.status, - attemptedStatus: bodyData.status, - validNextStatuses: validNextStatuses, - }, + message: CONSTANTS.apiResponses.STATUS_REASON_REQUIRED, }) } + // Allow override by admins or tenant admins, or by supervisors when explicit 'force' is provided + const roles = + (userDetails && userDetails.userInformation && userDetails.userInformation.roles) || [] + const isAdmin = + roles.includes(CONSTANTS.common.ADMIN_ROLE) || roles.includes(CONSTANTS.common.TENANT_ADMIN) + const isSupervisor = roles.some( + (r) => typeof r === 'string' && r.toLowerCase().includes('supervisor') + ) + const allowBypass = isAdmin || (isSupervisor && bodyData.force === true) + + if (!allowBypass) { + const validation = this.validateStatusTransition(currentProgramUser.status, bodyData.status) + if (!validation.valid) { + const validNextStatuses = this.getValidNextStatuses(currentProgramUser.status) + return resolve({ + success: false, + status: HTTP_STATUS_CODE.bad_request.status, + message: validation.message, + data: { + currentStatus: currentProgramUser.status, + attemptedStatus: bodyData.status, + validNextStatuses: validNextStatuses, + }, + }) + } + } else { + // Bypass allowed: continue and let updateData record prevStatus as usual + } } // Prepare update data @@ -400,14 +425,23 @@ module.exports = class ProgramUsersHelper { * List program users with filters and pagination. * @method * @name list - * @param {Object} queryParams - query parameters for filtering. - * @param {Object} bodyData - request body data for additional filters. - * @param {Object} userDetails - logged in user details (from decoded token). + * @param {Object} req - Express request object with query, body, userDetails * @returns {Object} paginated list of program users. */ - static list(queryParams, bodyData, userDetails) { + static list(req) { return new Promise(async (resolve, reject) => { try { + // Extract from request + const queryParams = req.query || {} + const bodyData = req.body || {} + const userDetails = req.userDetails || {} + + // Extract pagination and filter params + const pageSize = req.pageSize || parseInt(queryParams.limit) || parseInt(bodyData?.limit) || 20 + const pageNo = req.pageNo || parseInt(queryParams.page) || parseInt(bodyData?.page) || 1 + const searchText = req.searchText || queryParams.search || bodyData?.search || '' + const sortData = queryParams.sort || bodyData?.sort || { createdAt: -1 } + const tenantId = userDetails.userInformation.tenantId // 1. Build base filter query @@ -520,24 +554,19 @@ module.exports = class ProgramUsersHelper { } } - console.log('Final filter for program users list:', JSON.stringify(filter)) - - // 4. Pagination Logic - let limit = parseInt(queryParams.limit) || parseInt(bodyData?.limit) || 10 - let offset = 0 - let page = 1 - - if (queryParams.offset !== undefined || bodyData?.offset !== undefined) { - offset = parseInt(queryParams.offset) || parseInt(bodyData?.offset) || 0 - page = Math.floor(offset / limit) + 1 - } else { - page = parseInt(queryParams.page) || parseInt(bodyData?.page) || 1 - offset = (page - 1) * limit - } + // 4. Calculate offset from page and limit + const offset = (pageNo - 1) * pageSize // 5. Execute Query - const sortData = { createdAt: -1 } - const result = await programUsersQueries.listWithOffset(filter, 'all', 'none', offset, limit, sortData) + const finalSort = typeof sortData === 'string' ? { createdAt: -1 } : sortData || { createdAt: -1 } + const result = await programUsersQueries.listWithOffset( + filter, + 'all', + 'none', + offset, + pageSize, + finalSort + ) return resolve({ success: true, @@ -680,6 +709,18 @@ module.exports = class ProgramUsersHelper { }) } + // Ensure reason for status change is provided + if ( + !bodyData.statusReason || + (typeof bodyData.statusReason === 'string' && bodyData.statusReason.trim() === '') + ) { + return resolve({ + success: false, + status: HTTP_STATUS_CODE.bad_request.status, + message: CONSTANTS.apiResponses.STATUS_REASON_REQUIRED, + }) + } + // Validate status transition const validation = this.validateStatusTransition(currentProgramUser.status, bodyData.status) if (!validation.valid) { diff --git a/module/programUsers/validator/v1.js b/module/programUsers/validator/v1.js index f29859e7..3febbbe3 100644 --- a/module/programUsers/validator/v1.js +++ b/module/programUsers/validator/v1.js @@ -41,6 +41,15 @@ module.exports = (req) => { .withMessage( 'status must be one of: NOT_ONBOARDED, ONBOARDED, IN_PROGRESS, COMPLETED, GRADUATED, DROPPED_OUT' ) + + // Require reason for status change + req.checkBody('statusReason') + .exists() + .withMessage('statusReason is required') + .notEmpty() + .withMessage('statusReason cannot be empty') + .isString() + .withMessage('statusReason must be a string') } // Metadata is optional @@ -75,6 +84,15 @@ module.exports = (req) => { .withMessage( 'status must be one of: NOT_ONBOARDED, ONBOARDED, IN_PROGRESS, COMPLETED, GRADUATED, DROPPED_OUT' ) + + // Require reason for status change + req.checkBody('statusReason') + .exists() + .withMessage('statusReason is required') + .notEmpty() + .withMessage('statusReason cannot be empty') + .isString() + .withMessage('statusReason must be a string') } // Metadata is optional @@ -140,34 +158,6 @@ module.exports = (req) => { .withMessage('_id must be a valid MongoDB ObjectId') }, - /** - * Validator for updateStatus endpoint - */ - updateStatus: function () { - req.checkParams('_id') - .exists() - .withMessage('_id is required') - .notEmpty() - .withMessage('_id cannot be empty') - .isMongoId() - .withMessage('_id must be a valid MongoDB ObjectId') - - req.checkBody('status') - .exists() - .withMessage('status is required') - .notEmpty() - .withMessage('status cannot be empty') - .isIn(['NOT_ONBOARDED', 'ONBOARDED', 'IN_PROGRESS', 'COMPLETED', 'GRADUATED', 'DROPPED_OUT']) - .withMessage( - 'status must be one of: NOT_ONBOARDED, ONBOARDED, IN_PROGRESS, COMPLETED, GRADUATED, DROPPED_OUT' - ) - - // StatusReason is optional - if (req.body.statusReason) { - req.checkBody('statusReason').isString().withMessage('statusReason must be a string') - } - }, - /** * Validator for updateMetadata endpoint */ @@ -227,13 +217,6 @@ module.exports = (req) => { getStatusFlow: function () { // No validation required for this endpoint }, - - /** - * Validator for mock endpoint - no validation needed - */ - mock: function () { - // No validation required for this endpoint - }, } // Execute the validator for the current method From 4dbbe8a621b39900006c7902440c113bea98d881 Mon Sep 17 00:00:00 2001 From: Sachintechjoomla <92356209+Sachintechjoomla@users.noreply.github.com> Date: Mon, 29 Dec 2025 19:15:35 +0530 Subject: [PATCH 5/8] Issue#251228 Feat: Program User Status Mapping --- controllers/v1/programUsers.js | 4 +- document/programUsers/README.md | 11 ++ models/programUsers.js | 23 +-- module/programUsers/helper.js | 223 ++-------------------------- module/programUsers/validator/v1.js | 29 ---- 5 files changed, 31 insertions(+), 259 deletions(-) diff --git a/controllers/v1/programUsers.js b/controllers/v1/programUsers.js index cf82ed7f..381adfc3 100644 --- a/controllers/v1/programUsers.js +++ b/controllers/v1/programUsers.js @@ -2,7 +2,7 @@ * name : programUsers.js * author : Ankit Shahu * created-date : 9-Jan-2023 - * Description : Program Users Controller - CRUD operations for program-user mappings. + * Description : PII data related controller. */ // Dependencies @@ -288,8 +288,6 @@ module.exports = class ProgramUsers extends Abstract { } } - // Consolidated: status and metadata updates are handled by the `update` endpoint - /** * @api {get} /project/v1/programUsers/getByProgramId/:_id * @apiVersion 1.0.0 diff --git a/document/programUsers/README.md b/document/programUsers/README.md index 06e44f7a..60a32641 100644 --- a/document/programUsers/README.md +++ b/document/programUsers/README.md @@ -174,6 +174,17 @@ Note: The separate `updateStatus` API has been removed. Use the consolidated `PA Use the single update endpoint to modify status, metadata, or other mutable fields. When changing `status`, the request MUST include a non-empty `statusReason` string explaining the reason for the change. +Behavior details: + +- **Status validation & bypass:** Status transitions are validated to ensure they follow the allowed flow (no rollback, no skipping). By default, invalid transitions are rejected. However, the API allows controlled bypasses: + + - **Admins / Tenant Admins:** bypass validation automatically. + - **Supervisors:** may bypass only when the request includes `"force": true` in the body. Use bypasses sparingly and with audit controls. + +- **Metadata updates (merge behavior):** When `metadata` is provided in the `PATCH /update/:_id` body, the server performs a shallow merge between the existing `metadata` object and the incoming `metadata` object. The merge is performed as `{ ...existingMetadata, ...incomingMetadata }` — i.e., keys in the incoming `metadata` override top-level keys in the existing object. If you expect nested/deep merges, request a change to use a deep-merge strategy (e.g., `lodash.merge`). + +- **Audit recommendation:** Because bypasses allow skipping status validation, it is recommended to record an audit entry whenever a bypass occurs. Useful audit fields: `performedBy`, `performedByRoles`, `prevStatus`, `newStatus`, `statusReason`, `force` (true/false), and `timestamp`. + ```bash PATCH /project/v1/programUsers/update/507f1f77bcf86cd799439011 Headers: diff --git a/models/programUsers.js b/models/programUsers.js index 09a42e82..e0e48399 100644 --- a/models/programUsers.js +++ b/models/programUsers.js @@ -15,37 +15,28 @@ module.exports = { }, userId: { type: String, - required: true, index: true, + required: true, }, resourcesStarted: { type: Boolean, - default: false, index: true, + default: false, }, userProfile: { type: Object, required: true, }, - userRoleInformation: { - type: Object, + userRoleInformation: Object, + appInformation: Object, + consentShared: { + type: Boolean, + default: false, }, - - // Unified hierarchical metadata structure (project categories → templates → tasks) - // Optional: If passed during creation, use it; otherwise keep empty metadata: { type: Object, default: {}, }, - - appInformation: { - type: Object, - }, - consentShared: { - type: Boolean, - default: false, - }, - // User status in the program status: { type: String, diff --git a/module/programUsers/helper.js b/module/programUsers/helper.js index 3d03d9e9..422bcd10 100644 --- a/module/programUsers/helper.js +++ b/module/programUsers/helper.js @@ -333,6 +333,18 @@ module.exports = class ProgramUsersHelper { updateData.$set.updatedBy = tokenUserId // Add remaining fields to update + // If metadata is provided, merge with existing metadata instead of replacing + if (bodyData.metadata && typeof bodyData.metadata === 'object') { + const existingMetadata = currentProgramUser.metadata || {} + const mergedMetadata = { + ...existingMetadata, + ...bodyData.metadata, + } + updateData.$set.metadata = mergedMetadata + // remove from bodyData so it's not copied again + delete bodyData.metadata + } + Object.keys(bodyData).forEach((key) => { updateData.$set[key] = bodyData[key] }) @@ -656,217 +668,6 @@ module.exports = class ProgramUsersHelper { }) } - /** - * Update program user status with validation. - * Status must follow order: NOT_ONBOARDED → ONBOARDED → IN_PROGRESS → COMPLETED → GRADUATED - * DROPPED_OUT can happen from any state except GRADUATED - * No rollback allowed - * @method - * @name updateStatus - * @param {String} _id - program user id. - * @param {Object} bodyData - request body with new status. - * @param {Object} userDetails - logged in user details (from decoded token). - * @returns {Object} updated program user document. - */ - static updateStatus(_id, bodyData, userDetails) { - return new Promise(async (resolve, reject) => { - try { - // Validate _id field - _id = _id === ':_id' ? null : _id - - if (!_id) { - return resolve({ - success: false, - status: HTTP_STATUS_CODE.bad_request.status, - message: CONSTANTS.apiResponses.PROGRAM_USER_ID_REQUIRED, - }) - } - - if (!bodyData.status) { - return resolve({ - success: false, - status: HTTP_STATUS_CODE.bad_request.status, - message: CONSTANTS.apiResponses.STATUS_REQUIRED, - }) - } - - const tokenUserId = userDetails.userInformation.userId - const tenantId = userDetails.userInformation.tenantId - - const filter = { - _id: ObjectId(_id), - tenantId: tenantId, - } - - // Get current program user - const currentProgramUser = await programUsersQueries.findOne(filter) - - if (!currentProgramUser || !currentProgramUser._id) { - return resolve({ - success: false, - status: HTTP_STATUS_CODE.not_found.status, - message: CONSTANTS.apiResponses.PROGRAM_USER_NOT_FOUND, - }) - } - - // Ensure reason for status change is provided - if ( - !bodyData.statusReason || - (typeof bodyData.statusReason === 'string' && bodyData.statusReason.trim() === '') - ) { - return resolve({ - success: false, - status: HTTP_STATUS_CODE.bad_request.status, - message: CONSTANTS.apiResponses.STATUS_REASON_REQUIRED, - }) - } - - // Validate status transition - const validation = this.validateStatusTransition(currentProgramUser.status, bodyData.status) - if (!validation.valid) { - const validNextStatuses = this.getValidNextStatuses(currentProgramUser.status) - return resolve({ - success: false, - status: HTTP_STATUS_CODE.bad_request.status, - message: validation.message, - data: { - currentStatus: currentProgramUser.status, - attemptedStatus: bodyData.status, - validNextStatuses: validNextStatuses, - statusFlow: - STATUS_ORDER.slice(0, 5).join(' → ') + ' (DROPPED_OUT from any except GRADUATED)', - }, - }) - } - - // Prepare update data - const updateData = { - $set: { - prevStatus: currentProgramUser.status, - status: bodyData.status, - statusReason: bodyData.statusReason || null, - updatedBy: tokenUserId, - }, - } - - const updatedProgramUser = await programUsersQueries.findOneAndUpdate(filter, updateData, { new: true }) - - if (!updatedProgramUser || !updatedProgramUser._id) { - return resolve({ - success: false, - status: HTTP_STATUS_CODE.bad_request.status, - message: CONSTANTS.apiResponses.PROGRAM_USER_STATUS_NOT_UPDATED, - }) - } - - // Add valid next statuses to response - updatedProgramUser.validNextStatuses = this.getValidNextStatuses(updatedProgramUser.status) - - return resolve({ - success: true, - message: CONSTANTS.apiResponses.PROGRAM_USER_STATUS_UPDATED, - data: updatedProgramUser, - result: updatedProgramUser, - }) - } catch (error) { - return resolve({ - success: false, - message: error.message, - status: HTTP_STATUS_CODE.internal_server_error.status, - }) - } - }) - } - - /** - * Update program user metadata. - * @method - * @name updateMetadata - * @param {String} _id - program user id. - * @param {Object} bodyData - request body with metadata updates. - * @param {Object} userDetails - logged in user details (from decoded token). - * @returns {Object} updated program user document. - */ - static updateMetadata(_id, bodyData, userDetails) { - return new Promise(async (resolve, reject) => { - try { - // Validate _id field - _id = _id === ':_id' ? null : _id - - if (!_id) { - return resolve({ - success: false, - status: HTTP_STATUS_CODE.bad_request.status, - message: CONSTANTS.apiResponses.PROGRAM_USER_ID_REQUIRED, - }) - } - - if (!bodyData.metadata) { - return resolve({ - success: false, - status: HTTP_STATUS_CODE.bad_request.status, - message: CONSTANTS.apiResponses.METADATA_REQUIRED, - }) - } - - const tokenUserId = userDetails.userInformation.userId - const tenantId = userDetails.userInformation.tenantId - - const filter = { - _id: ObjectId(_id), - tenantId: tenantId, - } - - // Check if program user exists - const programUser = await programUsersQueries.findOne(filter) - - if (!programUser || !programUser._id) { - return resolve({ - success: false, - status: HTTP_STATUS_CODE.not_found.status, - message: CONSTANTS.apiResponses.PROGRAM_USER_NOT_FOUND, - }) - } - - // Merge metadata with existing metadata - const updatedMetadata = { - ...programUser.metadata, - ...bodyData.metadata, - } - - const updateData = { - $set: { - metadata: updatedMetadata, - updatedBy: tokenUserId, - }, - } - - const updatedProgramUser = await programUsersQueries.findOneAndUpdate(filter, updateData, { new: true }) - - if (!updatedProgramUser || !updatedProgramUser._id) { - return resolve({ - success: false, - status: HTTP_STATUS_CODE.bad_request.status, - message: CONSTANTS.apiResponses.PROGRAM_USER_METADATA_NOT_UPDATED, - }) - } - - return resolve({ - success: true, - message: CONSTANTS.apiResponses.PROGRAM_USER_METADATA_UPDATED, - data: updatedProgramUser, - result: updatedProgramUser, - }) - } catch (error) { - return resolve({ - success: false, - message: error.message, - status: HTTP_STATUS_CODE.internal_server_error.status, - }) - } - }) - } - /** * Get program users by program ID. * @method diff --git a/module/programUsers/validator/v1.js b/module/programUsers/validator/v1.js index 3febbbe3..dd2272d9 100644 --- a/module/programUsers/validator/v1.js +++ b/module/programUsers/validator/v1.js @@ -158,28 +158,6 @@ module.exports = (req) => { .withMessage('_id must be a valid MongoDB ObjectId') }, - /** - * Validator for updateMetadata endpoint - */ - updateMetadata: function () { - req.checkParams('_id') - .exists() - .withMessage('_id is required') - .notEmpty() - .withMessage('_id cannot be empty') - .isMongoId() - .withMessage('_id must be a valid MongoDB ObjectId') - - req.checkBody('metadata') - .exists() - .withMessage('metadata is required') - .notEmpty() - .withMessage('metadata cannot be empty') - // FIXED: Replaced .isObject() with .custom() - .custom((val) => typeof val === 'object' && !Array.isArray(val) && val !== null) - .withMessage('metadata must be an object') - }, - /** * Validator for getByProgramId endpoint */ @@ -210,13 +188,6 @@ module.exports = (req) => { .withMessage('limit must be a positive integer between 1 and 100') } }, - - /** - * Validator for getStatusFlow endpoint - no validation needed - */ - getStatusFlow: function () { - // No validation required for this endpoint - }, } // Execute the validator for the current method From 03f9405827867691d2a244ec95cd2487b9e44bfc Mon Sep 17 00:00:00 2001 From: Sachintechjoomla <92356209+Sachintechjoomla@users.noreply.github.com> Date: Mon, 29 Dec 2025 19:21:57 +0530 Subject: [PATCH 6/8] fix: format helper call --- databaseQueries/programUsers.js | 28 +++++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/databaseQueries/programUsers.js b/databaseQueries/programUsers.js index a844c5ad..6d2d4810 100644 --- a/databaseQueries/programUsers.js +++ b/databaseQueries/programUsers.js @@ -15,7 +15,11 @@ module.exports = class ProgramUsers { * @param {Array} [skipFields = "none"] - fields not to include. * @returns {Array} program users details. */ - static async programUsersDocument(filterData = 'all', fieldsArray = 'all', skipFields = 'none') { + static async programUsersDocument( + filterData = 'all', + fieldsArray = 'all', + skipFields = 'none' + ) { try { let queryObject = filterData != 'all' ? filterData : {} let projection = {} @@ -64,7 +68,11 @@ module.exports = class ProgramUsers { * @param {Array} [skipFields = "none"] - fields not to include. * @returns {Object} program user details. */ - static async findOne(filterData = 'all', fieldsArray = 'all', skipFields = 'none') { + static async findOne( + filterData = 'all', + fieldsArray = 'all', + skipFields = 'none' + ) { try { let queryObject = filterData != 'all' ? filterData : {} let projection = {} @@ -97,7 +105,11 @@ module.exports = class ProgramUsers { * @param {Object} [options = { new: true }] - options for the update. * @returns {Object} updated program user document. */ - static async findOneAndUpdate(filterData, updateData, options = { new: true }) { + static async findOneAndUpdate( + filterData, + updateData, + options = { new: true } + ) { try { let updatedDocument = await database.models.programUsers .findOneAndUpdate(filterData, updateData, options) @@ -116,7 +128,10 @@ module.exports = class ProgramUsers { * @param {Object} updateData - data to update. * @returns {Object} update result. */ - static async updateMany(filterData, updateData) { + static async updateMany( + filterData, + updateData + ) { try { let updateResult = await database.models.programUsers.updateMany(filterData, updateData) return updateResult @@ -313,7 +328,10 @@ module.exports = class ProgramUsers { * @param {Object} [options = {}] - bulk write options. * @returns {Object} bulk write result. */ - static async bulkWrite(operations, options = {}) { + static async bulkWrite( + operations, + options = {} + ) { try { let result = await database.models.programUsers.bulkWrite(operations, options) return result From 3ddff1d9d249d352811e4e83c068f5d51a305ba3 Mon Sep 17 00:00:00 2001 From: Sachintechjoomla <92356209+Sachintechjoomla@users.noreply.github.com> Date: Tue, 30 Dec 2025 10:49:14 +0530 Subject: [PATCH 7/8] Issue#251228 Feat: Program User Status Mapping --- databaseQueries/programUsers.js | 90 ++------------------------------- document/programUsers/README.md | 8 ++- module/programUsers/helper.js | 50 +++++++++++------- 3 files changed, 44 insertions(+), 104 deletions(-) diff --git a/databaseQueries/programUsers.js b/databaseQueries/programUsers.js index 6d2d4810..bb6be4a5 100644 --- a/databaseQueries/programUsers.js +++ b/databaseQueries/programUsers.js @@ -15,11 +15,7 @@ module.exports = class ProgramUsers { * @param {Array} [skipFields = "none"] - fields not to include. * @returns {Array} program users details. */ - static async programUsersDocument( - filterData = 'all', - fieldsArray = 'all', - skipFields = 'none' - ) { + static async programUsersDocument(filterData = 'all', fieldsArray = 'all', skipFields = 'none') { try { let queryObject = filterData != 'all' ? filterData : {} let projection = {} @@ -68,11 +64,7 @@ module.exports = class ProgramUsers { * @param {Array} [skipFields = "none"] - fields not to include. * @returns {Object} program user details. */ - static async findOne( - filterData = 'all', - fieldsArray = 'all', - skipFields = 'none' - ) { + static async findOne(filterData = 'all', fieldsArray = 'all', skipFields = 'none') { try { let queryObject = filterData != 'all' ? filterData : {} let projection = {} @@ -105,11 +97,7 @@ module.exports = class ProgramUsers { * @param {Object} [options = { new: true }] - options for the update. * @returns {Object} updated program user document. */ - static async findOneAndUpdate( - filterData, - updateData, - options = { new: true } - ) { + static async findOneAndUpdate(filterData, updateData, options = { new: true }) { try { let updatedDocument = await database.models.programUsers .findOneAndUpdate(filterData, updateData, options) @@ -128,10 +116,7 @@ module.exports = class ProgramUsers { * @param {Object} updateData - data to update. * @returns {Object} update result. */ - static async updateMany( - filterData, - updateData - ) { + static async updateMany(filterData, updateData) { try { let updateResult = await database.models.programUsers.updateMany(filterData, updateData) return updateResult @@ -242,68 +227,6 @@ module.exports = class ProgramUsers { } } - /** - * Get paginated program users using offset-based pagination. - * @method - * @name listWithOffset - * @param {Object} [filterData = {}] - filter query. - * @param {Array} [fieldsArray = "all"] - projected fields. - * @param {Array} [skipFields = "none"] - fields not to include. - * @param {Number} [offset = 0] - number of documents to skip. - * @param {Number} [limit = 10] - number of documents to return. - * @param {Object} [sortData = { createdAt: -1 }] - sort criteria. - * @returns {Object} paginated program users data with count. - */ - static async listWithOffset( - filterData = {}, - fieldsArray = 'all', - skipFields = 'none', - offset = 0, - limit = 10, - sortData = { createdAt: -1 } - ) { - try { - let projection = {} - - if (fieldsArray != 'all') { - fieldsArray.forEach((field) => { - projection[field] = 1 - }) - } - - if (skipFields !== 'none') { - skipFields.forEach((field) => { - projection[field] = 0 - }) - } - - const [data, totalCount] = await Promise.all([ - database.models.programUsers - .find(filterData, projection) - .sort(sortData) - .skip(offset) - .limit(limit) - .lean(), - database.models.programUsers.countDocuments(filterData), - ]) - - // Calculate page info for response - const page = Math.floor(offset / limit) + 1 - const totalPages = Math.ceil(totalCount / limit) - - return { - data, - totalCount, - page, - limit, - offset, - totalPages, - } - } catch (error) { - throw error - } - } - /** * Aggregate program users. * @method @@ -328,10 +251,7 @@ module.exports = class ProgramUsers { * @param {Object} [options = {}] - bulk write options. * @returns {Object} bulk write result. */ - static async bulkWrite( - operations, - options = {} - ) { + static async bulkWrite(operations, options = {}) { try { let result = await database.models.programUsers.bulkWrite(operations, options) return result diff --git a/document/programUsers/README.md b/document/programUsers/README.md index 60a32641..398c76b0 100644 --- a/document/programUsers/README.md +++ b/document/programUsers/README.md @@ -181,7 +181,13 @@ Behavior details: - **Admins / Tenant Admins:** bypass validation automatically. - **Supervisors:** may bypass only when the request includes `"force": true` in the body. Use bypasses sparingly and with audit controls. -- **Metadata updates (merge behavior):** When `metadata` is provided in the `PATCH /update/:_id` body, the server performs a shallow merge between the existing `metadata` object and the incoming `metadata` object. The merge is performed as `{ ...existingMetadata, ...incomingMetadata }` — i.e., keys in the incoming `metadata` override top-level keys in the existing object. If you expect nested/deep merges, request a change to use a deep-merge strategy (e.g., `lodash.merge`). +- **Metadata updates (deep merge behavior):** When `metadata` is provided in the `PATCH /update/:_id` body, the server performs a **deep recursive merge** between the existing `metadata` object and the incoming `metadata` object. This means: + + - **Nested objects** are merged recursively at all levels. + - **Arrays and primitives** are replaced (not merged) with the incoming values. + - **Other categories** in metadata are preserved untouched. + + **Example:** If existing metadata has `{ externalIdOfBoardingCompletionCategory: {...}, externalIdOfAnotherCategory: {...} }` and you send `{ externalIdOfBoardingCompletionCategory: { tasks: [...newTasks] } }`, the result will merge the tasks into the existing boarding category while preserving the other category and all other fields. - **Audit recommendation:** Because bypasses allow skipping status validation, it is recommended to record an audit entry whenever a bypass occurs. Useful audit fields: `performedBy`, `performedByRoles`, `prevStatus`, `newStatus`, `statusReason`, `force` (true/false), and `timestamp`. diff --git a/module/programUsers/helper.js b/module/programUsers/helper.js index 422bcd10..883479b7 100644 --- a/module/programUsers/helper.js +++ b/module/programUsers/helper.js @@ -22,6 +22,34 @@ const STATUS_ORDER = ['NOT_ONBOARDED', 'ONBOARDED', 'IN_PROGRESS', 'COMPLETED', */ module.exports = class ProgramUsersHelper { + /** + * Deep merge two objects recursively. + * Used for metadata merging so that when a user sends partial metadata + * (e.g., only one category), it merges into existing metadata at all levels. + * @static + * @param {Object} target - existing object + * @param {Object} source - incoming object to merge + * @returns {Object} merged object + */ + static deepMerge(target, source) { + if (!source || typeof source !== 'object') return target + if (!target || typeof target !== 'object') return source + + const result = { ...target } + + Object.keys(source).forEach((key) => { + if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) { + // Recursively merge nested objects + result[key] = this.deepMerge(result[key] || {}, source[key]) + } else { + // For primitives and arrays, replace with source value + result[key] = source[key] + } + }) + + return result + } + /** * Validate status transition * Rules: @@ -333,18 +361,14 @@ module.exports = class ProgramUsersHelper { updateData.$set.updatedBy = tokenUserId // Add remaining fields to update - // If metadata is provided, merge with existing metadata instead of replacing + // If metadata is provided, deep merge with existing metadata instead of replacing if (bodyData.metadata && typeof bodyData.metadata === 'object') { const existingMetadata = currentProgramUser.metadata || {} - const mergedMetadata = { - ...existingMetadata, - ...bodyData.metadata, - } + const mergedMetadata = this.deepMerge(existingMetadata, bodyData.metadata) updateData.$set.metadata = mergedMetadata // remove from bodyData so it's not copied again delete bodyData.metadata } - Object.keys(bodyData).forEach((key) => { updateData.$set[key] = bodyData[key] }) @@ -566,19 +590,9 @@ module.exports = class ProgramUsersHelper { } } - // 4. Calculate offset from page and limit - const offset = (pageNo - 1) * pageSize - - // 5. Execute Query + // 4. Execute Query const finalSort = typeof sortData === 'string' ? { createdAt: -1 } : sortData || { createdAt: -1 } - const result = await programUsersQueries.listWithOffset( - filter, - 'all', - 'none', - offset, - pageSize, - finalSort - ) + const result = await programUsersQueries.list(filter, 'all', 'none', pageNo, pageSize, finalSort) return resolve({ success: true, From be257a610fd107032074e21561757b359a06ff97 Mon Sep 17 00:00:00 2001 From: Sachintechjoomla <92356209+Sachintechjoomla@users.noreply.github.com> Date: Tue, 30 Dec 2025 12:55:18 +0530 Subject: [PATCH 8/8] Issue#251228 Feat: Program User Status Mapping --- controllers/v1/program/users.js | 111 +++++++++++ controllers/v1/programUsers.js | 334 -------------------------------- document/programUsers/README.md | 34 ++-- 3 files changed, 128 insertions(+), 351 deletions(-) create mode 100644 controllers/v1/program/users.js delete mode 100644 controllers/v1/programUsers.js diff --git a/controllers/v1/program/users.js b/controllers/v1/program/users.js new file mode 100644 index 00000000..dabf73de --- /dev/null +++ b/controllers/v1/program/users.js @@ -0,0 +1,111 @@ +/* + * name : users.js + * author : copilot + * created-date : 2025-12-30 + * Description : Program -> Users controller to expose programUsers APIs under /program/users + */ + +// Dependencies +const programUsersHelper = require(MODULES_BASE_PATH + '/programUsers/helper') + +module.exports = class Users extends Abstract { + constructor() { + super('programUsers') + } + + static get name() { + return 'programUsers' + } + + async create(req) { + try { + const result = await programUsersHelper.create(req.body, req.userDetails) + return result + } catch (error) { + return { + status: error.status || HTTP_STATUS_CODE.internal_server_error.status, + message: error.message || HTTP_STATUS_CODE.internal_server_error.message, + errorObject: error, + } + } + } + + async update(req) { + try { + const result = await programUsersHelper.update(req.params._id, req.body, req.userDetails) + return result + } catch (error) { + return { + status: error.status || HTTP_STATUS_CODE.internal_server_error.status, + message: error.message || HTTP_STATUS_CODE.internal_server_error.message, + errorObject: error, + } + } + } + + async read(req) { + try { + if (req.params.programId && req.params.userId && !req.params._id) { + const result = await programUsersHelper.readByProgramAndUserId( + req.params.programId, + req.params.userId, + req.userDetails + ) + return result + } else if (req.params._id) { + const result = await programUsersHelper.read(req.params._id, req.userDetails) + return result + } else { + throw { + status: HTTP_STATUS_CODE.bad_request.status, + message: 'Invalid parameters. Use either /:_id or /:programId/:userId', + } + } + } catch (error) { + return { + status: error.status || HTTP_STATUS_CODE.internal_server_error.status, + message: error.message || HTTP_STATUS_CODE.internal_server_error.message, + errorObject: error, + } + } + } + + async list(req) { + try { + const result = await programUsersHelper.list(req) + return result + } catch (error) { + return { + status: error.status || HTTP_STATUS_CODE.internal_server_error.status, + message: error.message || HTTP_STATUS_CODE.internal_server_error.message, + errorObject: error, + } + } + } + + async delete(req) { + try { + const result = await programUsersHelper.deleteResource(req.params._id, req.userDetails) + return result + } catch (error) { + return { + status: error.status || HTTP_STATUS_CODE.internal_server_error.status, + message: error.message || HTTP_STATUS_CODE.internal_server_error.message, + errorObject: error, + } + } + } + + async getByProgramId(req) { + try { + const result = await programUsersHelper.getByProgramId(req.params._id, req.query, req.userDetails) + return result + } catch (error) { + return { + status: error.status || HTTP_STATUS_CODE.internal_server_error.status, + message: error.message || HTTP_STATUS_CODE.internal_server_error.message, + errorObject: error, + } + } + } +} diff --git a/controllers/v1/programUsers.js b/controllers/v1/programUsers.js deleted file mode 100644 index 381adfc3..00000000 --- a/controllers/v1/programUsers.js +++ /dev/null @@ -1,334 +0,0 @@ -/** - * name : programUsers.js - * author : Ankit Shahu - * created-date : 9-Jan-2023 - * Description : PII data related controller. - */ - -// Dependencies -const programUsersHelper = require(MODULES_BASE_PATH + '/programUsers/helper') - -/** - * ProgramUsers - * @class - */ -module.exports = class ProgramUsers extends Abstract { - constructor() { - super('programUsers') - } - - static get name() { - return 'programUsers' - } - - /** - * @api {post} /project/v1/programUsers/create - * @apiVersion 1.0.0 - * @apiName create - * @apiGroup ProgramUsers - * @apiHeader {String} x-auth-token Authenticity token - * @apiSampleRequest /project/v1/programUsers/create - * @apiUse successBody - * @apiUse errorBody - * @apiParamExample {json} Request-Body: - * { - * "programId": "60a5e5d8f1b2c3d4e5f6a7b8", - * "userId": "user-uuid-123", - * "userProfile": { - * "firstName": "John", - * "lastName": "Doe", - * "email": "john@example.com" - * }, - * "userRoleInformation": { - * "role": "teacher" - * }, - * "status": "NOT_ONBOARDED", - * "metadata": {} - * } - * @apiParamExample {json} Response: - * { - * "message": "Program user created successfully", - * "status": 200, - * "result": { - * "_id": "60a5e5d8f1b2c3d4e5f6a7b9", - * "programId": "60a5e5d8f1b2c3d4e5f6a7b8", - * "userId": "user-uuid-123", - * "status": "NOT_ONBOARDED" - * } - * } - */ - - /** - * Create a new program user mapping. - * @method - * @name create - * @param {Object} req - request object. - * @param {Object} req.body - request body data. - * @param {Object} req.userDetails - logged in user details. - * @returns {JSON} - program user creation response. - */ - async create(req) { - try { - const result = await programUsersHelper.create(req.body, req.userDetails) - return result - } catch (error) { - return { - status: error.status || HTTP_STATUS_CODE.internal_server_error.status, - message: error.message || HTTP_STATUS_CODE.internal_server_error.message, - errorObject: error, - } - } - } - - /** - * @api {patch} /project/v1/programUsers/update/:_id - * @apiVersion 1.0.0 - * @apiName update - * @apiGroup ProgramUsers - * @apiHeader {String} x-auth-token Authenticity token - * @apiSampleRequest /project/v1/programUsers/update/60a5e5d8f1b2c3d4e5f6a7b9 - * @apiUse successBody - * @apiUse errorBody - * @apiParamExample {json} Request-Body: - * { - * "status": "ONBOARDED", - * "userRoleInformation": { - * "role": "mentor" - * } - * } - * @apiParamExample {json} Response: - * { - * "message": "Program user updated successfully", - * "status": 200, - * "result": { - * "_id": "60a5e5d8f1b2c3d4e5f6a7b9", - * "status": "ONBOARDED" - * } - * } - */ - - /** - * Update a program user mapping. - * @method - * @name update - * @param {Object} req - request object. - * @param {String} req.params._id - program user id. - * @param {Object} req.body - request body data. - * @param {Object} req.userDetails - logged in user details. - * @returns {JSON} - program user update response. - */ - async update(req) { - try { - const result = await programUsersHelper.update(req.params._id, req.body, req.userDetails) - return result - } catch (error) { - return { - status: error.status || HTTP_STATUS_CODE.internal_server_error.status, - message: error.message || HTTP_STATUS_CODE.internal_server_error.message, - errorObject: error, - } - } - } - - /** - * @api {get} /project/v1/programUsers/read/:_id - * @apiVersion 1.0.0 - * @apiName read - * @apiGroup ProgramUsers - * @apiHeader {String} x-auth-token Authenticity token - * @apiSampleRequest /project/v1/programUsers/read/60a5e5d8f1b2c3d4e5f6a7b9 - * @apiUse successBody - * @apiUse errorBody - * @apiParamExample {json} Response: - * { - * "message": "Program user fetched successfully", - * "status": 200, - * "result": { - * "_id": "60a5e5d8f1b2c3d4e5f6a7b9", - * "programId": "60a5e5d8f1b2c3d4e5f6a7b8", - * "userId": "user-uuid-123", - * "status": "NOT_ONBOARDED" - * } - * } - */ - - /** - * Get program user details by ID or by program ID and user ID. - * Handles two patterns: - * - /read/:_id - Get by program user ID - * - /read/:programId/:userId - Get by program ID and user ID - * @method - * @name read - * @param {Object} req - request object. - * @param {String} req.params._id - program user id (pattern 1). - * @param {String} req.params.programId - program id (pattern 2). - * @param {String} req.params.userId - user id (pattern 2). - * @param {Object} req.userDetails - logged in user details. - * @returns {JSON} - program user details. - */ - async read(req) { - try { - // Check if it's a programId:userId pattern - if (req.params.programId && req.params.userId && !req.params._id) { - // Pattern: /read/:programId/:userId - const result = await programUsersHelper.readByProgramAndUserId( - req.params.programId, - req.params.userId, - req.userDetails - ) - return result - } else if (req.params._id) { - // Pattern: /read/:_id - const result = await programUsersHelper.read(req.params._id, req.userDetails) - return result - } else { - throw { - status: HTTP_STATUS_CODE.bad_request.status, - message: 'Invalid parameters. Use either /:_id or /:programId/:userId', - } - } - } catch (error) { - return { - status: error.status || HTTP_STATUS_CODE.internal_server_error.status, - message: error.message || HTTP_STATUS_CODE.internal_server_error.message, - errorObject: error, - } - } - } - - /** - * @api {post} /project/v1/programUsers/list - * @apiVersion 1.0.0 - * @apiName list - * @apiGroup ProgramUsers - * @apiHeader {String} x-auth-token Authenticity token - * @apiSampleRequest /project/v1/programUsers/list?page=1&limit=10&programId=60a5e5d8f1b2c3d4e5f6a7b8 - * @apiUse successBody - * @apiUse errorBody - * @apiParamExample {json} Request-Body: - * { - * "status": "ONBOARDED" - * } - * @apiParamExample {json} Response: - * { - * "message": "Program users fetched successfully", - * "status": 200, - * "result": [...], - * "count": 100, - * "page": 1, - * "limit": 10, - * "totalPages": 10 - * } - */ - - /** - * List program users with filters and pagination. - * @method - * @name list - * @param {Object} req - request object. - * @param {Object} req.query - query parameters. - * @param {Object} req.body - request body for additional filters. - * @param {Object} req.userDetails - logged in user details. - * @returns {JSON} - paginated list of program users. - */ - async list(req) { - try { - const result = await programUsersHelper.list(req) - return result - } catch (error) { - return { - status: error.status || HTTP_STATUS_CODE.internal_server_error.status, - message: error.message || HTTP_STATUS_CODE.internal_server_error.message, - errorObject: error, - } - } - } - - /** - * @api {delete} /project/v1/programUsers/delete/:_id - * @apiVersion 1.0.0 - * @apiName delete - * @apiGroup ProgramUsers - * @apiHeader {String} x-auth-token Authenticity token - * @apiSampleRequest /project/v1/programUsers/delete/60a5e5d8f1b2c3d4e5f6a7b9 - * @apiUse successBody - * @apiUse errorBody - * @apiParamExample {json} Response: - * { - * "message": "Program user deleted successfully", - * "status": 200, - * "result": { - * "_id": "60a5e5d8f1b2c3d4e5f6a7b9" - * } - * } - */ - - /** - * Delete a program user mapping. - * Supports both patterns: - * - /delete/:_id - Standard delete by ID - * - DELETE /:_id - Standard REST DELETE - * @method - * @name delete - * @param {Object} req - request object. - * @param {String} req.params._id - program user id. - * @param {Object} req.userDetails - logged in user details. - * @returns {JSON} - deletion response. - */ - async delete(req) { - try { - const result = await programUsersHelper.deleteResource(req.params._id, req.userDetails) - return result - } catch (error) { - return { - status: error.status || HTTP_STATUS_CODE.internal_server_error.status, - message: error.message || HTTP_STATUS_CODE.internal_server_error.message, - errorObject: error, - } - } - } - - /** - * @api {get} /project/v1/programUsers/getByProgramId/:_id - * @apiVersion 1.0.0 - * @apiName getByProgramId - * @apiGroup ProgramUsers - * @apiHeader {String} x-auth-token Authenticity token - * @apiSampleRequest /project/v1/programUsers/getByProgramId/60a5e5d8f1b2c3d4e5f6a7b8?page=1&limit=10&status=ONBOARDED - * @apiUse successBody - * @apiUse errorBody - * @apiParamExample {json} Response: - * { - * "message": "Program users fetched successfully", - * "status": 200, - * "result": [...], - * "count": 50, - * "page": 1, - * "limit": 10, - * "totalPages": 5 - * } - */ - - /** - * Get program users by program ID. - * @method - * @name getByProgramId - * @param {Object} req - request object. - * @param {String} req.params._id - program id. - * @param {Object} req.query - query parameters. - * @param {Object} req.userDetails - logged in user details. - * @returns {JSON} - list of program users for a program. - */ - async getByProgramId(req) { - try { - const result = await programUsersHelper.getByProgramId(req.params._id, req.query, req.userDetails) - return result - } catch (error) { - return { - status: error.status || HTTP_STATUS_CODE.internal_server_error.status, - message: error.message || HTTP_STATUS_CODE.internal_server_error.message, - errorObject: error, - } - } - } -} diff --git a/document/programUsers/README.md b/document/programUsers/README.md index 398c76b0..92a85a8c 100644 --- a/document/programUsers/README.md +++ b/document/programUsers/README.md @@ -97,7 +97,7 @@ NOT_ONBOARDED → ONBOARDED → IN_PROGRESS → COMPLETED → GRADUATED ### Base URL ``` -/project/v1/programUsers +/project/v1/program/users ``` ### Authentication @@ -133,7 +133,7 @@ The token is decoded to extract: ### 1. Create Program User ```bash -POST /project/v1/programUsers/create +POST /project/v1/program/users/create Headers: x-auth-token: Content-Type: application/json @@ -170,7 +170,7 @@ Response: ### 2. Update (Status or Metadata) -Note: The separate `updateStatus` API has been removed. Use the consolidated `PATCH /project/v1/programUsers/update/:_id` endpoint for status and metadata updates. +Note: The separate `updateStatus` API has been removed. Use the consolidated `PATCH /project/v1/program/users/update/:_id` endpoint for status and metadata updates. Use the single update endpoint to modify status, metadata, or other mutable fields. When changing `status`, the request MUST include a non-empty `statusReason` string explaining the reason for the change. @@ -192,7 +192,7 @@ Behavior details: - **Audit recommendation:** Because bypasses allow skipping status validation, it is recommended to record an audit entry whenever a bypass occurs. Useful audit fields: `performedBy`, `performedByRoles`, `prevStatus`, `newStatus`, `statusReason`, `force` (true/false), and `timestamp`. ```bash -PATCH /project/v1/programUsers/update/507f1f77bcf86cd799439011 +PATCH /project/v1/program/users/update/507f1f77bcf86cd799439011 Headers: x-auth-token: Content-Type: application/json @@ -250,7 +250,7 @@ Supports both page-based and offset-based pagination. #### Basic List with Page-Based Pagination ```bash -POST /project/v1/programUsers/list?page=1&limit=10 +POST /project/v1/program/users/list?page=1&limit=10 Headers: x-auth-token: Content-Type: application/json @@ -277,7 +277,7 @@ Response: #### List with Offset-Based Pagination ```bash -POST /project/v1/programUsers/list?offset=0&limit=10 +POST /project/v1/program/users/list?offset=0&limit=10 Headers: x-auth-token: Content-Type: application/json @@ -304,7 +304,7 @@ Response: #### Filter by Single Status ```bash -POST /project/v1/programUsers/list?page=1&limit=10 +POST /project/v1/program/users/list?page=1&limit=10 Headers: x-auth-token: Content-Type: application/json @@ -319,7 +319,7 @@ Body: #### Filter by Multiple Statuses ```bash -POST /project/v1/programUsers/list?page=1&limit=10 +POST /project/v1/program/users/list?page=1&limit=10 Headers: x-auth-token: Content-Type: application/json @@ -338,7 +338,7 @@ The `templateExternalId` filter allows you to search for program users based on **Array format (recommended):** ```bash -POST /project/v1/programUsers/list?page=1&limit=10 +POST /project/v1/program/users/list?page=1&limit=10 Headers: x-auth-token: Content-Type: application/json @@ -353,7 +353,7 @@ Body: **String format:** ```bash -POST /project/v1/programUsers/list?page=1&limit=10 +POST /project/v1/program/users/list?page=1&limit=10 Headers: x-auth-token: Content-Type: application/json @@ -370,7 +370,7 @@ Body: **Array format (recommended):** ```bash -POST /project/v1/programUsers/list?page=1&limit=10 +POST /project/v1/program/users/list?page=1&limit=10 Headers: x-auth-token: Content-Type: application/json @@ -385,7 +385,7 @@ Body: **Comma-separated string format:** ```bash -POST /project/v1/programUsers/list?page=1&limit=10 +POST /project/v1/program/users/list?page=1&limit=10 Headers: x-auth-token: Content-Type: application/json @@ -400,7 +400,7 @@ Body: #### Combination of Multiple Filters ```bash -POST /project/v1/programUsers/list?page=1&limit=10 +POST /project/v1/program/users/list?page=1&limit=10 Headers: x-auth-token: Content-Type: application/json @@ -451,7 +451,7 @@ Body: ### 5. Delete Program User (Standard DELETE Pattern) ```bash -DELETE /project/v1/programUsers/delete/507f1f77bcf86cd799439011 +DELETE /project/v1/program/users/delete/507f1f77bcf86cd799439011 Headers: x-auth-token: @@ -586,13 +586,13 @@ project-service/ │ └── v1.js # Request validation ├── controllers/ │ └── v1/ -│ └── programUsers.js # API endpoints +│ └── program/users.js # API endpoints (canonical) ├── generics/ │ └── constants/ │ └── api-responses.js # Response messages └── document/ - └── programUsers/ - └── README.md # This documentation + └── programUsers/ + └── README.md # This documentation ``` ---