diff --git a/.env.sample b/.env.sample index 60d200e3..24844695 100644 --- a/.env.sample +++ b/.env.sample @@ -98,4 +98,4 @@ ORG_UPDATES_TOPIC = elevate_project_org_extension_event_listener USER_ACCOUNT_EVENT_TOPIC = elevate_user_account_event_listener // Kafka topic to listen user account events SESSION_VERIFICATION_METHOD = user_service_authenticated // session verification method -USER_SERVICE_INTERNAL_ACCESS_TOKEN_HEADER_KEY = internal_access_token // user service's internal access token header key \ No newline at end of file +USER_SERVICE_INTERNAL_ACCESS_TOKEN_HEADER_KEY = internal_access_token // user service's internal access token header key. diff --git a/.github/workflows/brac-dev-deployment.yaml b/.github/workflows/brac-dev-deployment.yaml new file mode 100644 index 00000000..8b00856d --- /dev/null +++ b/.github/workflows/brac-dev-deployment.yaml @@ -0,0 +1,81 @@ +name: Dev Build & Deploy Project Service (BRAC) + +on: + push: + branches: + - develop + +env: + AWS_REGION: ${{ secrets.AWS_REGION }} + ECR_REPOSITORY: ${{ secrets.ECR_REPOSITORY_BRAC }} + AWS_ACCOUNT_ID: ${{ secrets.AWS_ACCOUNT_ID }} + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + + permissions: + contents: read + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + # ========================= + # AWS Authentication + # ========================= + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ env.AWS_REGION }} + + # ========================= + # Login to Amazon ECR + # ========================= + - name: Login to Amazon ECR + uses: aws-actions/amazon-ecr-login@v2 + + # ========================= + # Build & Push Image + # ========================= + - name: Build and Push Docker Image to ECR + uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: | + ${{ env.AWS_ACCOUNT_ID }}.dkr.ecr.${{ env.AWS_REGION }}.amazonaws.com/${{ env.ECR_REPOSITORY }}:latest-brac + ${{ env.AWS_ACCOUNT_ID }}.dkr.ecr.${{ env.AWS_REGION }}.amazonaws.com/${{ env.ECR_REPOSITORY }}:${{ github.sha }} + + # ========================= + # Deploy on Server + # ========================= + - name: Deploy Stack + uses: appleboy/ssh-action@master + with: + host: ${{ secrets.HOST_NAME_DEV }} + username: ${{ secrets.USERNAME }} + key: ${{ secrets.SSH_KEY }} + port: ${{ secrets.PORT }} + script: | + set -e + + # Export AWS variables explicitly + export AWS_REGION="${{ secrets.AWS_REGION }}" + export AWS_ACCOUNT_ID="${{ secrets.AWS_ACCOUNT_ID }}" + + cd ${{ secrets.TARGET_DIR_DEV }} + + if [ -f .env ]; then + mv .env .env-bkp + fi + + echo '${{ secrets.DEV_ENV_BRAC }}' > .env + + aws ecr get-login-password --region "$AWS_REGION" \ + | docker login --username AWS \ + --password-stdin "$AWS_ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com" + + ./deploy.sh diff --git a/.github/workflows/brac-qa-deplyment.yaml b/.github/workflows/brac-qa-deplyment.yaml new file mode 100644 index 00000000..f82fc2d0 --- /dev/null +++ b/.github/workflows/brac-qa-deplyment.yaml @@ -0,0 +1,87 @@ +name: Tag Build & Deploy User Service (BRAC) + +on: + push: + tags: + - "v*" + +env: + AWS_REGION: ${{ secrets.AWS_REGION }} + AWS_ACCOUNT_ID: ${{ secrets.AWS_ACCOUNT_ID }} + ECR_REPOSITORY: ${{ secrets.ECR_REPOSITORY_BRAC }} + TAG: ${{ github.ref_name }} + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + + permissions: + contents: read + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + # ========================= + # AWS Authentication + # ========================= + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ env.AWS_REGION }} + + # ========================= + # Login to Amazon ECR + # ========================= + - name: Login to Amazon ECR + uses: aws-actions/amazon-ecr-login@v2 + + # ========================= + # Build & Push Docker Image + # ========================= + - name: Build and Push Docker Image to ECR + uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: | + ${{ env.AWS_ACCOUNT_ID }}.dkr.ecr.${{ env.AWS_REGION }}.amazonaws.com/${{ env.ECR_REPOSITORY }}:${{ env.TAG }} + + # ========================= + # Deploy on QA Server + # ========================= + - name: Deploy Stack to QA + uses: appleboy/ssh-action@master + with: + host: ${{ secrets.HOST_NAME_QA }} + username: ${{ secrets.USERNAME }} + key: ${{ secrets.SSH_KEY }} + port: ${{ secrets.PORT }} + script: | + set -e + + export AWS_ACCESS_KEY_ID=${{ secrets.AWS_ACCESS_KEY_ID }} + export AWS_SECRET_ACCESS_KEY=${{ secrets.AWS_SECRET_ACCESS_KEY }} + export AWS_REGION=${{ env.AWS_REGION }} + + cd ${{ secrets.TARGET_DIR_QA }} + + # Backup old env if exists + if [ -f .env ]; then + mv .env .env-bkp + fi + + # Write env safely (MULTILINE SAFE) + cat << 'EOF' > .env + ${{ secrets.QA_ENV_BRAC }} + EOF + + aws ecr get-login-password --region ${AWS_REGION} \ + | docker login \ + --username AWS \ + --password-stdin \ + ${{ env.AWS_ACCOUNT_ID }}.dkr.ecr.${AWS_REGION}.amazonaws.com + + ./deploy.sh ${{ env.TAG }} diff --git a/config/kafka.js b/config/kafka.js index a835e2d9..3da9ff63 100644 --- a/config/kafka.js +++ b/config/kafka.js @@ -14,6 +14,7 @@ const USER_DELETE_ON_OFF = process.env.USER_DELETE_ON_OFF const COURSES_TOPIC = process.env.USER_COURSES_SUBMISSION_TOPIC const ORG_EXTENSION_TOPIC = process.env.ORG_UPDATES_TOPIC const USER_ACCOUNT_EVENT_TOPIC = process.env.USER_ACCOUNT_EVENT_TOPIC +const USER_ACTIVITY_TOPIC = process.env.USER_ACTIVITY_TOPIC /** * Kafka configurations. @@ -59,6 +60,9 @@ const connect = function () { // consume event that produced by the user service _sendToKafkaConsumers(USER_ACCOUNT_EVENT_TOPIC, process.env.KAFKA_URL) + + _sendToKafkaConsumers(USER_ACTIVITY_TOPIC, process.env.KAFKA_URL) + return { kafkaProducer: producer, kafkaClient: client, @@ -85,10 +89,10 @@ var _sendToKafkaConsumers = function (topic, host) { ) consumer.on('message', async function (message) { - console.log('-------Kafka consumer log starts here------------------') - console.log('Topic Name: ', topic) - console.log('Message: ', JSON.stringify(message)) - console.log('-------Kafka consumer log ends here------------------') + // console.log('-------Kafka consumer log starts here------------------') + // console.log('Topic Name: ', topic) + // console.log('Message: ', JSON.stringify(message)) + // console.log('-------Kafka consumer log ends here------------------') if (message && message.topic === SUBMISSION_TOPIC) { submissionsConsumer.messageReceived(message) @@ -117,6 +121,11 @@ var _sendToKafkaConsumers = function (topic, host) { if (message && message.topic === USER_ACCOUNT_EVENT_TOPIC) { userExtensionConsumer.messageReceived(message) } + + // call projectActivity consumer + if (message && message.topic === USER_ACTIVITY_TOPIC) { + userActivitiesConsumer.messageReceived(message) + } }) consumer.on('error', async function (error) { @@ -143,6 +152,10 @@ var _sendToKafkaConsumers = function (topic, host) { if (error.topics && error.topics[0] === USER_ACCOUNT_EVENT_TOPIC) { userExtensionConsumer.errorTriggered(error) } + + if (error.topics && error.topics[0] === USER_ACTIVITY_TOPIC) { + userActivitiesConsumer.errorTriggered(error) + } }) } } diff --git a/constants/interface-routes/elevate-project/configs.json b/constants/interface-routes/elevate-project/configs.json index 18b611e3..0842c1e8 100644 --- a/constants/interface-routes/elevate-project/configs.json +++ b/constants/interface-routes/elevate-project/configs.json @@ -905,6 +905,62 @@ ], "service": "project" }, + { + "sourceRoute": "/project/v1/library/categories/delete/:id", + "type": "DELETE", + "priority": "MUST_HAVE", + "inSequence": false, + "orchestrated": false, + "targetPackages": [ + { + "basePackageName": "project", + "packageName": "elevate-project" + } + ], + "service": "project" + }, + { + "sourceRoute": "/project/v1/library/categories/details/:id", + "type": "GET", + "priority": "MUST_HAVE", + "inSequence": false, + "orchestrated": false, + "targetPackages": [ + { + "basePackageName": "project", + "packageName": "elevate-project" + } + ], + "service": "project" + }, + { + "sourceRoute": "/project/v1/programUsers/createOrUpdate", + "type": "POST", + "priority": "MUST_HAVE", + "inSequence": false, + "orchestrated": false, + "targetPackages": [ + { + "basePackageName": "project", + "packageName": "elevate-project" + } + ], + "service": "project" + }, + { + "sourceRoute": "/project/v1/programUsers/getEntities", + "type": "GET", + "priority": "MUST_HAVE", + "inSequence": false, + "orchestrated": false, + "targetPackages": [ + { + "basePackageName": "project", + "packageName": "elevate-project" + } + ], + "service": "project" + }, { "sourceRoute": "/project/v1/programs/create", "type": "POST", @@ -1521,6 +1577,20 @@ ], "service": "project" }, + { + "sourceRoute": "/project/v1/userProjects/createProjectPlan", + "type": "POST", + "priority": "MUST_HAVE", + "inSequence": false, + "orchestrated": false, + "targetPackages": [ + { + "basePackageName": "project", + "packageName": "elevate-project" + } + ], + "service": "project" + }, { "sourceRoute": "/project/v1/userProjects/update", "type": "POST", diff --git a/controllers/v1/library/categories.js b/controllers/v1/library/categories.js index f5e9d493..2de50cca 100644 --- a/controllers/v1/library/categories.js +++ b/controllers/v1/library/categories.js @@ -36,7 +36,7 @@ module.exports = class LibraryCategories extends Abstract { } /** - * @api {get} /improvement-project/api/v1/library/categories/projects/:categoryExternalId?page=:page&limit=:limit&search=:search&sort=:sort + * @api {get} /improvement-project/api/v1/library/categories/projects/:categoryExternalId?page=:page&limit=:limit&search=:search&sort=:sort * List of library projects. * @apiVersion 1.0.0 * @apiGroup Library Categories @@ -56,7 +56,7 @@ module.exports = class LibraryCategories extends Abstract { "description" : "Test template description", "createdAt": "2020-08-31T05:59:12.230Z" } - ], + ], "count": 7 } } @@ -179,7 +179,7 @@ module.exports = class LibraryCategories extends Abstract { } /** - * @api {get} /improvement-project/api/v1/library/categories/list + * @api {get} /improvement-project/api/v1/library/categories/list * List of library categories. * @apiVersion 1.0.0 * @apiGroup Library Categories @@ -240,7 +240,66 @@ module.exports = class LibraryCategories extends Abstract { async list(req) { return new Promise(async (resolve, reject) => { try { - let projectCategories = await libraryCategoriesHelper.list(req) + let projectCategories = await libraryCategoriesHelper.list(req.searchText, req.query, req.userDetails) + + projectCategories.result = projectCategories.data + + return resolve(projectCategories) + } catch (error) { + return reject({ + status: error.status || HTTP_STATUS_CODE.internal_server_error.status, + message: error.message || HTTP_STATUS_CODE.internal_server_error.message, + errorObject: error, + }) + } + }) + } + + /** + * delete a library category + * @method + * @name delete + * @param {Object} req - requested data + * @returns {Array} Library categories. + */ + + async delete(req) { + return new Promise(async (resolve, reject) => { + try { + const filterQuery = { + _id: req.params._id, + } + let projectCategories = await libraryCategoriesHelper.delete(filterQuery, req.userDetails) + + projectCategories.result = projectCategories.data + + return resolve(projectCategories) + } catch (error) { + return reject({ + status: error.status || HTTP_STATUS_CODE.internal_server_error.status, + message: error.message || HTTP_STATUS_CODE.internal_server_error.message, + errorObject: error, + }) + } + }) + } + + /** + * read a library category + * @method + * @name details + * @param {Object} req - requested data + * @returns {Array} Library categories. + */ + + async details(req) { + return new Promise(async (resolve, reject) => { + try { + const filterQuery = { + _id: req.params._id, + getChildren: req.query.getChildren === 'true', + } + let projectCategories = await libraryCategoriesHelper.details(filterQuery, req.userDetails) projectCategories.result = projectCategories.data diff --git a/controllers/v1/programUsers.js b/controllers/v1/programUsers.js index 35d8ef63..889de1e8 100644 --- a/controllers/v1/programUsers.js +++ b/controllers/v1/programUsers.js @@ -2,21 +2,110 @@ * name : programUsers.js * author : Ankit Shahu * created-date : 9-Jan-2023 - * Description : PII data related controller. -*/ + * Description : Program Users related controller. + */ + +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' + } + + /** + * Create or update program user + * Supports: create, update, add entity, update status + * Routes to updateEntity if entityId and entityUpdates are provided + * @method + * @name createOrUpdate + * @param {Object} req - request object + * @returns {Object} response with status and result + */ + async createOrUpdate(req) { + return new Promise(async (resolve, reject) => { + try { + if ( + !req.body.userId && + req.userDetails && + req.userDetails.userInformation && + req.userDetails.userInformation.userId + ) { + req.body.userId = req.userDetails.userInformation.userId + } + // Check if this is an entity-specific update + if (req.body.entityId && req.body.entityUpdates) { + const result = await programUsersHelper.updateEntity(req.body, req.userDetails) + return resolve(result) + } + + // Regular createOrUpdate flow + const result = await programUsersHelper.createOrUpdate(req.body, req.userDetails) + + return resolve(result) + } catch (error) { + return reject({ + status: HTTP_STATUS_CODE.internal_server_error.status, + message: error.message || 'Internal server error', + }) + } + }) + } - static get name() { - return "programUsers"; - } + /** + * Get entities from a program user with pagination and search + * @method + * @name getEntities + * @param {Object} req - request object + * @returns {Object} paginated list of entities + */ + async getEntities(req) { + return new Promise(async (resolve, reject) => { + try { + let { userId, programId, programExternalId, status, search = '', entityId = '' } = req.query + const { pageNo = 1, pageSize = 20 } = req -}; + if ( + !userId && + req.userDetails && + req.userDetails.userInformation && + req.userDetails.userInformation.userId + ) { + userId = req.userDetails.userInformation.userId + } + if (!userId || (!programId && !programExternalId)) { + return reject({ + status: HTTP_STATUS_CODE.bad_request.status, + message: 'userId and either programId or programExternalId are required', + }) + } + // Call helper + const result = await programUsersHelper.getEntitiesWithPagination( + userId, + programId, + programExternalId, + parseInt(pageNo), + parseInt(pageSize), + status, + search, + entityId, + req.userDetails + ) + return resolve(result) + } catch (error) { + return reject({ + status: HTTP_STATUS_CODE.internal_server_error.status, + message: error.message || 'Internal server error', + }) + } + }) + } +} diff --git a/controllers/v1/project/templates.js b/controllers/v1/project/templates.js index 9456e14f..6994cf7f 100644 --- a/controllers/v1/project/templates.js +++ b/controllers/v1/project/templates.js @@ -125,12 +125,12 @@ module.exports = class ProjectTemplates extends Abstract { } /** - * @api {post} /project/v1/project/templates/importProjectTemplate/:projectTemplateExternalId + * @api {post} /project/v1/project/templates/importProjectTemplate/:projectTemplateExternalId * Import templates from existsing project templates. * @apiVersion 1.0.0 * @apiGroup Project Templates * @apiSampleRequest /project/v1/project/templates/importProjectTemplate/template-1 - * @apiParamExample {json} Request: + * @apiParamExample {json} Request: * { * "externalId" : "template1", "isReusable" : false, @@ -187,7 +187,7 @@ module.exports = class ProjectTemplates extends Abstract { * @apiVersion 1.0.0 * @apiGroup Project Templates * @apiSampleRequest /project/v1/project/templates/listByIds - * @apiParamExample {json} Request: + * @apiParamExample {json} Request: * { * "externalIds" : ["IDEAIMP 4"] * } @@ -680,13 +680,13 @@ module.exports = class ProjectTemplates extends Abstract { } /** - * @api {post} /project/v1/project/templates/update/:templateId + * @api {post} /project/v1/project/templates/update/:templateId * Update projects template. * @apiVersion 1.0.0 * @apiGroup Project Templates * @apiSampleRequest /project/v1/project/templates/update/6006b5cca1a95727dbcdf648 - * @apiHeader {String} internal-access-token internal access token - * @apiHeader {String} X-authenticated-user-token Authenticity token + * @apiHeader {String} internal-access-token internal access token + * @apiHeader {String} X-authenticated-user-token Authenticity token * @apiUse successBody * @apiUse errorBody * @apiParamExample {json} Response: @@ -822,7 +822,10 @@ module.exports = class ProjectTemplates extends Abstract { req.pageSize, req.searchText, req.query.currentOrgOnly ? req.query.currentOrgOnly : false, - req.userDetails + req.userDetails, + req.query.categoryIds ? req.query.categoryIds : '', + req.query.groupByCategory ? req.query.groupByCategory : false, + req.query.taskDetails ? req.query.taskDetails : false ) // Assign the 'data' property of 'projectTemplates' to 'result'. diff --git a/controllers/v1/userProjects.js b/controllers/v1/userProjects.js index 011fcef9..6b974409 100644 --- a/controllers/v1/userProjects.js +++ b/controllers/v1/userProjects.js @@ -1545,6 +1545,67 @@ module.exports = class UserProjects extends Abstract { } */ + /** + * @api {post} /project/v1/userProjects/createProjectPlan + * @apiVersion 1.0.0 + * @apiGroup User Projects + * @apiName createProjectPlan + * @apiParamExample {json} Request: + * { + * "templates": [ + * { + * "templateId": "5f5b32cef16777642d51aaf0", + * "targetTaskName": "Social Empowerment", + * "customTasks": [] + * } + * ], + * "userId": "participantId", + * "entityId": "participantEntityId", + * "programName": "Program Name", + * "projectConfig": { + * "name": "IDP Name", + * "description": "IDP Description" + * } + * } + * @apiSuccessExample {json} Response: + * { + * "message": "Project Plan created successfully", + * "status": 200, + * "result": { + * "projectId": "master-project-id" + * } + * } + * @apiUse successBody + * @apiUse errorBody + */ + /** + * Create project plan. + * @method + * @name createProjectPlan + * @param {Object} req - request data. + * @returns {JSON} Project Plan created successfully. + */ + async createProjectPlan(req) { + return new Promise(async (resolve, reject) => { + try { + let result = await userProjectsHelper.createProjectPlan( + req.body, + req.userDetails.userInformation.userId, + req.userDetails.userToken, + req.userDetails + ) + + return resolve(result) + } catch (error) { + return reject({ + status: error.status || HTTP_STATUS_CODE.internal_server_error.status, + message: error.message || HTTP_STATUS_CODE.internal_server_error.message, + errorObject: error, + }) + } + }) + } + /** * Add entities in project. * @method diff --git a/databaseQueries/programUsers.js b/databaseQueries/programUsers.js index 479b8b8e..eb536868 100644 --- a/databaseQueries/programUsers.js +++ b/databaseQueries/programUsers.js @@ -5,48 +5,468 @@ * 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. + */ - /** - * 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); - } - }); - } - -}; + 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 or update program user record + * @method + * @name createOrUpdate + * @param {Object} filterData - filter criteria + * @param {Object} updateData - data to update/insert + * @param {Object} options - update options (upsert, new, etc) + * @returns {Object} program user document + */ + static createOrUpdate(filterData, updateData, options = {}) { + return new Promise(async (resolve, reject) => { + try { + const defaultOptions = { + upsert: true, + new: true, + setDefaultsOnInsert: true, + lean: true, + } + + const finalOptions = { ...defaultOptions, ...options } + + // Check if we need to handle hierarchy replacement (for existing documents) + const hierarchyReplacement = updateData._hierarchyReplacement + delete updateData._hierarchyReplacement + + // Check if we need to push entities (for existing documents) + const entitiesToPush = updateData._entitiesToPush + delete updateData._entitiesToPush + + // Check if we need to set metaInformation + const metaInformationToSet = updateData._metaInformationToSet + delete updateData._metaInformationToSet + + let result = await database.models.programUsers.findOneAndUpdate(filterData, updateData, finalOptions) + + // If metaInformation needs to be set, do it separately + if (metaInformationToSet && result && result._id) { + result = await database.models.programUsers.findOneAndUpdate( + { _id: result._id }, + { + $set: { metaInformation: metaInformationToSet, updatedAt: new Date() }, + }, + { new: true, lean: true } + ) + } + + // If entities need to be pushed and document exists, do push separately + if (entitiesToPush && result && result._id) { + // Fetch existing entities to check for duplicates + const existingDoc = await database.models.programUsers.findById(result._id, { entities: 1 }).lean() + const existingEntities = existingDoc?.entities || [] + const existingUserIds = new Set(existingEntities.map((e) => e.userId)) + + // Separate new entities from updates + const newEntities = [] + const entitiesToUpdate = [] + + entitiesToPush.forEach((entity) => { + if (existingUserIds.has(entity.userId)) { + entitiesToUpdate.push(entity) + } else { + newEntities.push(entity) + } + }) + + // Update existing entities + for (const entity of entitiesToUpdate) { + await database.models.programUsers.findOneAndUpdate( + { _id: result._id, 'entities.userId': entity.userId }, + { + $set: { + 'entities.$': entity, + updatedAt: new Date(), + }, + }, + { new: true, lean: true } + ) + } + + // Push only new entities + if (newEntities.length > 0) { + result = await database.models.programUsers.findOneAndUpdate( + { _id: result._id }, + { + $push: { entities: { $each: newEntities } }, + $set: { updatedAt: new Date() }, + }, + { new: true, lean: true } + ) + } + } + + // If hierarchy replacement is needed and document exists, do two separate updates + if (hierarchyReplacement && result && result._id) { + // First: Remove old entries at matching levels + const pullCondition = { + $or: hierarchyReplacement.map((item) => ({ level: item.level })), + } + + await database.models.programUsers.findOneAndUpdate( + { _id: result._id }, + { + $pull: { hierarchy: pullCondition }, + $set: { updatedAt: new Date() }, + }, + { new: true, lean: true } + ) + + // Second: Add new hierarchy items + result = await database.models.programUsers.findOneAndUpdate( + { _id: result._id }, + { + $push: { hierarchy: { $each: hierarchyReplacement } }, + $set: { updatedAt: new Date() }, + }, + { new: true, lean: true } + ) + } + + return resolve(result) + } catch (error) { + return reject(error) + } + }) + } + + /** + * Add entity to entities array + * @method + * @name addEntity + * @param {String} docId - document _id + * @param {Object} entity - entity object to add + * @returns {Object} updated document + */ + static addEntity(docId, entity) { + return new Promise(async (resolve, reject) => { + try { + let result = await database.models.programUsers.findByIdAndUpdate( + docId, + { + $push: { entities: entity }, + $set: { + updatedAt: new Date(), + 'overview.assigned': { + $cond: [ + { $eq: ['$overview.assigned', undefined] }, + 1, + { $add: ['$overview.assigned', 1] }, + ], + }, + }, + }, + { new: true, lean: true } + ) + + return resolve(result) + } catch (error) { + return reject(error) + } + }) + } + + /** + * Update entity in entities array + * @method + * @name updateEntity + * @param {String} docId - document _id + * @param {String} entityUserId - entity user id + * @param {Object} updateData - data to update in entity + * @returns {Object} updated document + */ + static updateEntity(docId, entityUserId, updateData) { + return new Promise(async (resolve, reject) => { + try { + const updateObj = {} + Object.keys(updateData).forEach((key) => { + updateObj[`entities.$.${key}`] = updateData[key] + }) + + let result = await database.models.programUsers.findOneAndUpdate( + { _id: docId, 'entities.userId': entityUserId }, + { + $set: { + ...updateObj, + updatedAt: new Date(), + }, + }, + { new: true, lean: true } + ) + + return resolve(result) + } catch (error) { + return reject(error) + } + }) + } + + /** + * Update overview statistics + * @method + * @name updateOverview + * @param {String} docId - document _id + * @param {Object} updateOperations - increment/decrement operations + * @returns {Object} updated document + */ + static updateOverview(docId, updateOperations) { + return new Promise(async (resolve, reject) => { + try { + const updateObj = { $set: { 'overview.lastRecalculated': new Date() } } + + if (updateOperations.$inc) { + updateObj.$inc = updateOperations.$inc + } + if (updateOperations.$set) { + updateObj.$set = { ...updateObj.$set, ...updateOperations.$set } + } + + let result = await database.models.programUsers.findByIdAndUpdate(docId, updateObj, { + new: true, + lean: true, + }) + + return resolve(result) + } catch (error) { + return reject(error) + } + }) + } + + /** + * Get document with entities and pagination + * @method + * @name getDocumentWithEntities + * @param {String} docId - document _id + * @param {Number} skip - skip count for pagination + * @param {Number} limit - limit for pagination + * @param {String} searchQuery - search text + * @returns {Object} document with entities + */ + static getDocumentWithEntities(docId, skip = 0, limit = 20, searchQuery = '') { + return new Promise(async (resolve, reject) => { + try { + let result = await database.models.programUsers + .findById(docId, { + entities: 1, + overview: 1, + userId: 1, + programId: 1, + }) + .lean() + + return resolve(result) + } catch (error) { + return reject(error) + } + }) + } + + /** + * Find program user by userId and either programId or programExternalId + * @method + * @name findByUserAndProgram + * @param {String} userId - user ID + * @param {String} programId - program ID (optional) + * @param {String} programExternalId - program external ID (optional) + * @returns {Object} program user document + */ + static findByUserAndProgram(userId, programId, programExternalId) { + return new Promise(async (resolve, reject) => { + try { + // Build query: use programId if available, otherwise programExternalId + const query = { userId } + if (programId) { + query.programId = programId + } else if (programExternalId) { + query.programExternalId = programExternalId + } + + let result = await database.models.programUsers.findOne(query).lean() + + return resolve(result) + } catch (error) { + return reject(error) + } + }) + } + /** + * Find program user by userId and either programId or programExternalId + * @method + * @name findByUserAndProgram + * @param {String} userId - user ID + * @param {String} programId - program ID (optional) + * @param {String} programExternalId - program external ID (optional) + * @returns {Object} program user document + */ + static findById(docId) { + return new Promise(async (resolve, reject) => { + try { + // Build query: use programId if available, otherwise programExternalId + const query = { _id: docId } + + let result = await database.models.programUsers.findOne(query).lean() + + return resolve(result) + } catch (error) { + return reject(error) + } + }) + } + + /** + * + * Update specific fields of an entity within a program user + * @method + * @name updateEntity + * @param {String} userId - program user's userId + * @param {String} programId - program ID (optional) + * @param {String} programExternalId - program external ID (optional) + * @param {String} entityId - entity's userId to identify which entity to update + * @param {Object|Array} entityUpdates - fields to update on the entity (object or array of objects) + * @param {String} tenantId - tenant ID + * @returns {Object} updated program user document + */ + static updateEntity(userId, programId, programExternalId, entityId, entityUpdates, tenantId) { + return new Promise(async (resolve, reject) => { + try { + // Build query: use programId if available, otherwise programExternalId + const query = { userId, tenantId } + if (programId) { + query.programId = programId + } else if (programExternalId) { + query.programExternalId = programExternalId + } + + // Only support regular objects like {status: "ONBOARDED", myList: [{1:1}]} + // Handle edge case: if array was converted to object with numeric keys (e.g., {0: {...}}) + let updates = entityUpdates + + if (typeof entityUpdates !== 'object' || entityUpdates === null) { + return reject({ + message: 'entityUpdates must be an object', + status: 400, + }) + } + + // Build the $set operations with dot notation for nested fields + const setOperations = { updatedAt: new Date() } + Object.keys(updates).forEach((key) => { + // Only process non-numeric keys (regular object properties) + if (!/^\d+$/.test(key)) { + setOperations[`entities.$.${key}`] = updates[key] + } + }) + + // First, find the document to verify it exists and find which field matches the entity + const docData = await database.models.programUsers.findOne(query).lean() + + if (!docData) { + return reject({ + message: 'Program user not found', + status: 404, + }) + } + + // Find the entity and determine which field matches + const entity = docData.entities?.find( + (e) => e.userId == entityId || e.externalId == entityId || e.entityId == entityId + ) + + if (!entity) { + return reject({ + message: 'Entity not found in program user', + status: 404, + }) + } + + // Determine which field to use for matching (priority: userId > entityId > externalId) + let matchField = 'entities.userId' + if (entity.userId == entityId) { + matchField = 'entities.userId' + } else if (entity.entityId == entityId) { + matchField = 'entities.entityId' + } else if (entity.externalId == entityId) { + matchField = 'entities.externalId' + } + + // Build the query with the specific matching field + // The positional operator requires the array matching condition to be directly in the query + const updateQuery = { ...query } + updateQuery[matchField] = entityId + + // Perform the update + const result = await database.models.programUsers.findOneAndUpdate( + updateQuery, + { + $set: setOperations, + }, + { new: true, lean: true } + ) + + if (!result) { + return reject({ + message: 'Failed to update entity', + status: 500, + details: { + updateQuery, + setOperations, + entityId, + matchField, + }, + }) + } + + return resolve(result) + } catch (error) { + console.error('updateEntity - Exception caught:', { + error: error.message || error, + stack: error.stack, + userId, + programId, + programExternalId, + entityId, + entityUpdates: JSON.stringify(entityUpdates), + }) + return reject(error) + } + }) + } +} diff --git a/envVariables.js b/envVariables.js index a17ef042..b51b417a 100644 --- a/envVariables.js +++ b/envVariables.js @@ -245,6 +245,11 @@ let enviromentVariables = { optional: true, default: 'internal_access_token', }, + PROGRAM_USERS_ENTITIES: { + message: 'The program users entities type', + optional: true, + default: 'users', + }, } let success = true diff --git a/generics/constants/api-responses.js b/generics/constants/api-responses.js index 216e3eb3..73a2ff97 100644 --- a/generics/constants/api-responses.js +++ b/generics/constants/api-responses.js @@ -80,6 +80,8 @@ module.exports = { PROJECT_CATEGORIES_ADDED: 'Successfully created project categories', PROJECT_CATEGORIES_NOT_UPDATED: 'Could not updated project categories', PROJECT_CATEGORIES_NOT_ADDED: 'Could not create project categories', + PROJECT_CATEGORIES_DELETED: 'Successfully deleted project categories', + PROJECT_CATEGORIES_NOT_DELETED: 'Could not delete project categories', PROJECT_TEMPLATE_NOT_UPDATED: 'Not found project template', COULD_NOT_CREATE_ASSESSMENT_SOLUTION: 'Could not create assessment solution', FAILED_TO_ADD_ENTITY_TO_SOLUTION: 'Failed to add entity to solution', @@ -246,6 +248,7 @@ module.exports = { TASK_MANDATORY_FIELDS_MISSING: 'Task mandatoru fields missing', PROGRAM_NOT_FOUND: 'Program not found', PROJECTS_CREATED: 'Projects created successfully', + PROJECT_PLAN_CREATED: 'Project Plan created successfully', CATEGORY_ALREADY_EXISTS: 'Category already exists', REQUIRED_FIELDS_NOT_PRESENT_FOR_THE_TASK_UPDATE: 'Required minimum fields _id or name are not present for the task creation.', diff --git a/generics/helpers/utils.js b/generics/helpers/utils.js index bbb5099a..70abebdc 100644 --- a/generics/helpers/utils.js +++ b/generics/helpers/utils.js @@ -1078,4 +1078,73 @@ module.exports = { strictObjectIdCheck, upperCase: upperCase, addSolutionsWithOrdersForProgramComponent, + /** + * Sync project template fields between old (single) and new (array) formats + * This ensures backward compatibility when reading/writing projects + * @param {Object} projectData - Project data object + * @param {String} direction - 'normalize' (old->new) or 'legacy' (new->old) or 'sync' (both ways) + * @returns {Object} Project data with synced template fields + */ + syncProjectTemplates: function (projectData) { + if (!projectData || typeof projectData !== 'object') { + return projectData + } + + const ObjectId = global.ObjectId || require('mongoose').Types.ObjectId + + if ( + (projectData.projectTemplateId || projectData.projectTemplateExternalId) && + (!projectData.projectTemplates || projectData.projectTemplates.length === 0) + ) { + projectData.projectTemplates = [] + if (projectData.projectTemplateId) { + projectData.projectTemplates.push({ + _id: + projectData.projectTemplateId instanceof ObjectId + ? projectData.projectTemplateId + : new ObjectId(projectData.projectTemplateId), + externalId: projectData.projectTemplateExternalId || '', + addedAt: new Date(), + }) + } + } + + return projectData + }, + + /** + * Build query to search projects by template ID (supports both legacy and new formats) + * @param {String|ObjectId|Array} templateIds - Single template ID or array of template IDs + * @param {Object} additionalFilters - Additional query filters (e.g., { tenantId, orgId, status }) + * @returns {Object} MongoDB query object + */ + buildProjectTemplateQuery: function (templateIds, additionalFilters = {}) { + if (!templateIds) { + return additionalFilters + } + + const ObjectId = global.ObjectId || require('mongoose').Types.ObjectId + + // Normalize templateIds to array + const templateIdsArray = Array.isArray(templateIds) ? templateIds : [templateIds] + + // Convert to ObjectIds + const templateObjectIds = templateIdsArray.map((id) => (id instanceof ObjectId ? id : new ObjectId(id))) + + // Build query that searches both legacy and new formats + const templateQuery = { + $or: [ + // Legacy single template field + { projectTemplateId: { $in: templateObjectIds } }, + // New multiple templates array field + { 'projectTemplates._id': { $in: templateObjectIds } }, + ], + } + + // Merge with additional filters + return { + ...additionalFilters, + ...templateQuery, + } + }, } diff --git a/generics/kafka/consumers/userActivities.js b/generics/kafka/consumers/userActivities.js new file mode 100644 index 00000000..f27cd936 --- /dev/null +++ b/generics/kafka/consumers/userActivities.js @@ -0,0 +1,110 @@ +/** + * name : submissions.js + * author : Aman Jung Karki + * created-date : 22-Nov-2020 + * Description : Submission consumer. + */ + +//dependencies + +const programUsersService = require(SERVICES_BASE_PATH + '/programUsers') +const ObjectId = global.ObjectId || require('mongoose').Types.ObjectId + +/** + * submission consumer message received. + * @function + * @name messageReceived + * @param {String} message - consumer data + * @returns {Promise} return a Promise. + */ + +var messageReceived = function (message) { + return new Promise(async function (resolve, reject) { + try { + // let parsedMessage = JSON.parse(message.value) + // let userId = parsedMessage.userId + // let project = parsedMessage.projects + // let createdBy = project.createdBy + // let projectProgramId = project.programId + // let programExternalId = project.programExternalId + // let hierarchy = [] + // if (userId != createdBy) { + // hierarchy.push({ + // level: 0, + // id: createdBy, + // }) + // } + // //let programUsersRef = await programUsersService.findByUserAndProgram(userId, projectProgramId); + // //if (!programUsersRef) { + // let result = await programUsersService.createOrUpdate({ + // userId: userId, + // hierarchy: hierarchy, + // programId: projectProgramId, + // programExternalId: project.programExternalId, + // entities: [], + // status: 'IN_PROGRESS', + // metaInformation: { + // idpAssignedAt: new Date(), + // idpAssignedBy: createdBy, + // idpProjectId: project._id, + // }, + // createdBy: createdBy, + // updatedBy: createdBy, + // referenceFrom: new ObjectId(project.referenceFrom), + // tenantId: project.tenantId, + // orgId: project.orgId, + // }) + + // if (result.result._id) { + // console.log('PARTICIPANT_PROGRAMUSERS_ASSIGNED', result.result._id) + // if (project.referenceFrom) { + // let result2 = await programUsersService.updateEntity( + // createdBy, + // project.referenceFrom, + // '', + // `${userId}`, + // { + // status: 'IN_PROGRESS', + // participantProgramUserReference: result.result._id, + // }, + // project.tenantId + // ) + // if (result2._id) { + // console.log('LC_PROGRAMUSERS_ENTITY_UPDATED', result2._id) + // } else { + // console.log('LC_PROGRAMUSERS_ENTITY_ASSIGNMENT_FAILED', result2) + // } + // } + // } else { + // console.log('PARTICIPANT_PROGRAMUSERS_ASSIGNMENT_FAILED', result.result) + // } + + return resolve('Message Received') + } catch (error) { + return reject(error) + } + }) +} + +/** + * If message is not received. + * @function + * @name errorTriggered + * @param {Object} error - error object + * @returns {Promise} return a Promise. + */ + +var errorTriggered = function (error) { + return new Promise(function (resolve, reject) { + try { + return resolve(error) + } catch (error) { + return reject(error) + } + }) +} + +module.exports = { + messageReceived: messageReceived, + errorTriggered: errorTriggered, +} diff --git a/generics/kafka/producers.js b/generics/kafka/producers.js index 63e806f1..5000148a 100644 --- a/generics/kafka/producers.js +++ b/generics/kafka/producers.js @@ -106,10 +106,10 @@ const pushMessageToKafka = function (payload) { throw reject('Kafka configuration is not done') } - console.log('-------Kafka producer log starts here------------------') - console.log('Topic Name: ', payload[0].topic) - console.log('Message: ', JSON.stringify(payload)) - console.log('-------Kafka producer log ends here------------------') + // console.log('-------Kafka producer log starts here------------------') + // console.log('Topic Name: ', payload[0].topic) + // console.log('Message: ', JSON.stringify(payload)) + // console.log('-------Kafka producer log ends here------------------') kafkaClient.kafkaProducer.send(payload, (err, data) => { if (err) { diff --git a/generics/middleware/authenticator.js b/generics/middleware/authenticator.js index 33b9a9a8..aa4841a4 100644 --- a/generics/middleware/authenticator.js +++ b/generics/middleware/authenticator.js @@ -75,6 +75,7 @@ module.exports = async function (req, res, next, token = '') { '/scp/publishTemplateAndTasks', '/library/categories/create', '/library/categories/update', + '/library/categories/delete', '/programs/create', '/programs/update', '/programs/read', @@ -112,6 +113,7 @@ module.exports = async function (req, res, next, token = '') { '/organizationExtension/updateRelatedOrgs', '/userExtension/update', '/solutions/fetchLinkInternal', + '/programUsers/createOrUpdate', ] let performInternalAccessTokenCheck = false let adminHeader = false @@ -138,7 +140,6 @@ module.exports = async function (req, res, next, token = '') { return } } - if (!token) { rspObj.errCode = CONSTANTS.apiResponses.TOKEN_MISSING_CODE rspObj.errMsg = CONSTANTS.apiResponses.TOKEN_MISSING_MESSAGE @@ -165,7 +166,7 @@ module.exports = async function (req, res, next, token = '') { rspObj.responseCode = HTTP_STATUS_CODE['unauthorized'].status // <---- For Elevate user service user compactibility ----> - let decodedToken = null + let errdecodedToken = null let userInformation = {} try { if (process.env.AUTH_METHOD === CONSTANTS.common.AUTH_METHOD.NATIVE) { diff --git a/generics/services/programUsers.js b/generics/services/programUsers.js new file mode 100644 index 00000000..1744b974 --- /dev/null +++ b/generics/services/programUsers.js @@ -0,0 +1,276 @@ +/** + * name : programUsers.js + * description : Program Users service + * created-date : 19-Jan-2026 + */ + +const programUsersQueries = require(DB_QUERY_BASE_PATH + '/programUsers') +const userService = require(GENERICS_FILES_PATH + '/services/users') + +module.exports = class ProgramUsersService { + /** + * Create or update program user - handles all operations in a single update + * @method + * @name createOrUpdate + * @param {Object} data - request data with all parameters + * @returns {Object} result with status and data + */ + static async createOrUpdate(data) { + try { + const { + tenantId, + orgId, + userId, + programId, + programExternalId, + hierarchy = [], + entities = [], + status, + metaInformation = {}, + } = data + + // Build filter based on available identifiers + let filterData = {} + if (userId) { + filterData.userId = userId + } + if (programId) { + filterData.programId = programId + } else if (programExternalId) { + filterData.programExternalId = programExternalId + } + + // Build update data with all provided parameters + const updateData = { + $set: { + updatedAt: new Date(), + tenantId, + orgId, + }, + $setOnInsert: { + createdAt: new Date(), + overview: {}, + }, + } + + // Set base fields if provided + if (userId) { + updateData.$set.userId = userId + } + if (status) { + updateData.$set.status = status + } + if (programId) { + updateData.$set.programId = programId + } + if (programExternalId) { + updateData.$set.programExternalId = programExternalId + } + + // Handle hierarchy: replace if same level exists, else add + if (hierarchy && hierarchy.length > 0) { + // For new documents: just set the hierarchy + updateData.$setOnInsert.hierarchy = hierarchy + + // For existing documents: remove old levels and push new ones + // Split into two operations if document exists + if (hierarchy.length > 0) { + // Store hierarchy for special handling + updateData._hierarchyReplacement = hierarchy + } + } else { + updateData.$setOnInsert.hierarchy = [] + } + + // Handle entities: set on insert, push on update + if (entities && entities.length > 0) { + updateData.$setOnInsert.entities = entities + // Store entities for special handling in query layer + updateData._entitiesToPush = entities + } else { + updateData.$setOnInsert.entities = [] + } + + // Handle metaInformation: store for query layer to handle separately + // This includes both user-provided keys and entityDetails to avoid MongoDB conflicts + const metaInformationToSet = { ...metaInformation } + metaInformationToSet.entityDetails = { + service: process.env.PROGRAM_USERS_ENTITY_SERVICE, + endPoint: process.env.PROGRAM_USERS_ENTITY_END_POINT, + } + updateData._metaInformationToSet = metaInformationToSet + + const result = await programUsersQueries.createOrUpdate(filterData, updateData, { + upsert: true, + new: true, + lean: true, + }) + delete result.overview // Remove overview from result + return { + status: result.createdAt === result.updatedAt ? 201 : 200, + message: 'Program user created or updated successfully', + result, + } + } catch (error) { + throw error + } + } + + /** + * Get program user entities with pagination + * @method + * @name getEntitiesWithPagination + * @param {String} userId - user ID + * @param {String} programId - program ID (optional) + * @param {String} programExternalId - program external ID (optional) + * @param {Number} page - page number + * @param {Number} limit - items per page + * @param {String} searchQuery - search text + * @param {String} entityId - specific entity ID to fetch (optional) + * @returns {Object} entities with pagination info + */ + static async getEntitiesWithPagination( + userId, + programId, + programExternalId, + page = 1, + limit = 20, + status, + searchQuery = '', + entityId, + userDetails + ) { + try { + const skip = (page - 1) * limit + + // Find document by userId and either programId or programExternalId + const docData = await this.findByUserAndProgram(userId, programId, programExternalId) + + if (!docData) { + return { + status: 404, + message: 'Program user not found', + data: [], + } + } + + // Get entities from the found document + let filteredEntities = docData.entities || [] + + // Filter by specific entityId if provided + if (entityId) { + filteredEntities = filteredEntities.filter( + (entity) => entity.userId === entityId || entity.entityId === entityId + ) + if (filteredEntities.length === 0) { + return { + status: 200, + message: 'Entity not found', + data: { data: [], overview: docData.overview || {} }, + result: { data: [], overview: docData.overview || {} }, + } + } + } + + // Filter by status if provided + if (status) { + filteredEntities = filteredEntities.filter((entity) => entity.status == status) + } + + // Filter by search query if provided + if (searchQuery) { + const lowerSearch = searchQuery.toLowerCase() + filteredEntities = filteredEntities.filter((entity) => { + // Assuming entity has a 'name' field to search against + return entity.name && entity.name.toLowerCase().includes(lowerSearch) + }) + } + + // Apply pagination + const totalCount = filteredEntities.length + const paginatedEntities = filteredEntities.slice(skip, skip + limit) + + const userIds = paginatedEntities.map((entity) => entity.userId).filter(Boolean) + // Fetch user details from user service + const { success, data } = + (await userService.accountSearch(userIds, userDetails.userInformation.tenantId)) || {} + + // Throw error if no valid users returned from service + if (!success || !data || data.count === 0) { + return { + success: true, + status: 200, + message: 'No valid users found for the provided entity user IDs.', + data: { data: [], overview: docData.overview || {} }, + result: { data: [], overview: docData.overview || {} }, + } + } + + // Map accountSearch data with entity data from docData and filter by searchQuery + const filteredData = paginatedEntities.map((entity) => { + const userData = data.data.find((user) => user.id == entity.userId) + return { + ...entity, + userDetails: userData || null, + } + }) + + return { + status: 200, + message: 'Entities retrieved successfully', + data: { data: filteredData, overview: docData.overview || {} }, + result: { data: filteredData, overview: docData.overview || {} }, + count: filteredData.length, + total: totalCount, + } + } catch (error) { + throw error + } + } + + /** + * Find program user by userId and either programId or programExternalId + * @method + * @name findByUserAndProgram + * @param {String} userId - user ID + * @param {String} programId - program ID (optional) + * @param {String} programExternalId - program external ID (optional) + * @returns {Object} program user document + */ + static async findByUserAndProgram(userId, programId, programExternalId) { + try { + const result = await programUsersQueries.findByUserAndProgram(userId, programId, programExternalId) + return result + } catch (error) { + throw error + } + } + + /** + * Update specific fields of an entity within a program user + * @method + * @name updateEntity + * @param {String} userId - program user's userId + * @param {String} programId - program ID (optional) + * @param {String} programExternalId - program external ID (optional) + * @param {String} entityId - entity's userId to identify which entity to update + * @param {Object} entityUpdates - fields to update on the entity + * @param {String} tenantId - tenant ID + * @returns {Object} updated program user document + */ + static async updateEntity(userId, programId, programExternalId, entityId, entityUpdates, tenantId) { + try { + const result = await programUsersQueries.updateEntity( + userId, + programId, + programExternalId, + entityId, + entityUpdates, + tenantId + ) + return result + } catch (error) { + throw error + } + } +} diff --git a/models/programUsers.js b/models/programUsers.js index b276a3b8..145db970 100644 --- a/models/programUsers.js +++ b/models/programUsers.js @@ -1,38 +1,151 @@ +/** + * Hierarchy Schema + * Stores the management chain (who reports to whom) + */ +const hierarchySchema = { + level: { + type: Number, + required: true, + min: 0, + description: "0 = direct manager, 1 = manager's manager, etc.", + }, + id: { + type: String, + required: true, + description: 'User ID of the manager (LC, Supervisor, etc.)', + }, +} + 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: { + // ============================================ + // CORE IDENTIFIERS + // ============================================ + userId: { + type: String, + required: true, + index: true, + description: 'User ID', + }, + programId: { + type: String, + required: true, + index: true, + description: 'Program ID', + }, + programExternalId: { + type: String, + required: true, + description: 'Human-readable external program identifier', + }, + + // ============================================ + // HIERARCHY & RELATIONSHIPS + // ============================================ + hierarchy: { + type: [hierarchySchema], + default: [], + description: "Management hierarchy (level 0 = direct manager, level 1 = manager's manager, etc.)", + }, + + // ============================================ + // OVERVIEW (FOR LC ONLY) + // ============================================ + overview: { + type: Object, + default: () => ({}), + description: 'Summary statistics (used only for LC programs, empty object for participants)', + }, + + // ============================================ + // ENTITIES (FOR LC ONLY - MATERIALIZED VIEW) + // ============================================ + entities: { + type: [Object], + default: [], + description: 'List of entities (participants) a user is managing', + }, + + // ============================================ + // METADATA & REFERENCES + // ============================================ + referenceFrom: { + type: Object, + description: 'Reference to parent/global program (for participants only)', + }, + + metaInformation: { + type: Object, + default: () => ({}), + description: 'Additional context and timeline data', + }, + + // ============================================ + // keywords (OPTIONAL - FOR FILTERING/SEARCH) + // ============================================ + keywords: { + type: [String], + default: [], + description: 'Optional tags for categorization and search', + }, + + // ============================================ + // STATUS + // ============================================ + status: { + type: String, + required: true, + enum: [ + // For LC/Supervisor + 'ACTIVE', + 'INACTIVE', + // For Participant + 'NOT_ONBOARDED', + 'ONBOARDED', + 'IN_PROGRESS', + 'COMPLETED', + 'GRADUATED', + 'DROPPED_OUT', + ], + index: true, + description: 'Current status of program user', + default: 'ACTIVE', + }, + tenantId: { + type: String, + index: true, + required: true, + }, + orgId: { + type: String, + index: true, + required: true, + }, + // ============================================ + // AUDIT FIELDS + // ============================================ + createdBy: { + type: String, + required: true, + description: 'User ID who created this entry', + }, + updatedBy: { + type: String, + required: true, + description: 'User ID who last updated this entry', + }, + }, + compoundIndex: [ + { + name: { userId: 1, programId: 1 }, + indexType: { unique: true }, + }, + { + name: { programId: 1, status: 1 }, + }, + { + name: { 'hierarchy.id': 1 }, + }, + ], +} diff --git a/models/project-categories.js b/models/project-categories.js index 290a2917..d664c699 100644 --- a/models/project-categories.js +++ b/models/project-categories.js @@ -66,11 +66,44 @@ module.exports = { default: [], index: true, }, + description: { + type: String, + required: true, + default: 'default', + }, + keywords: { + type: Array, + default: [], + index: true, + }, + parentId: { + type: 'ObjectId', + default: null, + index: true, // CRITICAL for hierarchy queries + }, + hasChildCategories: { + type: Boolean, + default: false, + index: true, // Quick leaf identification + }, + sequenceNumber: { + type: Number, + default: 0, + index: true, + }, + metaInformation: { + type: Object, + default: {}, + }, }, compoundIndex: [ { name: { externalId: 1, tenantId: 1 }, indexType: { unique: true }, }, + // For Query (parentId + tenantId queries) + { + name: { parentId: 1, tenantId: 1 }, + }, ], } diff --git a/models/project-templates.js b/models/project-templates.js index 2a003277..3e181fff 100644 --- a/models/project-templates.js +++ b/models/project-templates.js @@ -23,7 +23,6 @@ module.exports = { type: String, index: true, }, - name: String, }, ], description: { diff --git a/models/projects.js b/models/projects.js index d4995fed..d49e6cc1 100644 --- a/models/projects.js +++ b/models/projects.js @@ -75,6 +75,7 @@ module.exports = { type: String, default: 'SYSTEM', }, + // Legacy single template fields (kept for backward compatibility) projectTemplateId: { type: 'ObjectId', index: true, @@ -83,6 +84,29 @@ module.exports = { type: String, index: true, }, + // New multiple templates support + projectTemplates: { + type: [ + { + _id: { + type: 'ObjectId', + required: true, + }, + externalId: { + type: String, + required: true, + }, + }, + ], + default: [], + description: 'Array of project templates associated with this project', + }, + // keywords (OPTIONAL - FOR FILTERING/SEARCH) + keywords: { + type: [String], + default: [], + description: 'Optional tags for categorization and search', + }, startDate: Date, endDate: Date, learningResources: { @@ -226,5 +250,10 @@ module.exports = { name: { userId: 1, solutionId: 1 }, indexType: { unique: true, partialFilterExpression: { solutionId: { $exists: true } } }, }, + // Index for querying by template ID in array (multiple templates) + { + name: { 'projectTemplates._id': 1 }, + indexType: {}, + }, ], } diff --git a/module/library/categories/helper.js b/module/library/categories/helper.js index 2bfab450..2742f0c0 100644 --- a/module/library/categories/helper.js +++ b/module/library/categories/helper.js @@ -25,6 +25,144 @@ const orgExtensionQueries = require(DB_QUERY_BASE_PATH + '/organizationExtension */ module.exports = class LibraryCategoriesHelper { + /** + * Validate parentId for category operations + * @method + * @name validateParentId + * @param {String} parentId - parent category id + * @param {String} categoryId - current category id (for update operations) + * @param {String} tenantId - tenant id + * @returns {Object} validation result + */ + static async validateParentId(parentId, categoryId = null, tenantId) { + if (!parentId) { + return { success: true, parentCategory: null } + } + + // Convert to ObjectId safely + const parentObjectId = UTILS.convertStringToObjectId(parentId) + if (!parentObjectId) { + throw { + success: false, + status: HTTP_STATUS_CODE.bad_request.status, + message: 'Invalid parentId format', + } + } + + // Check if parent category exists, is not deleted, and is in the same tenant + const parentCategory = await projectCategoriesQueries.categoryDocuments( + { + _id: parentObjectId, + tenantId: tenantId, + isDeleted: false, + }, + ['_id', 'hasChildCategories', 'parentId'] + ) + + if (!parentCategory || parentCategory.length === 0) { + throw { + success: false, + status: HTTP_STATUS_CODE.bad_request.status, + message: 'Parent category not found or does not belong to the same tenant', + } + } + + const parent = parentCategory[0] + + if (categoryId) { + // Cannot set category as its own parent + if (parentId.toString() === categoryId.toString()) { + throw { + success: false, + status: HTTP_STATUS_CODE.bad_request.status, + message: 'Category cannot be its own parent', + } + } + + // Cannot move category to its own descendant (circular reference) + const isDescendant = await this.isDescendant(parentId, categoryId, tenantId) + if (isDescendant) { + throw { + success: false, + status: HTTP_STATUS_CODE.bad_request.status, + message: 'Cannot move category to its own descendant - would create circular reference', + } + } + } + + return { success: true, parentCategory: parent } + } + + /** + * Check if a category is a descendant of another category + * @method + * @name isDescendant + * @param {String} potentialDescendantId - category to check if it's a descendant + * @param {String} ancestorId - potential ancestor category + * @param {String} tenantId - tenant id + * @returns {Boolean} true if potentialDescendantId is a descendant of ancestorId + */ + static async isDescendant(potentialDescendantId, ancestorId, tenantId) { + let currentId = potentialDescendantId + + while (currentId) { + if (currentId.toString() === ancestorId.toString()) { + return true + } + + const category = await projectCategoriesQueries.categoryDocuments( + { + _id: UTILS.convertStringToObjectId(currentId), + tenantId: tenantId, + isDeleted: false, + }, + ['parentId'] + ) + + if (!category || category.length === 0 || !category[0].parentId) { + break + } + + currentId = category[0].parentId + } + + return false + } + + /** + * Get the hierarchy depth for a given category + * @method + * @name getHierarchyDepth + * @param {String} categoryId - category id + * @param {String} tenantId - tenant id + * @returns {Number} hierarchy depth + */ + static async getHierarchyDepth(categoryId, tenantId) { + let depth = 0 + let currentId = categoryId + + while (currentId && depth < 10) { + // Safety limit to prevent infinite loops + const category = await projectCategoriesQueries.categoryDocuments( + { + _id: UTILS.convertStringToObjectId(currentId), + tenantId: tenantId, + isDeleted: false, + }, + ['parentId'] + ) + + if (!category || category.length === 0 || !category[0].parentId) { + break + } + + currentId = category[0].parentId + depth++ + } + + return depth + } + /** * List of library projects. * @method @@ -80,7 +218,7 @@ module.exports = class LibraryCategoriesHelper { matchQuery['$match']['tenantId'] = userDetails.userInformation.tenantId /** - * + * Sample for matchQuery obj when orgExtension.externalProjectResourceVisibilityPolicy = CURRENT { "$match": { @@ -92,7 +230,7 @@ module.exports = class LibraryCategoriesHelper { } */ /** - * + * Sample for matchQuery obj when orgExtension.externalProjectResourceVisibilityPolicy = ASSOCIATED { "$match": { @@ -122,7 +260,7 @@ module.exports = class LibraryCategoriesHelper { } */ /** - * + * Sample for matchQuery obj when orgExtension.externalProjectResourceVisibilityPolicy = ALL { "$match": { @@ -521,6 +659,7 @@ module.exports = class LibraryCategoriesHelper { matchQuery['tenantId'] = userDetails.tenantAndOrgInfo.tenantId let categoryData = await projectCategoriesQueries.categoryDocuments(matchQuery, 'all') + // Throw error if category is not found if ( !categoryData || @@ -534,6 +673,18 @@ module.exports = class LibraryCategoriesHelper { } } + // Validate parent_id if provided in updateData + if (updateData.parentId !== undefined) { + let parentCategory + const validationResult = await this.validateParentId( + updateData.parentId, + categoryData[0]._id.toString(), + userDetails.tenantAndOrgInfo.tenantId + ) + parentCategory = validationResult.parentCategory + } + + // Handle evidence uploads let evidenceUploadData = await handleEvidenceUpload(files, userDetails.userInformation.userId) evidenceUploadData = evidenceUploadData.data @@ -566,6 +717,38 @@ module.exports = class LibraryCategoriesHelper { } } + // Update hasChildCategories for old and new parents + if (updateData.parentId !== undefined) { + const tenantId = userDetails.tenantAndOrgInfo.tenantId + const categoryId = categoryData[0]._id.toString() + + // Handle old parent: check if it has any other children + if (categoryData[0].parentId && categoryData[0].parentId.toString() !== updateData.parentId) { + const otherChildren = await projectCategoriesQueries.categoryDocuments( + { + parentId: categoryData[0].parentId, + tenantId: tenantId, + isDeleted: false, + status: CONSTANTS.common.ACTIVE, + }, + ['_id'] + ) + + await projectCategoriesQueries.updateMany( + { _id: categoryData[0].parentId }, + { hasChildCategories: otherChildren && otherChildren.length > 0 } + ) + } + + // Handle new parent: set hasChildCategories to true + if (updateData.parentId) { + await projectCategoriesQueries.updateMany( + { _id: updateData.parentId }, + { hasChildCategories: true } + ) + } + } + return resolve({ success: true, message: CONSTANTS.apiResponses.PROJECT_CATEGORIES_UPDATED, @@ -724,6 +907,48 @@ module.exports = class LibraryCategoriesHelper { } } + // Validate parent_id if provided + let parentCategory = null + if (categoryData.parentId) { + const validationResult = await this.validateParentId(categoryData.parentId, null, tenantId) + parentCategory = validationResult.parentCategory + } + + // Auto-assign sequenceNumber based on siblings under same parent + let sequenceNumber = 0 + if (categoryData.parentId) { + // Find max sequenceNumber among siblings + const siblings = await projectCategoriesQueries.categoryDocuments( + { + parentId: categoryData.parentId, + tenantId: tenantId, + isDeleted: false, + }, + ['sequenceNumber'] + ) + + if (siblings && siblings.length > 0) { + const maxSequence = Math.max(...siblings.map((sibling) => sibling.sequenceNumber || 0)) + sequenceNumber = maxSequence + 1 + } + } else { + // For root level categories, find max sequenceNumber among root categories + const rootCategories = await projectCategoriesQueries.categoryDocuments( + { + parentId: null, + tenantId: tenantId, + isDeleted: false, + }, + ['sequenceNumber'] + ) + + if (rootCategories && rootCategories.length > 0) { + const maxSequence = Math.max(...rootCategories.map((category) => category.sequenceNumber || 0)) + sequenceNumber = maxSequence + 1 + } + } + categoryData.sequenceNumber = sequenceNumber + // Fetch the signed urls from handleEvidenceUpload function const evidences = await handleEvidenceUpload(files, userDetails.userInformation.userId) categoryData['evidences'] = evidences.data @@ -742,6 +967,14 @@ module.exports = class LibraryCategoriesHelper { } } + // Update parent's hasChildCategories to true if parent exists + if (parentCategory && !parentCategory.hasChildCategories) { + await projectCategoriesQueries.updateMany( + { _id: parentCategory._id, tenantId: tenantId }, + { hasChildCategories: true } + ) + } + return resolve({ success: true, message: CONSTANTS.apiResponses.PROJECT_CATEGORIES_ADDED, @@ -766,33 +999,84 @@ module.exports = class LibraryCategoriesHelper { * @returns {Object} category details */ - static list(req) { + static list(searchText, params, userDetails) { return new Promise(async (resolve, reject) => { try { - let tenantId = req.userDetails.userInformation.tenantId - let organizationId = req.userDetails.userInformation.organizationId + let tenantId = userDetails.userInformation.tenantId + let organizationId = userDetails.userInformation.organizationId let query = { visibleToOrganizations: { $in: [organizationId] }, } // create query to fetch assets query['tenantId'] = tenantId + query['status'] = CONSTANTS.common.ACTIVE_STATUS + query['isDeleted'] = false // handle currentOrgOnly filter - if (req.query['currentOrgOnly']) { - let currentOrgOnly = UTILS.convertStringToBoolean(req.query['currentOrgOnly']) + if (params['currentOrgOnly']) { + let currentOrgOnly = UTILS.convertStringToBoolean(params['currentOrgOnly']) if (currentOrgOnly) { - query['orgId'] = { $in: ['ALL', req.userDetails.userInformation.organizationId] } + query['orgId'] = { $in: ['ALL', organizationId] } } } - query['status'] = CONSTANTS.common.ACTIVE_STATUS - let categoryData = await projectCategoriesQueries.categoryDocuments(query, [ - 'externalId', - 'name', - 'icon', - 'updatedAt', - 'noOfProjects', - ]) + // Handle parentId query param. Accepts: actual id, omitted, or the string 'null' (for root) + let parentCategory = null + if (params.parentId) { + const rawParent = params.parentId + // if client sends ?parentId=null or empty string, treat as root (parentId === null) + if (rawParent === 'null' || rawParent === null || rawParent === '') { + query['parentId'] = null + } else { + // Convert to ObjectId safely to avoid mongoose casting errors + const parentObjectId = UTILS.convertStringToObjectId(rawParent) + if (!parentObjectId) { + throw { + success: false, + status: HTTP_STATUS_CODE.bad_request.status, + message: 'Invalid parentId provided', + } + } + // Check if parent category exists, is not deleted, and is in the same tenant + parentCategory = await projectCategoriesQueries.categoryDocuments( + { _id: parentObjectId, tenantId: tenantId, isDeleted: false }, + ['_id', 'hasChildCategories'] + ) + + if (!parentCategory || parentCategory.length === 0) { + throw { + success: false, + status: HTTP_STATUS_CODE.bad_request.status, + message: 'Parent category not found or does not belong to the same tenant', + } + } + query['parentId'] = parentObjectId + } + } + + // Add keywords filter - categories must have at least one of the specified keywords + if (params.keywords && params.keywords.trim() !== '') { + const keywordsArray = params.keywords + .split(',') + .map((k) => k.trim()) + .filter((k) => k !== '') + if (keywordsArray.length > 0) { + query['keywords'] = { $in: keywordsArray } + } + } + + // Add search functionality for name and description (separate from keywords filter) + if (searchText && searchText.trim() !== '') { + const searchTerm = searchText.trim() + query['$or'] = [ + { name: new RegExp(searchTerm, 'i') }, + { description: new RegExp(searchTerm, 'i') }, + { externalId: new RegExp(searchTerm, 'i') }, + ] + } + + const skipFields = ['__v', 'createdAt', 'updatedAt', 'createdBy', 'updatedBy'] + let categoryData = await projectCategoriesQueries.categoryDocuments(query, 'all', skipFields) if (!categoryData.length > 0) { throw { @@ -801,6 +1085,30 @@ module.exports = class LibraryCategoriesHelper { } } + // If getChildren is true, fetch immediate children for each category + if (params.getChildren) { + let getChildren = UTILS.convertStringToBoolean(params['getChildren']) + if (getChildren) { + for (let category of categoryData) { + let childrenQuery = { + parentId: category._id, + tenantId: tenantId, + status: CONSTANTS.common.ACTIVE_STATUS, + isDeleted: false, + } + + let children = await projectCategoriesQueries.categoryDocuments( + childrenQuery, + 'all', + skipFields + ) + + category.children = children + category.childrenCount = children.length + } + } + } + return resolve({ success: true, message: CONSTANTS.apiResponses.PROJECT_CATEGORIES_FETCHED, @@ -815,6 +1123,177 @@ module.exports = class LibraryCategoriesHelper { } }) } + + /** + * Delete library category. + * @method + * @name delete + * @param {Object} filterQuery - filter query + * @param {Object} userDetails - user details + * @returns {Object} Delete operation result + */ + static delete(filterQuery, userDetails) { + return new Promise(async (resolve, reject) => { + try { + const tenantId = userDetails.userInformation.tenantId + const categoryId = filterQuery._id + + // Find the category to delete + let categoryData = await projectCategoriesQueries.categoryDocuments( + { + _id: categoryId, + tenantId: tenantId, + isDeleted: false, + }, + 'all' + ) + + if (!categoryData || categoryData.length === 0) { + throw { + status: HTTP_STATUS_CODE.not_found.status, + message: CONSTANTS.apiResponses.CATEGORY_NOT_FOUND, + } + } + + categoryData = categoryData[0] + + // Check if category has children + const children = await projectCategoriesQueries.categoryDocuments( + { + parentId: categoryId, + tenantId: tenantId, + isDeleted: false, + }, + ['_id'] + ) + + if (children && children.length > 0) { + throw { + status: HTTP_STATUS_CODE.bad_request.status, + message: + 'Cannot delete category that has child categories. Please delete or move child categories first.', + } + } + + // Check if category has associated projects + const associatedTemplates = categoryData.noOfProjects || 0 + + if (associatedTemplates > 0) { + throw { + status: HTTP_STATUS_CODE.bad_request.status, + message: + 'Cannot delete category that has associated project templates. Please remove the category from templates first.', + } + } + + // Soft delete the category + const updateResult = await projectCategoriesQueries.updateMany( + { _id: categoryId, tenantId: tenantId }, + { + isDeleted: true, + updatedBy: userDetails.userInformation.userId, + updatedAt: new Date(), + } + ) + + if (!updateResult) { + throw { + status: HTTP_STATUS_CODE.bad_request.status, + message: CONSTANTS.apiResponses.PROJECT_CATEGORIES_NOT_DELETED, + } + } + + // Update parent's hasChildCategories if this was the last child + if (categoryData.parentId) { + const siblings = await projectCategoriesQueries.categoryDocuments( + { + parentId: categoryData.parentId, + tenantId: tenantId, + isDeleted: false, + _id: { $ne: categoryId }, // Exclude the deleted category + }, + ['_id'] + ) + + await projectCategoriesQueries.updateMany( + { _id: categoryData.parentId }, + { hasChildCategories: siblings && siblings.length > 0 } + ) + } + + return resolve({ + success: true, + message: CONSTANTS.apiResponses.PROJECT_CATEGORIES_DELETED, + }) + } catch (error) { + return resolve({ + success: false, + message: error.message, + data: {}, + }) + } + }) + } + + /** + * Get library category. + * @method + * @name details + * @param {Object} filterQuery - filter query + * @param {Object} userDetails - user details + * @returns {Object} Category result + */ + static details(filterQuery, userDetails) { + return new Promise(async (resolve, reject) => { + try { + let tenantId = userDetails.userInformation.tenantId + let organizationId = userDetails.userInformation.organizationId + + let matchQuery = { + _id: filterQuery._id, + tenantId: tenantId, + status: CONSTANTS.common.ACTIVE_STATUS, + isDeleted: false, + } + + let categoryData = await projectCategoriesQueries.categoryDocuments(matchQuery, 'all') + + if (!categoryData || categoryData.length === 0) { + throw { + status: HTTP_STATUS_CODE.not_found.status, + message: CONSTANTS.apiResponses.CATEGORY_NOT_FOUND, + } + } + + // If getChildren is true, fetch immediate children + if (filterQuery.getChildren) { + let childrenQuery = { + parentId: filterQuery._id, + tenantId: tenantId, + status: CONSTANTS.common.ACTIVE_STATUS, + isDeleted: false, + } + + const skipFields = ['__v', 'createdAt', 'updatedAt', 'createdBy', 'updatedBy'] + let children = await projectCategoriesQueries.categoryDocuments(childrenQuery, 'all', skipFields) + + categoryData[0].children = children + } + + return resolve({ + success: true, + message: CONSTANTS.apiResponses.PROJECT_CATEGORIES_FETCHED, + data: categoryData[0], + }) + } catch (error) { + return resolve({ + success: false, + message: error.message, + data: {}, + }) + } + }) + } } /** @@ -916,7 +1395,7 @@ function handleEvidenceUpload(files, userId) { * @returns {Object} returns modified matchQuery */ /** - * + * Sample for matchQuery obj when orgExtension.externalProjectResourceVisibilityPolicy = CURRENT { "$match": { @@ -928,7 +1407,7 @@ function handleEvidenceUpload(files, userId) { } */ /** - * + * Sample for matchQuery obj when orgExtension.externalProjectResourceVisibilityPolicy = ASSOCIATED { "$match": { @@ -958,7 +1437,7 @@ function handleEvidenceUpload(files, userId) { } */ /** - * + * Sample for matchQuery obj when orgExtension.externalProjectResourceVisibilityPolicy = ALL { "$match": { diff --git a/module/library/categories/validator/v1.js b/module/library/categories/validator/v1.js index ec843e6c..7ea7daa4 100644 --- a/module/library/categories/validator/v1.js +++ b/module/library/categories/validator/v1.js @@ -10,6 +10,24 @@ module.exports = (req) => { create: function () { req.checkBody('externalId').exists().withMessage('externalId is required') req.checkBody('name').exists().withMessage('name is required') + req.checkBody('description').optional().isString().withMessage('description must be a string') + req.checkBody('keywords').optional().isArray().withMessage('keywords must be an array') + if (req.body.keywords) { + req.body.keywords.forEach((keyword, index) => { + req.checkBody(`keywords[${index}]`) + .isString() + .withMessage(`keyword at index ${index} must be a string`) + }) + } + req.checkBody('parent_id').optional().isMongoId().withMessage('parent_id must be a valid ObjectId') + req.checkBody('hasChildCategories') + .not() + .exists() + .withMessage('hasChildCategories cannot be set in request body') + req.checkBody('sequenceNumber') + .optional() + .isInt({ min: 0 }) + .withMessage('sequenceNumber must be a non-negative integer') }, update: function () { req.checkParams('_id').exists().withMessage('required category id') diff --git a/module/programUsers/helper.js b/module/programUsers/helper.js index 809a496f..019bc1e2 100644 --- a/module/programUsers/helper.js +++ b/module/programUsers/helper.js @@ -5,25 +5,26 @@ * Description : Programs users related helper functionality. */ +const { resolveLevel } = require('bunyan') + // Dependencies +const programUsersQueries = require(DB_QUERY_BASE_PATH + '/programUsers') +const programUsersService = require(SERVICES_BASE_PATH + '/programUsers') +const programQueries = require(DB_QUERY_BASE_PATH + '/programs') /** * ProgramUsersHelper * @class */ - -const programUsersQueries = require(DB_QUERY_BASE_PATH + '/programUsers') - module.exports = class ProgramUsersHelper { /** * check if user joined a program or not and consentShared - * @method + * @methodcreateRecord * @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 { @@ -43,4 +44,484 @@ module.exports = class ProgramUsersHelper { } }) } + + /** + * Create or update program user + * Handles all operations: create user, add entity, create record, update status + * @method + * @name createOrUpdate + * @param {Object} data - request body data containing all parameters + * @param {Object} userDetails - logged in user details + * @returns {Object} result with status and data + */ + static async createOrUpdate(data, userDetails) { + try { + const { + userId, + programId, + programExternalId, + entities, + hierarchy, + status, + metaInformation, + referenceFrom, + } = data + const loggedInUserId = userDetails.userInformation?.userId + const tenantId = userDetails.userInformation?.tenantId + const orgId = userDetails.userInformation?.organizationId + + // Validate required fields + if (!userId || (!programId && !programExternalId)) { + return { + success: false, + status: HTTP_STATUS_CODE.bad_request.status, + message: 'userId and either programId or programExternalId are required', + } + } + + // Check if program exists for given programId or programExternalId + const programExists = await this.checkProgramExists(tenantId, programId, programExternalId) + if (!programExists) { + return { + success: false, + status: HTTP_STATUS_CODE.not_found.status, + message: 'Program not found', + } + } + + // Validate referenceFrom if provided + if (referenceFrom) { + const referenceProgramExists = await this.checkProgramExists(tenantId, referenceFrom, null) + if (!referenceProgramExists || referenceProgramExists?.isAPrivateProgram) { + return { + success: false, + status: HTTP_STATUS_CODE.not_found.status, + message: 'Reference program not found OR is a private program', + } + } + + servicePayload.referenceFrom = referenceFrom + } + + // Prepare service payload with all parameters + const servicePayload = { + tenantId, + orgId, + userId, + programId, + programExternalId, + status, + metaInformation, + createdBy: loggedInUserId, + updatedBy: loggedInUserId, + } + + // Add entities if provided + if (entities && entities.length > 0) { + servicePayload.entities = entities + } + + // Add hierarchy if provided + if (hierarchy && Array.isArray(hierarchy) && hierarchy.length > 0) { + servicePayload.hierarchy = hierarchy + } + + // Call service with all parameters in single operation + const result = await programUsersService.createOrUpdate(servicePayload) + + // // Determine activity type for logging + // let activityType = 'STATUS_CHANGED'; + // if (entity) { + // activityType = 'ENTITY_ADDED'; + // } else if (referenceFrom) { + // activityType = 'RECORD_CREATED'; + // } else if (hierarchy && hierarchy.length > 0) { + // activityType = 'USER_CREATED'; + // } + + // // Log activity asynchronously (non-blocking) + // this.logActivity(activityType, result.result._id, { + // userId, + // programId, + // ...(entity && { entityId: entity.entityId, parentUserId: userId }), + // ...(referenceFrom && { parentProgramUsersId: referenceFrom }), + // ...(hierarchy && { hierarchyAdded: hierarchy.length }), + // ...(status && { newStatus: status }) + // }, loggedInUserId).catch(err => console.error('Activity logging error:', err)); + + // // Publish event asynchronously (non-blocking) + // this.publishEvent(activityType, { + // userId, + // programId, + // ...(entity && { entityUserId: entity.userId, entityId: entity.entityId }), + // ...(referenceFrom && { parentProgramUsersId: referenceFrom }), + // ...(status && { newStatus: status }), + // timestamp: new Date() + // }).catch(err => console.error('Event publishing error:', err)); + + // Update overview asynchronously (non-blocking) + if (result.result && result.result._id) { + setImmediate(async () => { + this._updateOverviewAsync(result.result._id) + }) + } + + return { + success: true, + status: result.status, + message: result.message, + data: result.result, + result: result.result, + } + } catch (error) { + return { + success: false, + status: HTTP_STATUS_CODE.internal_server_error.status, + message: error.message || 'Internal server error', + } + } + } + + /** + * Get entities with pagination + * @method + * @name getEntitiesWithPagination + * @param {String} userId - user id + * @param {String} programId - program id + * @param {String} programExternalId - program external id + * @param {Number} page - page number + * @param {Number} limit - items per page + * @param {String} search - search query + * @returns {Object} result + */ + static async getEntitiesWithPagination( + userId, + programId, + programExternalId, + page = 1, + limit = 20, + status, + search = '', + entityId, + userDetails + ) { + try { + // Call service + const result = await programUsersService.getEntitiesWithPagination( + userId, + programId, + programExternalId, + page, + limit, + status, + search, + entityId, + userDetails + ) + + return { + success: true, + status: result.status, + message: result.message, + data: result.data, + result: result.data, + count: result.count, + total: result.total, + overview: result.overview, + } + } catch (error) { + return { + success: false, + status: HTTP_STATUS_CODE.internal_server_error.status, + message: error.message || 'Internal server error', + } + } + } + + /** + * Find program user + * @method + * @name findByUserAndProgram + * @param {String} userId - user id + * @param {String} programId - program id + * @param {String} programExternalId - program external id + * @returns {Object} program user document + */ + static async findByUserAndProgram(userId, programId, programExternalId) { + try { + return await programUsersService.findByUserAndProgram(userId, programId, programExternalId) + } catch (error) { + throw error + } + } + + /** + * Log activity to activity collection + * @method + * @name logActivity + * @param {String} activityType - type of activity (CREATE, UPDATE, ADD_ENTITY, etc) + * @param {String} programUsersRef - reference to programUsers document ID + * @param {Object} activityDetails - details of activity + * @param {String} createdBy - user who triggered activity + * @returns {Promise} void + */ + static async logActivity(activityType, programUsersRef, activityDetails, createdBy) { + try { + // Check if activity logging is enabled in config + const activityConfig = global.config?.activityConfig?.[activityType] + + if (!activityConfig?.enabled) { + return // Activity logging disabled for this type + } + + // Create activity log entry + const activity = { + programUsersRef, + activityType, + activityDetails, + createdBy, + createdAt: new Date(), + } + + // Insert into activity collection + // Uncomment when programUsersActivities model is available + // await database.models.programUsersActivities.create(activity); + + console.log('[ProgramUsers Activity]', activityType, activity) + } catch (error) { + // Don't throw - activity logging should not block main flow + console.error('[ProgramUsers Activity Error]', error.message) + } + } + + /** + * Publish event to Kafka message queue + * @method + * @name publishEvent + * @param {String} eventType - type of event + * @param {Object} eventData - event data payload + * @returns {Promise} void + */ + static async publishEvent(eventType, eventData) { + try { + // Get kafka producer from global or environment + const kafkaProducer = global.kafkaProducer || global.kafkaClient?.producer + + if (!kafkaProducer) { + console.log('[ProgramUsers Event] Kafka producer not available, skipping event:', eventType) + return + } + + // Determine topic from environment or event type + const topicName = process.env[`PROGRAMUSERS_${eventType}_TOPIC`] || 'program-users-events' + + const kafkaMessage = { + key: eventData.userId || eventData.programId, + value: JSON.stringify({ + eventType, + eventData, + timestamp: new Date().toISOString(), + service: 'project-service', + }), + } + + // Send to Kafka (implementation depends on kafka client version) + await kafkaProducer.send({ + topic: topicName, + messages: [kafkaMessage], + }) + + console.log('[ProgramUsers Event Published]', eventType, 'to', topicName) + } catch (error) { + // Don't throw - event publishing should not block main flow + console.error('[ProgramUsers Event Error]', eventType, error.message) + } + } + + /** + * Check if program exists by programId or programExternalId + * @method + * @name checkProgramExists + * @param {String} programId - program ID + * @param {String} programExternalId - program external ID + * @returns {Boolean} true if program exists, false otherwise + */ + /** + * Update specific entity fields within a program user + * @method + * @name updateEntity + * @param {Object} data - request body data + * @param {String} data.userId - program user's userId + * @param {String} data.programId - program ID (optional) + * @param {String} data.programExternalId - program external ID (optional) + * @param {String} data.entityId - entity's userId to identify which entity to update + * @param {Object} data.entityUpdates - fields to update on the entity + * @param {Object} userDetails - logged in user details + * @returns {Object} result with status and updated entity data + */ + static async updateEntity(data, userDetails) { + try { + const { userId, programId, programExternalId, entityId, entityUpdates } = data + const tenantId = userDetails.userInformation?.tenantId + + // Validate required fields + if (!userId || (!programId && !programExternalId)) { + return { + success: false, + status: HTTP_STATUS_CODE.bad_request.status, + message: 'userId and either programId or programExternalId are required', + } + } + + if (!entityId) { + return { + success: false, + status: HTTP_STATUS_CODE.bad_request.status, + message: 'entityId is required', + } + } + + // Validate entityUpdates - only regular objects are supported (e.g., {status: "ONBOARDED", myList: [{1:1}]}) + if (!entityUpdates) { + return { + success: false, + status: HTTP_STATUS_CODE.bad_request.status, + message: 'entityUpdates is required', + } + } + + // Only accept regular objects with named properties + const isValidObject = + typeof entityUpdates === 'object' && entityUpdates !== null && Object.keys(entityUpdates).length > 0 + + if (!isValidObject) { + return { + success: false, + status: HTTP_STATUS_CODE.bad_request.status, + message: 'entityUpdates must be a non-empty object with named properties', + } + } + + // Call service to update entity + const result = await programUsersService.updateEntity( + userId, + programId, + programExternalId, + entityId, + entityUpdates, + tenantId + ) + // Update overview asynchronously (non-blocking) + if (result && result._id) { + setImmediate(async () => { + this._updateOverviewAsync(result._id) + }) + } + + return { + success: true, + status: 200, + message: 'Entity updated successfully', + result, + } + } catch (error) { + throw error + } + } + + static checkProgramExists(tenantId, programId, programExternalId) { + return new Promise(async (resolve, reject) => { + try { + let programMatchQuery = {} + programMatchQuery['tenantId'] = tenantId + + if (programId) { + programMatchQuery['_id'] = programId + } else { + programMatchQuery['externalId'] = programExternalId + } + + let programData = await programQueries.programsDocument(programMatchQuery, [ + 'name', + 'externalId', + 'isAPrivateProgram', + ]) + if (programData && programData.length > 0) { + return resolve(programData[0]) + } else { + return resolve(false) + } + } catch (error) { + return reject(error) + } + }) + } + + static async _updateOverviewAsync(programUsersId) { + // Placeholder for asynchronous overview update logic + // This could involve recalculating summary statistics or other data + console.log(`[ProgramUsers] Updating overview for programUsersId: ${programUsersId}`) + + try { + // Fetch program user document + const docData = await programUsersQueries.findById(programUsersId) + if (!docData) { + console.error('Program user document not found for overview update:', programUsersId) + return + } + + const entities = docData.entities || [] + // Count entities by status + const statusCounts = { + notonboarded: 0, + onboarded: 0, + inprogress: 0, + completed: 0, + graduated: 0, + droppedout: 0, + total: entities.length, + } + + entities.forEach((entity) => { + switch (entity.status) { + case 'ONBOARDED': + statusCounts.onboarded++ + break + case 'NOT_ONBOARDED': + statusCounts.notonboarded++ + break + case 'IN_PROGRESS': + statusCounts.inprogress++ + break + case 'COMPLETED': + statusCounts.completed++ + break + case 'GRADUATED': + statusCounts.graduated++ + break + case 'DROP_OUT': + statusCounts.droppedout++ + break + } + }) + + // Update overview with status counts + const result = await programUsersQueries + .updateOverview(programUsersId, { + $set: { + 'overview.assigned': statusCounts.total, + 'overview.notonboarded': statusCounts.notonboarded, + 'overview.onboarded': statusCounts.onboarded, + 'overview.inprogress': statusCounts.inprogress, + 'overview.completed': statusCounts.completed, + 'overview.graduated': statusCounts.graduated, + 'overview.droppedout': statusCounts.droppedout, + 'overview.lastModified': new Date(), + }, + }) + .catch((err) => console.error('Overview update error:', err)) + } catch (err) { + console.error('Error calculating entity counts:', err) + } + } } diff --git a/module/programUsers/validator/v1.js b/module/programUsers/validator/v1.js new file mode 100644 index 00000000..12277db7 --- /dev/null +++ b/module/programUsers/validator/v1.js @@ -0,0 +1,148 @@ +/** + * name : v1.js + * author : Ankit Shahu + * created-date : 9-Jan-2023 + * Description : Program Users validator. + */ + +module.exports = (req) => { + let programUsersValidator = { + createOrUpdate: function () { + // Either programId or programExternalId is required + req.checkBody('programId') + .custom((value) => { + const hasId = req.body.programId || req.body.programExternalId + if (!hasId) { + throw new Error('Either programId or programExternalId is required') + } + return true + }) + .optional() + + // Validate entity if provided + req.checkBody('entity') + .custom((value) => { + if (value) { + if (typeof value !== 'object' || Array.isArray(value)) { + throw new Error('entity must be an object') + } + if (!value.userId) { + throw new Error('entity.userId is required') + } + if (!value.entityId) { + throw new Error('entity.entityId is required') + } + } + return true + }) + .optional() + + // Validate referenceFrom if provided + req.checkBody('referenceFrom') + .custom((value) => { + if (value && (typeof value !== 'object' || Array.isArray(value))) { + throw new Error('referenceFrom must be an object') + } + return true + }) + .optional() + + // Validate hierarchy if provided + req.checkBody('hierarchy') + .custom((value) => { + if (value) { + if (!Array.isArray(value)) { + throw new Error('hierarchy must be an array') + } + value.forEach((h, index) => { + if (typeof h.level !== 'number' || h.level < 0) { + throw new Error(`hierarchy[${index}].level must be a non-negative number`) + } + if (!h.id || typeof h.id !== 'string') { + throw new Error(`hierarchy[${index}].id is required and must be a string`) + } + }) + } + return true + }) + .optional() + + // Validate status if provided + req.checkBody('status') + .custom((value) => { + if (value) { + const validStatuses = [ + 'ACTIVE', + 'INACTIVE', + 'NOT_ONBOARDED', + 'ONBOARDED', + 'IN_PROGRESS', + 'COMPLETED', + 'GRADUATED', + 'DROPPED_OUT', + ] + if (!validStatuses.includes(value)) { + throw new Error(`Invalid status. Must be one of: ${validStatuses.join(', ')}`) + } + } + return true + }) + .optional() + + // Validate metaInformation if provided + req.checkBody('metaInformation') + .custom((value) => { + if (value && (typeof value !== 'object' || Array.isArray(value))) { + throw new Error('metaInformation must be an object') + } + return true + }) + .optional() + }, + + getEntities: function () { + // Either programId or programExternalId is required + req.checkQuery('programId') + .custom((value) => { + const hasId = req.query.programId || req.query.programExternalId + if (!hasId) { + throw new Error('Either programId or programExternalId is required') + } + return true + }) + .optional() + + // Validate pagination parameters + req.checkQuery('page') + .custom((value) => { + if (value) { + const pageNum = parseInt(value) + if (isNaN(pageNum) || pageNum < 1) { + throw new Error('page must be a positive number') + } + } + return true + }) + .optional() + + req.checkQuery('limit') + .custom((value) => { + if (value) { + const limitNum = parseInt(value) + if (isNaN(limitNum) || limitNum < 1) { + throw new Error('limit must be a positive number') + } + } + return true + }) + .optional() + + // Validate search if provided + req.checkQuery('search').isString().withMessage('search must be a string').optional() + }, + } + + if (programUsersValidator[req.params.method]) { + programUsersValidator[req.params.method]() + } +} diff --git a/module/project/templates/helper.js b/module/project/templates/helper.js index 67f7b1af..2a62a4d2 100644 --- a/module/project/templates/helper.js +++ b/module/project/templates/helper.js @@ -81,10 +81,7 @@ module.exports = class ProjectTemplatesHelper { matchQuery['tenantId'] = userDetails.tenantAndOrgInfo.tenantId matchQuery['externalId'] = { $in: categoryIds } // what is category documents - let categories = await projectCategoriesQueries.categoryDocuments(matchQuery, [ - 'externalId', - 'name', - ]) + let categories = await projectCategoriesQueries.categoryDocuments(matchQuery, ['externalId']) if (!categories.length > 0) { throw { @@ -99,7 +96,6 @@ module.exports = class ProjectTemplatesHelper { [category.externalId]: { _id: ObjectId(category._id), externalId: category.externalId, - name: category.name, }, }), {} @@ -1982,13 +1978,23 @@ module.exports = class ProjectTemplatesHelper { * @returns {Object} - project templates list. */ - static list(pageNo = '', pageSize = '', searchText = '', currentOrgOnly = false, userDetails) { + static list( + pageNo = '', + pageSize = '', + searchText = '', + currentOrgOnly = false, + userDetails, + categoryIds = '', + groupByCategory = false, + taskDetails = false + ) { return new Promise(async (resolve, reject) => { try { // Create a query object with the 'isReusable' property set to true. let queryObject = { isReusable: true } currentOrgOnly = UTILS.convertStringToBoolean(currentOrgOnly) - + groupByCategory = UTILS.convertStringToBoolean(groupByCategory) + taskDetails = UTILS.convertStringToBoolean(taskDetails) queryObject['tenantId'] = userDetails.userInformation.tenantId // handle currentOrgOnly filter @@ -2006,6 +2012,27 @@ module.exports = class ProjectTemplatesHelper { ] } + // If 'categoryIds' are provided, add a filter for categories. + let categoryIdArray + + if (categoryIds && categoryIds !== '') { + categoryIdArray = categoryIds + .split(',') + .map((id) => id.trim()) + .filter((id) => id !== '') + if (categoryIdArray.length > 0) { + // Convert category IDs to ObjectIds if needed + const categoryObjectIds = categoryIdArray.map((id) => { + const objectId = UTILS.convertStringToObjectId(id) || id + //requestedCategorySet.add(id); + return objectId + }) + + // Filter by categories._id to match category objects within the categories array + queryObject['categories._id'] = { $in: categoryObjectIds } + } + } + // Call the 'templateDocument' function from 'projectTemplateQueries' // using the 'queryObject' to fetch templates. const templates = await projectTemplateQueries.templateDocument(queryObject) @@ -2015,8 +2042,48 @@ module.exports = class ProjectTemplatesHelper { const endIndex = pageNo * pageSize // Slice the 'templates' array to get paginated results. - const paginatedResults = templates.slice(startIndex, endIndex) + let paginatedResults = templates.slice(startIndex, endIndex) + + if (taskDetails) { + for (const template of paginatedResults) { + // Fetch tasks and subtasks for each template in paginated results + if (template.tasks && template.tasks.length > 0) { + template.tasks = await this.tasksAndSubTasks( + template._id, + '', + userDetails.userInformation.tenantId, + userDetails.userInformation.organizationId + ) + } + } + } + + if (groupByCategory) { + let groupedTemplates = {} + paginatedResults.forEach((template) => { + if (template.categories && template.categories.length > 0) { + template.categories.forEach((category) => { + let catId = category._id.toString() + if (categoryIdArray && categoryIdArray.length > 0) { + // Check if the current categoryId is in the requestedCategorySet + if (categoryIdArray.includes(catId)) { + if (!groupedTemplates[catId]) { + groupedTemplates[catId] = [] + } + groupedTemplates[catId].push(template) + } + } else { + if (!groupedTemplates[catId]) { + groupedTemplates[catId] = [] + } + groupedTemplates[catId].push(template) + } + }) + } + }) + paginatedResults = groupedTemplates + } // Resolve the promise with success, message, and paginated data. return resolve({ success: true, diff --git a/module/userProjects/helper.js b/module/userProjects/helper.js index 75e9f6a9..5d953008 100644 --- a/module/userProjects/helper.js +++ b/module/userProjects/helper.js @@ -36,6 +36,8 @@ const projectService = require(SERVICES_BASE_PATH + '/projects') const defaultUserProfileConfig = require('@config/defaultUserProfileDeleteConfig') const configFilePath = process.env.AUTH_CONFIG_FILE_PATH const surveyService = require(SERVICES_BASE_PATH + '/survey') +const programUsersService = require(SERVICES_BASE_PATH + '/programUsers') +const ObjectId = global.ObjectId || require('mongoose').Types.ObjectId /** * UserProjectsHelper @@ -373,7 +375,7 @@ module.exports = class UserProjectsHelper { if (data.tasks) { let taskReport = {} - updateProject.tasks = await _projectTask(data.tasks) + updateProject.tasks = await _projectTask(data.tasks, false, '', '', data.programId, userDetails) if (userProject[0].tasks && userProject[0].tasks.length > 0) { updateProject.tasks.forEach((task) => { @@ -1291,7 +1293,7 @@ module.exports = class UserProjectsHelper { "programId": "685140cbf891ccf74e05baf9", "observationId": "685146542054fe175c7150c8", "solutionId": "685140d1ffc25f705c56e99e", - } + } } */ @@ -1646,7 +1648,9 @@ module.exports = class UserProjectsHelper { projectTemplateId, userId, tenantId, - orgId + orgId, + userDetails, + solutionDetails.programId ) if (!projectCreation.success) { return resolve(projectCreation) @@ -2068,7 +2072,7 @@ module.exports = class UserProjectsHelper { * @returns {String} - message. */ - static userAssignedProjectCreation(templateId, userId, tenantId, orgId) { + static userAssignedProjectCreation(templateId, userId, tenantId, orgId, userDetails = {}, programId = '') { return new Promise(async (resolve, reject) => { try { const projectTemplateData = await projectTemplateQueries.templateDocument( @@ -2110,7 +2114,7 @@ module.exports = class UserProjectsHelper { orgId ) if (tasksAndSubTasks.length > 0) { - result.tasks = await _projectTask(tasksAndSubTasks) + result.tasks = await _projectTask(tasksAndSubTasks, false, '', '', programId, userDetails) result.tasks.forEach((task) => { if ( task && @@ -3089,12 +3093,15 @@ module.exports = class UserProjectsHelper { // If it is valid make sure we add those data to newly creating projects if (requestedData.entityId && requestedData.entityId !== '') { let entityInformation = await entitiesService.entityDocuments( - { _id: requestedData.entityId }, + { _id: requestedData.entityId, tenantId: tenantId }, CONSTANTS.common.ALL ) if (!entityInformation?.success || !entityInformation?.data?.length > 0) { - return resolve(entityInformation) + return resolve({ + success: false, + message: 'Entity information not found for the given entityId', + }) } libraryProjects.data['entityInformation'] = entityInformation.data[0] @@ -3207,7 +3214,7 @@ module.exports = class UserProjectsHelper { //Fetch user profile information. let addReportInfoToSolution = false - let userProfile = await projectService.profileRead(userToken) + let userProfile = await userService.profile(userId, userToken) // Check if the user profile fetch was successful if (!userProfile.success) { throw { @@ -4322,7 +4329,7 @@ module.exports = class UserProjectsHelper { * deleteUserPIIData function to delete users Data. * @method * @name deleteUserPIIData - * @param {userDeleteEvent} - userDeleteEvent message object + * @param {userDeleteEvent} - userDeleteEvent message object * { "entity": "user", "eventType": "delete", @@ -4435,6 +4442,612 @@ module.exports = class UserProjectsHelper { } catch (err) {} } + /** + * Create project plan from multiple templates. + * @method + * @name createProjectPlan + * @param {Object} data - request data. + * @param {String} userId - Logged in user id (admin). + * @param {String} userToken - User token. + * @param {Object} userDetails - loggedin user's info + * @returns {Object} Project Plan created information. + */ + static createProjectPlan(data, userId, userToken, userDetails) { + return new Promise(async (resolve, reject) => { + try { + const { templates, userId: participantId, entityId, programName, projectConfig } = data + let tenantId = userDetails.userInformation.tenantId + let orgId = userDetails.userInformation.organizationId + + // Step 1: Validations + // a. Validate Participant User + let userProfile = await userService.profile(participantId, userToken) + if (!userProfile.success || !userProfile.data) { + throw { + status: HTTP_STATUS_CODE.bad_request.status, + message: CONSTANTS.apiResponses.USER_NOT_FOUND, + } + } + + // b. Validate Entity + if (entityId) { + let entityInformation = await entitiesService.entityDocuments( + { _id: entityId, tenantId: tenantId }, + CONSTANTS.common.ALL + ) + if (!entityInformation?.success || !entityInformation?.data?.length > 0) { + throw { + status: HTTP_STATUS_CODE.bad_request.status, + message: CONSTANTS.apiResponses.ENTITY_NOT_FOUND, + } + } + } + + // c. Validate all Template IDs - must exist and be published + // Filter out any templates without templateId + const templateIds = templates + .map((t) => (t && t.templateId ? t.templateId : null)) + .filter((id) => id !== null) + + if (templateIds.length === 0) { + throw { + status: HTTP_STATUS_CODE.bad_request.status, + message: 'No valid template IDs provided', + } + } + + const validTemplates = await projectTemplateQueries.templateDocument( + { + _id: { $in: templateIds }, + status: CONSTANTS.common.PUBLISHED, + tenantId: tenantId, + }, + ['_id', 'title', 'categories', 'solutionId', 'solutionExternalId', 'externalId', 'taskSequence'] + ) + + if (validTemplates.length !== templates.length) { + throw { + status: HTTP_STATUS_CODE.bad_request.status, + message: CONSTANTS.apiResponses.PROJECT_TEMPLATE_NOT_FOUND, + } + } + + // Collect all unique category IDs from templates + let allCategoryIds = [] + const categoryIdSet = new Set() // To avoid duplicates + validTemplates.forEach((template) => { + if (template.categories && Array.isArray(template.categories)) { + template.categories.forEach((category) => { + const categoryId = category._id?.toString() || category + if (categoryId && !categoryIdSet.has(categoryId)) { + categoryIdSet.add(categoryId) + allCategoryIds.push(categoryId) + } + }) + } + }) + + // Fetch full category documents from projectCategories collection + let allCategories = [] + if (allCategoryIds.length > 0) { + allCategories = await projectCategoriesQueries.categoryDocuments( + { + _id: { $in: allCategoryIds }, + tenantId: tenantId, + }, + ['_id', 'name', 'externalId', 'evidences'] + ) + } + + // Step 2: Always Create New Program & Solution + let programAndSolutionData = { + programName: programName || `Program for ${userProfile.data.name}`, + isPrivateProgram: true, + type: CONSTANTS.common.IMPROVEMENT_PROJECT, + subType: CONSTANTS.common.IMPROVEMENT_PROJECT, + isReusable: false, + entities: entityId ? [entityId] : [], + } + + let programAndSolutionInformation = await solutionsHelper.createProgramAndSolution( + userId, + programAndSolutionData, + false, + userDetails, + true // isExternalProgram + ) + + if (!programAndSolutionInformation.success) { + throw { + status: HTTP_STATUS_CODE.bad_request.status, + message: CONSTANTS.apiResponses.SOLUTION_PROGRAMS_NOT_CREATED, + } + } + + const masterProgramId = programAndSolutionInformation.result.program._id + const masterSolutionId = programAndSolutionInformation.result.solution._id + + let acl = {} + if (userDetails.userInformation.userId != participantId) { + acl = { + visibility: CONSTANTS.common.PROJECT_VISIBILITY_SPECIFIC, + users: [participantId, userDetails.userInformation.userId], + } + } else { + acl = { + visibility: CONSTANTS.common.PROJECT_VISIBILITY_SELF, + users: [participantId], + } + } + + // Step 3: Initialize Single Project + let projectData = { + acl: acl, + title: projectConfig?.name || `${userProfile.data.name}'s Project Plan`, + description: projectConfig?.description || 'Individual Development Plan', + userId: participantId, + createdBy: userId, + updatedBy: userId, + status: CONSTANTS.common.STARTED, + lastDownloadedAt: new Date(), + isAPrivateProgram: true, + programId: masterProgramId, + programExternalId: programAndSolutionInformation.result.program.externalId, + solutionId: masterSolutionId, + solutionExternalId: programAndSolutionInformation.result.solution.externalId, + programInformation: { + _id: masterProgramId, + name: programAndSolutionInformation.result.program.name, + externalId: programAndSolutionInformation.result.program.externalId, + }, + solutionInformation: { + _id: masterSolutionId, + name: programAndSolutionInformation.result.solution.name, + externalId: programAndSolutionInformation.result.solution.externalId, + }, + tenantId: tenantId, + orgId: orgId, + categories: allCategories, // Add categories from all templates + keywords: ['project-plan'], + referenceFrom: projectConfig?.referenceFrom || '', + tasks: [], + taskSequence: [], + userProfile: userProfile.data, + createdAt: new Date(), + updatedAt: new Date(), + } + + // Add project templates to project data + projectData.projectTemplates = validTemplates.map((template) => ({ + _id: template._id instanceof global.ObjectId ? template._id : new global.ObjectId(template._id), + externalId: template.externalId, + })) + + // Add entity information if provided + if (entityId) { + const entityInfo = await entitiesService.entityDocuments( + { _id: entityId, tenantId: tenantId }, + CONSTANTS.common.ALL + ) + if (entityInfo?.success && entityInfo?.data?.length > 0) { + const entity = entityInfo.data[0] + projectData.entityInformation = { + _id: entity._id, + entityType: entity.entityType, + entityTypeId: entity.entityTypeId, + entityId: entity._id, + externalId: entity?.metaInformation?.externalId || '', + entityName: entity?.metaInformation?.name || '', + } + projectData.entityId = entity._id + } + } + + // Step 4: Template Orchestration (Loop for each template) + for (let templateIndex = 0; templateIndex < templates.length; templateIndex++) { + const template = templates[templateIndex] + + // Validate template has templateId + if (!template || !template.templateId) { + continue + } + + // a. Fetch Template metadata + const templateData = validTemplates.find((t) => { + if (!t || !t._id || !template.templateId) return false + return t._id.toString() === template.templateId.toString() + }) + + if (!templateData) { + continue // Skip invalid templates + } + + // b. Create improvementProject task at root level first (to get its ID) + const taskName = + template.targetTaskName || + template.targetProjectName || + templateData.title || + `Template ${templateIndex + 1}` + const taskExternalId = `task-${uuidv4().replace(/-/g, '')}` + const improvementTaskId = uuidv4() + + // c. Fetch Template Tasks and Subtasks + const templateTasks = await projectTemplatesHelper.tasksAndSubTasks( + template.templateId, + '', // language + tenantId, + orgId + ) + + if (!templateTasks || templateTasks.length === 0) { + continue + } + + let excludedExternalIds = [] + let filteredTemplateTasks = templateTasks + if ( + template.excludedTaskIds && + Array.isArray(template.excludedTaskIds) && + template.excludedTaskIds.length > 0 + ) { + // Create a map for quick task lookup + const templateTaskMap = new Map(templateTasks.map((task) => [task._id.toString(), task])) + + for (const taskId of template.excludedTaskIds) { + const task = templateTaskMap.get(taskId.toString()) + if (!task) { + throw { + status: HTTP_STATUS_CODE.bad_request.status, + message: `Task ID ${taskId} not found in template ${template.templateId}`, + } + } + + const isDeletable = task.hasOwnProperty('isDeletable') ? task.isDeletable : false + if (!isDeletable) { + throw { + status: HTTP_STATUS_CODE.bad_request.status, + message: `Task ${task.name} (${taskId}) is not deletable and cannot be excluded`, + } + } + excludedExternalIds.push(task.externalId) + } + + filteredTemplateTasks = templateTasks.filter( + (task) => !template.excludedTaskIds.includes(task._id.toString()) + ) + } + + // Ensure all tasks have _id before processing (required by _projectTask) + const tasksWithIds = filteredTemplateTasks + .map((task) => { + if (task && !task._id) { + task._id = uuidv4() + } + return task + }) + .filter((task) => task !== null && task !== undefined) + + // d. Process Template Tasks using _projectTask with improvementTaskId as parent + let processedTemplateTasks = [] + try { + processedTemplateTasks = await _projectTask( + tasksWithIds, + true, // isImportedFromLibrary + improvementTaskId, // parentTaskId - set to improvementTask._id + userToken, + masterProgramId, + userDetails + ) + } catch (error) { + console.error(`Error processing template tasks for template ${template.templateId}:`, error) + throw error + } + + // Ensure processedTemplateTasks is an array + if (!Array.isArray(processedTemplateTasks)) { + processedTemplateTasks = [] + } + + // e. Process Custom Tasks if provided + let processedCustomTasks = [] + if (template.customTasks && template.customTasks.length > 0) { + // Preserve metaInformation from original request before processing + const originalMetaInformation = template.customTasks.map((task) => { + return task && task.metaInformation ? { ...task.metaInformation } : null + }) + + // Ensure all custom tasks have _id before processing + const customTasksWithIds = template.customTasks + .map((task) => { + if (task && !task._id) { + task._id = uuidv4() + } + return task + }) + .filter((task) => task !== null && task !== undefined) + + try { + processedCustomTasks = await _projectTask( + customTasksWithIds, + false, // isImportedFromLibrary + improvementTaskId, // parentTaskId - set to improvementTask._id + userToken, + masterProgramId, + userDetails + ) + } catch (error) { + console.error(`Error processing custom tasks for template ${template.templateId}:`, error) + throw error + } + + // Ensure processedCustomTasks is an array + if (!Array.isArray(processedCustomTasks)) { + processedCustomTasks = [] + } + + // Mark all custom tasks as isACustomTask: true and add metaInformation + processedCustomTasks.forEach((customTask, index) => { + customTask.isACustomTask = true + customTask.createdBy = userId + customTask.updatedBy = userId + customTask.createdAt = new Date() + customTask.updatedAt = new Date() + + // Add metaInformation for custom tasks + if (!customTask.metaInformation) { + customTask.metaInformation = {} + } + + // Use metaInformation from original request if present, else use defaults + const originalMeta = originalMetaInformation[index] + if (originalMeta) { + // Merge original metaInformation with processed task's metaInformation + customTask.metaInformation.buttonLabel = + originalMeta.buttonLabel || customTask.metaInformation.buttonLabel || 'Upload' + customTask.metaInformation.icon = + originalMeta.icon || customTask.metaInformation.icon || 'Upload' + } else { + // No metaInformation in original request, use defaults + customTask.metaInformation.buttonLabel = + customTask.metaInformation.buttonLabel || 'Upload' + customTask.metaInformation.icon = customTask.metaInformation.icon || 'Upload' + } + }) + } + + // f. Ensure parentId is set correctly for all root-level subtasks + if (processedTemplateTasks && Array.isArray(processedTemplateTasks)) { + processedTemplateTasks.forEach((task) => { + if (task && (!task.parentId || task.parentId !== improvementTaskId)) { + task.parentId = improvementTaskId + } + }) + } + if (processedCustomTasks && Array.isArray(processedCustomTasks)) { + processedCustomTasks.forEach((task) => { + if (task && (!task.parentId || task.parentId !== improvementTaskId)) { + task.parentId = improvementTaskId + } + }) + } + + // g. Combine template tasks and custom tasks as children + const allSubTasks = [...processedTemplateTasks, ...processedCustomTasks] + + // h. Build taskSequence for improvementTask based on template's taskSequence + let improvementTaskSequence = [] + + // If template has taskSequence, use it to order the subtasks + if (templateData.taskSequence && templateData.taskSequence.length > 0) { + // Filter out excluded external IDs from template's taskSequence + const filteredTemplateTaskSequence = templateData.taskSequence.filter( + (extId) => !excludedExternalIds.includes(extId) + ) + + // Create a map of externalId to task for quick lookup + const taskMap = new Map() + allSubTasks.forEach((task) => { + if (task && task.externalId) { + taskMap.set(task.externalId, task) + } + }) + + // First, add tasks in template's taskSequence order + filteredTemplateTaskSequence.forEach((templateTaskExternalId) => { + const task = taskMap.get(templateTaskExternalId) + if (task && task.externalId) { + improvementTaskSequence.push(task.externalId) + taskMap.delete(templateTaskExternalId) // Remove to avoid duplicates + } + }) + + // Then, add any remaining tasks (custom tasks or tasks not in template sequence) + taskMap.forEach((task) => { + if (task && task.externalId) { + improvementTaskSequence.push(task.externalId) + } + }) + } else { + // If no template taskSequence, use the order of processed tasks + allSubTasks.forEach((subTask) => { + if (subTask && subTask.externalId) { + improvementTaskSequence.push(subTask.externalId) + } + }) + } + + let improvementTask = { + _id: improvementTaskId, + externalId: taskExternalId, + name: taskName, + description: template.targetTaskName || template.targetProjectName || templateData.title || '', + type: CONSTANTS.common.IMPROVEMENT_PROJECT, + status: CONSTANTS.common.NOT_STARTED_STATUS, + isACustomTask: false, + isDeletable: false, + isDeleted: false, + isImportedFromLibrary: false, + createdAt: new Date(), + updatedAt: new Date(), + createdBy: userId, + updatedBy: userId, + tenantId: tenantId, + orgId: orgId, + syncedAt: new Date(), + children: allSubTasks, // Template tasks + custom tasks as subtasks + taskSequence: improvementTaskSequence, // Children's externalIds in correct order + attachments: [], + projectTemplateDetails: { + _id: template.templateId, + externalId: templateData && templateData.externalId ? templateData.externalId : '', + name: templateData && templateData.title ? templateData.title : taskName, + }, + } + + // Add improvementProject task to project + // Root taskSequence should only contain improvementTask externalIds (not subtasks) + projectData.tasks.push(improvementTask) + projectData.taskSequence.push(taskExternalId) + } + + // Step 5: Initialize task report for Project + // Count all tasks (root level improvementProject tasks) + const activeTasks = projectData.tasks.filter((t) => !t.isDeleted) + let taskReport = { + total: activeTasks.length, + } + + activeTasks.forEach((task) => { + if (task.isDeleted == false) { + if (!taskReport[task.status]) { + taskReport[task.status] = 1 + } else { + taskReport[task.status] += 1 + } + } + }) + + projectData.taskReport = taskReport + // Step 6: Create Single Project + // Ensure tasks array is properly initialized (should already be an array) + if (!Array.isArray(projectData.tasks)) { + projectData.tasks = [] + } + + let createdProject = await projectQueries.createProject(projectData) + // Verify tasks were saved + if (!createdProject.tasks || createdProject.tasks.length === 0) { + console.error('WARNING: Project created but tasks array is empty!') + console.error('Tasks that should have been saved:', projectData.tasks.length) + } + + // Push to Kafka for event streaming + await this.attachEntityInformationIfExists(createdProject) + let programUserMapping = await this.handleProgramUserMapping({ + userId: participantId, + project: createdProject, + }) + console.log('Program User Mapping Result:*****************', programUserMapping) + await kafkaProducersHelper.pushProjectToKafka(createdProject) + await kafkaProducersHelper.pushUserActivitiesToKafka({ + userId: participantId, + projects: createdProject, + }) + + return resolve({ + success: true, + message: CONSTANTS.apiResponses.PROJECT_PLAN_CREATED, + data: { + projectId: createdProject._id, + }, + result: { + projectId: createdProject._id, + }, + }) + } catch (error) { + return reject({ + status: error.status ? error.status : HTTP_STATUS_CODE.internal_server_error.status, + message: error.message || error, + }) + } + }) + } + + /*Add user to program user mapping on project creation*/ + static async handleProgramUserMapping(eventData) { + return new Promise(async (resolve, reject) => { + try { + let { userId, project } = eventData + let createdBy = project.createdBy + let projectProgramId = project.programId + let programExternalId = project.programExternalId + let hierarchy = [] + if (userId != createdBy) { + hierarchy.push({ + level: 0, + id: createdBy, + }) + } + //let programUsersRef = await programUsersService.findByUserAndProgram(userId, projectProgramId); + //if (!programUsersRef) { + let result = await programUsersService.createOrUpdate({ + userId: userId, + hierarchy: hierarchy, + programId: projectProgramId, + programExternalId: programExternalId, + entities: [], + status: 'IN_PROGRESS', + metaInformation: { + idpAssignedAt: new Date(), + idpAssignedBy: createdBy, + idpProjectId: project._id, + }, + createdBy: createdBy, + updatedBy: createdBy, + referenceFrom: project.referenceFrom ? new ObjectId(project.referenceFrom) : null, + tenantId: project.tenantId, + orgId: project.orgId, + }) + + if (result.result._id) { + console.log('PARTICIPANT_PROGRAMUSERS_ASSIGNED', result.result._id) + if (project.referenceFrom) { + let result2 = await programUsersService.updateEntity( + createdBy, + project.referenceFrom, + '', + `${userId}`, + { + status: 'IN_PROGRESS', + idpProjectId: project._id, + participantProgramUserReference: result.result._id, + }, + project.tenantId + ) + if (result2._id) { + console.log('LC_PROGRAMUSERS_ENTITY_UPDATED', result2._id) + } else { + console.log('LC_PROGRAMUSERS_ENTITY_ASSIGNMENT_FAILED', result2) + } + } + } else { + console.log('PARTICIPANT_PROGRAMUSERS_ASSIGNMENT_FAILED', result.result) + } + + return resolve({ + success: true, + message: 'Program user mapping handled successfully', + result: result.result, + }) + } catch (error) { + return reject({ + success: false, + status: error.status ? error.status : HTTP_STATUS_CODE.internal_server_error.status, + message: error.message || error, + }) + } + }) + } /** * Get project infromation when project as a task * @method @@ -4906,9 +5519,9 @@ async function _projectTask( singleTask.solutionDetails._id, '', { - name: `${singleTask.solutionDetails.name}-${timestamp}`, + name: `${singleTask.solutionDetails.name}`, externalId: `${singleTask.solutionDetails.externalId}-${timestamp}`, - description: `${singleTask.solutionDetails.name}-${timestamp}`, + description: `${singleTask.solutionDetails.name}`, programExternalId: programId, status: CONSTANTS.common.PUBLISHED_STATUS, tenantData: userDetails.tenantAndOrgInfo, diff --git a/module/userProjects/validator/v1.js b/module/userProjects/validator/v1.js index 780ffb75..5869baa6 100644 --- a/module/userProjects/validator/v1.js +++ b/module/userProjects/validator/v1.js @@ -132,6 +132,39 @@ module.exports = (req) => { } req.checkQuery(existId).exists().withMessage('required solution or projectId Id') }, + createProjectPlan: function () { + // Validate templates array + req.checkBody('templates') + .exists() + .withMessage('templates array is required') + .isArray() + .withMessage('templates must be an array') + .custom((templates) => { + if (!templates || templates.length === 0) { + throw new Error('templates array cannot be empty') + } + return templates.every((template) => { + if (!template.templateId) { + throw new Error('Each template must have a templateId') + } + return true + }) + }) + + // Validate userId - must be provided and not empty + req.checkBody('userId') + .exists() + .withMessage('userId is required') + .notEmpty() + .withMessage('userId cannot be empty') + + // Validate entityId - must be provided and not empty + req.checkBody('entityId') + .exists() + .withMessage('entityId is required') + .notEmpty() + .withMessage('entityId cannot be empty') + }, } if (projectsValidator[req.params.method]) {