diff --git a/constants/interface-routes/elevate-project/configs.json b/constants/interface-routes/elevate-project/configs.json index 18b611e3..b2f9eb05 100644 --- a/constants/interface-routes/elevate-project/configs.json +++ b/constants/interface-routes/elevate-project/configs.json @@ -905,6 +905,34 @@ ], "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/programs/create", "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/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/generics/constants/api-responses.js b/generics/constants/api-responses.js index 216e3eb3..1e3c1565 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', diff --git a/migrations/addHierarchyFieldsToCategories.js b/migrations/addHierarchyFieldsToCategories.js new file mode 100644 index 00000000..f90c3b34 --- /dev/null +++ b/migrations/addHierarchyFieldsToCategories.js @@ -0,0 +1,97 @@ +/** + * Migration: addHierarchyFieldsToCategories + * Description: Add hierarchical fields to existing project categories + * Fields added: + * - parentId: ObjectId (null by default) + * - hasChildCategories: Boolean (false by default) + * - sequenceNumber: Number (0 by default) + * - metaInformation: Object ({} by default) + */ + +const mongoose = require('mongoose') + +module.exports = { + async up(db, client) { + const session = client.startSession() + try { + await session.withTransaction(async () => { + const categoriesCollection = db.collection('projectCategories') + + // Update all existing documents to add the new fields with default values + // Only update documents where parentId doesn't exist (for idempotency) + const result = await categoriesCollection.updateMany( + { + parentId: { $exists: false }, + }, + { + $set: { + parentId: null, + hasChildCategories: false, + sequenceNumber: 0, + metaInformation: {}, + }, + }, + { session } + ) + + console.log( + `Migration: addHierarchyFieldsToCategories - Updated ${result.modifiedCount} category documents` + ) + + // Create indexes if they don't exist + await categoriesCollection.createIndex({ parentId: 1, tenantId: 1 }, { session }) + console.log('Created compound index on parentId and tenantId') + + return result + }) + } catch (error) { + console.error('Error during migration addHierarchyFieldsToCategories:', error) + throw error + } finally { + await session.endSession() + } + }, + + async down(db, client) { + const session = client.startSession() + try { + await session.withTransaction(async () => { + const categoriesCollection = db.collection('projectCategories') + + // Remove the added fields from all documents + const result = await categoriesCollection.updateMany( + {}, + { + $unset: { + parentId: '', + hasChildCategories: '', + sequenceNumber: '', + metaInformation: '', + }, + }, + { session } + ) + + console.log( + `Migration rollback: addHierarchyFieldsToCategories - Reverted ${result.modifiedCount} category documents` + ) + + // Drop the compound index if it exists + try { + await categoriesCollection.dropIndex('parentId_1_tenantId_1', { session }) + console.log('Dropped compound index on parentId and tenantId') + } catch (err) { + // Index might not exist, which is fine + console.log('Compound index not found during rollback') + } + + return result + }) + } catch (error) { + console.error('Error during migration rollback addHierarchyFieldsToCategories:', error) + throw error + } finally { + await session.endSession() + } + }, +} 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/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/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,