From e9cf645d1a8623c47364556555114b614d89a09b Mon Sep 17 00:00:00 2001 From: vaishali k Date: Thu, 8 Jan 2026 05:50:17 +0530 Subject: [PATCH 01/10] Issue #251045 feat: Hierarchical Categories Implementation --- .../elevate-project/configs.json | 14 + controllers/v1/library/categories.js | 35 +- controllers/v1/project/templates.js | 16 +- generics/constants/api-responses.js | 2 + models/project-categories.js | 32 ++ module/library/categories/helper.js | 491 +++++++++++++++++- module/library/categories/validator/v1.js | 18 + module/project/templates/helper.js | 45 +- 8 files changed, 633 insertions(+), 20 deletions(-) diff --git a/constants/interface-routes/elevate-project/configs.json b/constants/interface-routes/elevate-project/configs.json index 18b611e3..ade91573 100644 --- a/constants/interface-routes/elevate-project/configs.json +++ b/constants/interface-routes/elevate-project/configs.json @@ -905,6 +905,20 @@ ], "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/programs/create", "type": "POST", diff --git a/controllers/v1/library/categories.js b/controllers/v1/library/categories.js index f5e9d493..4f79f923 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 @@ -254,4 +254,33 @@ module.exports = class LibraryCategories extends Abstract { } }) } + + /** + * List of library categories + * @method + * @name list + * @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, + }) + } + }) + } } diff --git a/controllers/v1/project/templates.js b/controllers/v1/project/templates.js index 9456e14f..8250ead4 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,9 @@ 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 ) // 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/models/project-categories.js b/models/project-categories.js index 290a2917..ead915e5 100644 --- a/models/project-categories.js +++ b/models/project-categories.js @@ -66,11 +66,43 @@ module.exports = { default: [], index: true, }, + description: { + type: String, + index: true, + 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, + }, }, compoundIndex: [ { name: { externalId: 1, tenantId: 1 }, indexType: { unique: true }, }, + { + name: { parent_id: 1, tenantId: 1, sequenceNumber: 1 }, + }, + { + name: { tenantId: 1, hasChildCategories: 1 }, + }, ], } diff --git a/module/library/categories/helper.js b/module/library/categories/helper.js index 2bfab450..0ac45a92 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.parent_id) { + // Find max sequenceNumber among siblings + const siblings = await projectCategoriesQueries.categoryDocuments( + { + parent_id: categoryData.parent_id, + 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( + { + parent_id: 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, @@ -777,6 +1010,8 @@ module.exports = class LibraryCategoriesHelper { // create query to fetch assets query['tenantId'] = tenantId + query['status'] = CONSTANTS.common.ACTIVE_STATUS + query['isDeleted'] = false // handle currentOrgOnly filter if (req.query['currentOrgOnly']) { @@ -785,13 +1020,73 @@ module.exports = class LibraryCategoriesHelper { query['orgId'] = { $in: ['ALL', req.userDetails.userInformation.organizationId] } } } - query['status'] = CONSTANTS.common.ACTIVE_STATUS + // Handle parentId query param. Accepts: actual id, omitted, or the string 'null' (for root) + let parentCategory = null + if (req.query.parentId) { + const rawParent = req.query.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 (req.query.keywords && req.query.keywords.trim() !== '') { + const keywordsArray = req.query.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 (req.searchText && req.searchText.trim() !== '') { + const searchTerm = req.searchText.trim() + query['$or'] = [ + { name: new RegExp(searchTerm, 'i') }, + { description: new RegExp(searchTerm, 'i') }, + { externalId: new RegExp(searchTerm, 'i') }, + ] + } + let categoryData = await projectCategoriesQueries.categoryDocuments(query, [ 'externalId', 'name', 'icon', 'updatedAt', 'noOfProjects', + 'description', + 'keywords', + 'parentId', + 'hasChildCategories', + 'sequenceNumber', + 'metaInformation', ]) if (!categoryData.length > 0) { @@ -815,6 +1110,188 @@ 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 && associatedTemplates.length > 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, + } + + let children = await projectCategoriesQueries.categoryDocuments(childrenQuery, [ + 'externalId', + 'name', + 'icon', + 'updatedAt', + 'noOfProjects', + 'description', + 'keywords', + 'parentId', + 'hasChildCategories', + 'sequenceNumber', + 'metaInformation', + ]) + + 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 +1393,7 @@ function handleEvidenceUpload(files, userId) { * @returns {Object} returns modified matchQuery */ /** - * + * Sample for matchQuery obj when orgExtension.externalProjectResourceVisibilityPolicy = CURRENT { "$match": { @@ -928,7 +1405,7 @@ function handleEvidenceUpload(files, userId) { } */ /** - * + * Sample for matchQuery obj when orgExtension.externalProjectResourceVisibilityPolicy = ASSOCIATED { "$match": { @@ -958,7 +1435,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..99f5d9e2 100644 --- a/module/project/templates/helper.js +++ b/module/project/templates/helper.js @@ -1982,7 +1982,15 @@ 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 + ) { return new Promise(async (resolve, reject) => { try { // Create a query object with the 'isReusable' property set to true. @@ -2006,6 +2014,22 @@ module.exports = class ProjectTemplatesHelper { ] } + // If 'categoryIds' are provided, add a filter for categories. + if (categoryIds && categoryIds !== '') { + const 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) => { + return UTILS.convertStringToObjectId(id) || id + }) + // 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 +2039,23 @@ 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 (groupByCategory) { + let groupedTemplates = {} + paginatedResults.forEach((template) => { + if (template.categories && template.categories.length > 0) { + template.categories.forEach((category) => { + const catId = category._id.toString() // Convert ObjectId to string for key + if (!groupedTemplates[catId]) { + groupedTemplates[catId] = [] + } + groupedTemplates[catId].push(template) + }) + } + }) + paginatedResults = groupedTemplates + } // Resolve the promise with success, message, and paginated data. return resolve({ success: true, From 554d27ab0fd579257cb3ae6ed31e225a7257f4aa Mon Sep 17 00:00:00 2001 From: vaishali k Date: Thu, 8 Jan 2026 05:54:37 +0530 Subject: [PATCH 02/10] added details API --- .../elevate-project/configs.json | 14 ++++++++ controllers/v1/library/categories.js | 32 ++++++++++++++++++- 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/constants/interface-routes/elevate-project/configs.json b/constants/interface-routes/elevate-project/configs.json index ade91573..b2f9eb05 100644 --- a/constants/interface-routes/elevate-project/configs.json +++ b/constants/interface-routes/elevate-project/configs.json @@ -919,6 +919,20 @@ ], "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 4f79f923..a427c26f 100644 --- a/controllers/v1/library/categories.js +++ b/controllers/v1/library/categories.js @@ -256,7 +256,7 @@ module.exports = class LibraryCategories extends Abstract { } /** - * List of library categories + * delete a library category * @method * @name list * @param {Object} req - requested data @@ -283,4 +283,34 @@ module.exports = class LibraryCategories extends Abstract { } }) } + + /** + * read a library category + * @method + * @name list + * @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 + + 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, + }) + } + }) + } } From 030f146a15fd4b9e4503aab0f935b2358c9771c8 Mon Sep 17 00:00:00 2001 From: vaishali k Date: Thu, 8 Jan 2026 17:33:34 +0530 Subject: [PATCH 03/10] Issue #000 fix: metainformation added --- controllers/v1/library/categories.js | 4 +- controllers/v1/project/templates.js | 3 +- models/project-categories.js | 4 ++ module/library/categories/helper.js | 58 ++++++++++++++++------------ module/project/templates/helper.js | 17 +++++++- 5 files changed, 58 insertions(+), 28 deletions(-) diff --git a/controllers/v1/library/categories.js b/controllers/v1/library/categories.js index a427c26f..5dec79c4 100644 --- a/controllers/v1/library/categories.js +++ b/controllers/v1/library/categories.js @@ -240,7 +240,7 @@ 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 @@ -287,7 +287,7 @@ module.exports = class LibraryCategories extends Abstract { /** * read a library category * @method - * @name list + * @name details * @param {Object} req - requested data * @returns {Array} Library categories. */ diff --git a/controllers/v1/project/templates.js b/controllers/v1/project/templates.js index 8250ead4..6994cf7f 100644 --- a/controllers/v1/project/templates.js +++ b/controllers/v1/project/templates.js @@ -824,7 +824,8 @@ module.exports = class ProjectTemplates extends Abstract { req.query.currentOrgOnly ? req.query.currentOrgOnly : false, req.userDetails, req.query.categoryIds ? req.query.categoryIds : '', - req.query.groupByCategory ? req.query.groupByCategory : false + 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/models/project-categories.js b/models/project-categories.js index ead915e5..5516021b 100644 --- a/models/project-categories.js +++ b/models/project-categories.js @@ -92,6 +92,10 @@ module.exports = { default: 0, index: true, }, + metaInformation: { + type: Object, + default: {}, + }, }, compoundIndex: [ { diff --git a/module/library/categories/helper.js b/module/library/categories/helper.js index 0ac45a92..d1094c82 100644 --- a/module/library/categories/helper.js +++ b/module/library/categories/helper.js @@ -999,11 +999,11 @@ 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] }, } @@ -1014,16 +1014,16 @@ module.exports = class LibraryCategoriesHelper { 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] } } } // Handle parentId query param. Accepts: actual id, omitted, or the string 'null' (for root) let parentCategory = null - if (req.query.parentId) { - const rawParent = req.query.parentId + 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 @@ -1055,8 +1055,8 @@ module.exports = class LibraryCategoriesHelper { } // Add keywords filter - categories must have at least one of the specified keywords - if (req.query.keywords && req.query.keywords.trim() !== '') { - const keywordsArray = req.query.keywords + if (params.keywords && params.keywords.trim() !== '') { + const keywordsArray = params.keywords .split(',') .map((k) => k.trim()) .filter((k) => k !== '') @@ -1066,8 +1066,8 @@ module.exports = class LibraryCategoriesHelper { } // Add search functionality for name and description (separate from keywords filter) - if (req.searchText && req.searchText.trim() !== '') { - const searchTerm = req.searchText.trim() + if (searchText && searchText.trim() !== '') { + const searchTerm = searchText.trim() query['$or'] = [ { name: new RegExp(searchTerm, 'i') }, { description: new RegExp(searchTerm, 'i') }, @@ -1075,19 +1075,8 @@ module.exports = class LibraryCategoriesHelper { ] } - let categoryData = await projectCategoriesQueries.categoryDocuments(query, [ - 'externalId', - 'name', - 'icon', - 'updatedAt', - 'noOfProjects', - 'description', - 'keywords', - 'parentId', - 'hasChildCategories', - 'sequenceNumber', - 'metaInformation', - ]) + const skipFields = ['__v', 'createdAt', 'updatedAt', 'createdBy', 'updatedBy'] + let categoryData = await projectCategoriesQueries.categoryDocuments(query, 'all', skipFields) if (!categoryData.length > 0) { throw { @@ -1096,6 +1085,27 @@ module.exports = class LibraryCategoriesHelper { } } + // If getChildren is true, fetch immediate children for each category + if (params.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, diff --git a/module/project/templates/helper.js b/module/project/templates/helper.js index 99f5d9e2..afc82b04 100644 --- a/module/project/templates/helper.js +++ b/module/project/templates/helper.js @@ -1989,7 +1989,8 @@ module.exports = class ProjectTemplatesHelper { currentOrgOnly = false, userDetails, categoryIds = '', - groupByCategory = false + groupByCategory = false, + taskDetails = false ) { return new Promise(async (resolve, reject) => { try { @@ -2041,6 +2042,20 @@ module.exports = class ProjectTemplatesHelper { // Slice the 'templates' array to get paginated results. 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) => { From ceb95983ee20fdf0201d4b55e9a15587333c59a3 Mon Sep 17 00:00:00 2001 From: vaishali k Date: Thu, 8 Jan 2026 23:21:02 +0530 Subject: [PATCH 04/10] Issue #000 fix: parent_id is not defined --- models/project-categories.js | 2 +- module/library/categories/helper.js | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/models/project-categories.js b/models/project-categories.js index 5516021b..3ab91ccc 100644 --- a/models/project-categories.js +++ b/models/project-categories.js @@ -103,7 +103,7 @@ module.exports = { indexType: { unique: true }, }, { - name: { parent_id: 1, tenantId: 1, sequenceNumber: 1 }, + name: { parentId: 1, tenantId: 1, sequenceNumber: 1 }, }, { name: { tenantId: 1, hasChildCategories: 1 }, diff --git a/module/library/categories/helper.js b/module/library/categories/helper.js index d1094c82..9da1377e 100644 --- a/module/library/categories/helper.js +++ b/module/library/categories/helper.js @@ -916,11 +916,11 @@ module.exports = class LibraryCategoriesHelper { // Auto-assign sequenceNumber based on siblings under same parent let sequenceNumber = 0 - if (categoryData.parent_id) { + if (categoryData.parentId) { // Find max sequenceNumber among siblings const siblings = await projectCategoriesQueries.categoryDocuments( { - parent_id: categoryData.parent_id, + parentId: categoryData.parentId, tenantId: tenantId, isDeleted: false, }, @@ -935,7 +935,7 @@ module.exports = class LibraryCategoriesHelper { // For root level categories, find max sequenceNumber among root categories const rootCategories = await projectCategoriesQueries.categoryDocuments( { - parent_id: null, + parentId: null, tenantId: tenantId, isDeleted: false, }, @@ -1175,7 +1175,7 @@ module.exports = class LibraryCategoriesHelper { // Check if category has associated projects const associatedTemplates = categoryData.noOfProjects || 0 - if (associatedTemplates && associatedTemplates.length > 0) { + if (associatedTemplates > 0) { throw { status: HTTP_STATUS_CODE.bad_request.status, message: From 173caa3fc8a8630dc68768c70b78279b6aaa6159 Mon Sep 17 00:00:00 2001 From: vaishali k Date: Thu, 8 Jan 2026 23:29:29 +0530 Subject: [PATCH 05/10] Issue #000 fix: parent_id is not defined --- module/project/templates/helper.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/module/project/templates/helper.js b/module/project/templates/helper.js index afc82b04..b0200826 100644 --- a/module/project/templates/helper.js +++ b/module/project/templates/helper.js @@ -1997,7 +1997,8 @@ module.exports = class ProjectTemplatesHelper { // 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 From 5aa79a817003f87515227632d71468aeb887c8ca Mon Sep 17 00:00:00 2001 From: vaishali k Date: Fri, 9 Jan 2026 00:28:51 +0530 Subject: [PATCH 06/10] Coderabbit comments resolved --- module/project/templates/helper.js | 30 ++++++++++++++++++++++-------- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/module/project/templates/helper.js b/module/project/templates/helper.js index b0200826..259f70fb 100644 --- a/module/project/templates/helper.js +++ b/module/project/templates/helper.js @@ -2017,16 +2017,21 @@ module.exports = class ProjectTemplatesHelper { } // If 'categoryIds' are provided, add a filter for categories. + let categoryIdArray + if (categoryIds && categoryIds !== '') { - const categoryIdArray = 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) => { - return UTILS.convertStringToObjectId(id) || 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 } } @@ -2058,15 +2063,24 @@ module.exports = class ProjectTemplatesHelper { } if (groupByCategory) { - let groupedTemplates = {} paginatedResults.forEach((template) => { if (template.categories && template.categories.length > 0) { - template.categories.forEach((category) => { - const catId = category._id.toString() // Convert ObjectId to string for key - if (!groupedTemplates[catId]) { - groupedTemplates[catId] = [] + template.categories.forEach((categoryId) => { + const catId = categoryId.toString() // Convert ObjectId to string for key + 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) } - groupedTemplates[catId].push(template) }) } }) From a77b0370e3c96dd2b2007784472030fe1a670abe Mon Sep 17 00:00:00 2001 From: vaishali k Date: Fri, 9 Jan 2026 07:02:06 +0530 Subject: [PATCH 07/10] Resolving commnets --- controllers/v1/library/categories.js | 2 +- models/project-templates.js | 1 - module/library/categories/helper.js | 17 +++-------------- module/project/templates/helper.js | 12 +++++------- 4 files changed, 9 insertions(+), 23 deletions(-) diff --git a/controllers/v1/library/categories.js b/controllers/v1/library/categories.js index 5dec79c4..2de50cca 100644 --- a/controllers/v1/library/categories.js +++ b/controllers/v1/library/categories.js @@ -258,7 +258,7 @@ module.exports = class LibraryCategories extends Abstract { /** * delete a library category * @method - * @name list + * @name delete * @param {Object} req - requested data * @returns {Array} Library categories. */ 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 9da1377e..8eeb3df4 100644 --- a/module/library/categories/helper.js +++ b/module/library/categories/helper.js @@ -1017,7 +1017,7 @@ module.exports = class LibraryCategoriesHelper { if (params['currentOrgOnly']) { let currentOrgOnly = UTILS.convertStringToBoolean(params['currentOrgOnly']) if (currentOrgOnly) { - query['orgId'] = { $in: ['ALL', req.userDetails.userInformation.organizationId] } + query['orgId'] = { $in: ['ALL', organizationId] } } } // Handle parentId query param. Accepts: actual id, omitted, or the string 'null' (for root) @@ -1271,19 +1271,8 @@ module.exports = class LibraryCategoriesHelper { isDeleted: false, } - let children = await projectCategoriesQueries.categoryDocuments(childrenQuery, [ - 'externalId', - 'name', - 'icon', - 'updatedAt', - 'noOfProjects', - 'description', - 'keywords', - 'parentId', - 'hasChildCategories', - 'sequenceNumber', - 'metaInformation', - ]) + const skipFields = ['__v', 'createdAt', 'updatedAt', 'createdBy', 'updatedBy'] + let children = await projectCategoriesQueries.categoryDocuments(childrenQuery, 'all', skipFields) categoryData[0].children = children } diff --git a/module/project/templates/helper.js b/module/project/templates/helper.js index 259f70fb..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, }, }), {} @@ -2063,10 +2059,12 @@ module.exports = class ProjectTemplatesHelper { } if (groupByCategory) { + let groupedTemplates = {} paginatedResults.forEach((template) => { if (template.categories && template.categories.length > 0) { - template.categories.forEach((categoryId) => { - const catId = categoryId.toString() // Convert ObjectId to string for key + 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)) { From a6b6f27ffca16b9d86da28dc645d0f569b4e6448 Mon Sep 17 00:00:00 2001 From: vaishali k Date: Fri, 9 Jan 2026 07:07:11 +0530 Subject: [PATCH 08/10] Resolving commnets --- module/library/categories/helper.js | 31 ++++++++++++++++------------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/module/library/categories/helper.js b/module/library/categories/helper.js index 8eeb3df4..2742f0c0 100644 --- a/module/library/categories/helper.js +++ b/module/library/categories/helper.js @@ -1087,22 +1087,25 @@ module.exports = class LibraryCategoriesHelper { // If getChildren is true, fetch immediate children for each category if (params.getChildren) { - for (let category of categoryData) { - let childrenQuery = { - parentId: category._id, - tenantId: tenantId, - status: CONSTANTS.common.ACTIVE_STATUS, - isDeleted: false, - } + 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 - ) + let children = await projectCategoriesQueries.categoryDocuments( + childrenQuery, + 'all', + skipFields + ) - category.children = children - category.childrenCount = children.length + category.children = children + category.childrenCount = children.length + } } } From 785dbe903f75a60a11d3d033ed268ed097dad46f Mon Sep 17 00:00:00 2001 From: vaishali k Date: Fri, 9 Jan 2026 15:12:42 +0530 Subject: [PATCH 09/10] Resolving commnets --- models/project-categories.js | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/models/project-categories.js b/models/project-categories.js index 3ab91ccc..d664c699 100644 --- a/models/project-categories.js +++ b/models/project-categories.js @@ -68,7 +68,6 @@ module.exports = { }, description: { type: String, - index: true, required: true, default: 'default', }, @@ -102,11 +101,9 @@ module.exports = { name: { externalId: 1, tenantId: 1 }, indexType: { unique: true }, }, + // For Query (parentId + tenantId queries) { - name: { parentId: 1, tenantId: 1, sequenceNumber: 1 }, - }, - { - name: { tenantId: 1, hasChildCategories: 1 }, + name: { parentId: 1, tenantId: 1 }, }, ], } From 57e3bd3097c568fc1a69f73c6322d26ea88eaf07 Mon Sep 17 00:00:00 2001 From: vaishali k Date: Mon, 12 Jan 2026 03:10:28 +0530 Subject: [PATCH 10/10] migration Added --- migrations/addHierarchyFieldsToCategories.js | 97 ++++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 migrations/addHierarchyFieldsToCategories.js 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() + } + }, +}