diff --git a/.env.sample b/.env.sample index 60d200e3..d8fd6ada 100644 --- a/.env.sample +++ b/.env.sample @@ -98,4 +98,8 @@ 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 + + +ENABLE_CATEGORY_KAFKA_EVENTS=true // kafka category event service +KAFKA_CATEGORY_TOPIC=category-updates // Kafka topic to listen category account events \ No newline at end of file diff --git a/controllers/v1/library/categories.js b/controllers/v1/library/categories.js index f5e9d493..c7698ad6 100644 --- a/controllers/v1/library/categories.js +++ b/controllers/v1/library/categories.js @@ -71,27 +71,52 @@ module.exports = class LibraryCategories extends Abstract { * @param {Object} req - requested data * @returns {Array} Library Categories project. */ - async projects(req) { - return new Promise(async (resolve, reject) => { - try { - const libraryProjects = await libraryCategoriesHelper.projects( - req.params._id ? req.params._id : '', - req.pageSize, - req.pageNo, - req.searchText, - req.query.sort, - req.userDetails - ) + try { + // Support both single and multiple category IDs + let categoryIds = [] + + // Method 1: Single ID from path parameter (GET /categories/:id/projects) + if (req.params._id) { + categoryIds = [req.params._id] + } + // Method 2: Comma-separated IDs from query string (GET /categories/projects?ids=id1,id2,id3) + else if (req.query.ids) { + categoryIds = req.query.ids + .split(',') + .map((id) => id.trim()) + .filter((id) => id) + } - return resolve({ - message: libraryProjects.message, - result: libraryProjects.data, - }) - } catch (error) { - return reject(error) + if (!categoryIds || categoryIds.length === 0) { + throw { + status: HTTP_STATUS_CODE.bad_request.status, + message: + 'categoryIds required - provide as path param, query string (comma-separated), or request body array', + } } - }) + + const libraryProjects = await libraryCategoriesHelper.projects( + categoryIds, + req.pageSize, + req.pageNo, + req.searchText, + req.query.sort, + req.userDetails + ) + + return { + success: true, + message: libraryProjects.message, + result: libraryProjects.data, + } + } catch (error) { + return { + status: error.status || HTTP_STATUS_CODE.internal_server_error.status, + message: error.message || HTTP_STATUS_CODE.internal_server_error.message, + errorObject: error, + } + } } /** @@ -115,23 +140,41 @@ module.exports = class LibraryCategories extends Abstract { * @returns {Object} Library project category details . */ + /** + * @api {post} /project/v1/library/categories/create + * @apiVersion 1.0.0 + * @apiName create + * @apiGroup LibraryCategories + * @apiHeader {String} x-auth-token Authenticity token + * @apiUse successBody + * @apiUse errorBody + */ async create(req) { - return new Promise(async (resolve, reject) => { - try { - const libraryProjectcategory = await libraryCategoriesHelper.create( - req.body, - req.files, - req.userDetails - ) - - return resolve({ - message: libraryProjectcategory.message, - result: libraryProjectcategory.data, - }) - } catch (error) { - return reject(error) + try { + const result = await libraryCategoriesHelper.create( + req.body, + req.files, + req.userDetails + ) + if (result.success) { + return { + success: true, + message: result.message, + result: result.data, + } + } else { + throw { + message: result.message, + status: result.status || HTTP_STATUS_CODE.bad_request.status, + } + } + } catch (error) { + return { + status: error.status || HTTP_STATUS_CODE.internal_server_error.status, + message: error.message || HTTP_STATUS_CODE.internal_server_error.message, + errorObject: error, } - }) + } } /** @@ -155,27 +198,36 @@ module.exports = class LibraryCategories extends Abstract { * @returns {Array} Library Categories project. */ + /** + * @api {post} /project/v1/library/categories/update/:id + * @apiVersion 1.0.0 + * @apiName update + * @apiGroup LibraryCategories + * @apiHeader {String} x-auth-token Authenticity token + * @apiUse successBody + * @apiUse errorBody + */ async update(req) { - return new Promise(async (resolve, reject) => { - try { - const findQuery = { - _id: req.params._id, + try { + const result = await libraryCategoriesHelper.update(req) + if (result.success) { + return { + success: true, + message: result.message, } - const libraryProjectcategory = await libraryCategoriesHelper.update( - findQuery, - req.body, - req.files, - req.userDetails - ) - - return resolve({ - message: libraryProjectcategory.message, - result: libraryProjectcategory.data, - }) - } catch (error) { - return reject(error) + } else { + throw { + message: result.message, + status: result.status || HTTP_STATUS_CODE.bad_request.status, + } + } + } catch (error) { + return { + status: error.status || HTTP_STATUS_CODE.internal_server_error.status, + message: error.message || HTTP_STATUS_CODE.internal_server_error.message, + errorObject: error, } - }) + } } /** @@ -237,21 +289,183 @@ module.exports = class LibraryCategories extends Abstract { * @returns {Array} Library categories. */ + /** + * @api {get} /project/v1/library/categories/list + * @apiVersion 1.0.0 + * @apiName list + * @apiGroup LibraryCategories + * @apiHeader {String} x-auth-token Authenticity token + * @apiUse successBody + * @apiUse errorBody + */ async list(req) { - return new Promise(async (resolve, reject) => { - try { - let projectCategories = await libraryCategoriesHelper.list(req) + try { + const result = await libraryCategoriesHelper.list(req) + return { + success: true, + message: result.message, + result: result.data, + } + } catch (error) { + return { + status: error.status || HTTP_STATUS_CODE.internal_server_error.status, + message: error.message || HTTP_STATUS_CODE.internal_server_error.message, + errorObject: error, + } + } + } + + /** + * @api {get} /project/v1/library/categories/:id/hierarchy + * @apiVersion 1.0.0 + * @apiName categoryHierarchy + * @apiGroup LibraryCategories + * @apiHeader {String} x-auth-token Authenticity token + * @apiUse successBody + * @apiUse errorBody + */ + async hierarchy(req) { + try { + const result = await libraryCategoriesHelper.getCategoryHierarchy(req) + return { + success: true, + message: result.message, + result: result.data, + } + } catch (error) { + return { + status: error.status || HTTP_STATUS_CODE.internal_server_error.status, + message: error.message || HTTP_STATUS_CODE.internal_server_error.message, + errorObject: error, + } + } + } - projectCategories.result = projectCategories.data + /** + * @api {patch} /project/v1/library/categories/leaves + * @apiVersion 1.0.0 + * @apiName leaves + * @apiGroup LibraryCategories + * @apiHeader {String} x-auth-token Authenticity token + * @apiUse successBody + * @apiUse errorBody + */ + async leaves(req) { + try { + const result = await libraryCategoriesHelper.getLeaves(req) + return { + success: true, + message: result.message, + result: result.data, + } + } catch (error) { + return { + status: error.status || HTTP_STATUS_CODE.internal_server_error.status, + message: error.message || HTTP_STATUS_CODE.internal_server_error.message, + errorObject: error, + } + } + } - 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, - }) + /** + * @api {post} /project/v1/library/categories/bulk + * @apiVersion 1.0.0 + * @apiName bulk + * @apiGroup LibraryCategories + * @apiHeader {String} x-auth-token Authenticity token + * @apiUse successBody + * @apiUse errorBody + */ + async bulk(req) { + try { + const categories = req.body.categories || [] + + if (!Array.isArray(categories) || categories.length === 0) { + throw { + status: HTTP_STATUS_CODE.bad_request.status, + message: 'categories required - provide a non-empty array in request body', + } + } + + const result = await libraryCategoriesHelper.bulkCreate( + categories, + req.userDetails + ) + return { + success: true, + message: result.message, + result: result.data, + } + } catch (error) { + return { + status: error.status || HTTP_STATUS_CODE.internal_server_error.status, + message: error.message || HTTP_STATUS_CODE.internal_server_error.message, + errorObject: error, + } + } + } + + /** + * @api {delete} /project/v1/library/categories/delete/:id + * @apiVersion 1.0.0 + * @apiName delete + * @apiGroup LibraryCategories + * @apiHeader {String} x-auth-token Authenticity token + * @apiUse successBody + * @apiUse errorBody + */ + async delete(req) { + try { + const result = await libraryCategoriesHelper.delete(req) + if (result.success) { + return { + success: true, + message: result.message, + result: result.data, + } + } else { + throw { + message: result.message, + status: result.status || HTTP_STATUS_CODE.bad_request.status, + } + } + } catch (error) { + return { + status: error.status || HTTP_STATUS_CODE.internal_server_error.status, + message: error.message || HTTP_STATUS_CODE.internal_server_error.message, + errorObject: error, + } + } + } + + /** + * @api {get} /project/v1/library/categories/:id + * @apiVersion 1.0.0 + * @apiName details + * @apiGroup LibraryCategories + * @apiHeader {String} x-auth-token Authenticity token + * @apiUse successBody + * @apiUse errorBody + */ + async details(req) { + try { + const categoryId = req.params._id + + const result = await libraryCategoriesHelper.details( + categoryId, + req.userDetails + ) + return { + success: true, + message: result.message, + result: result.data, + } + } catch (error) { + return { + status: error.status || HTTP_STATUS_CODE.internal_server_error.status, + message: error.message || HTTP_STATUS_CODE.internal_server_error.message, + errorObject: error, } - }) + } } } diff --git a/databaseQueries/projectCategories.js b/databaseQueries/projectCategories.js index beaa6b8a..9b8712dc 100644 --- a/databaseQueries/projectCategories.js +++ b/databaseQueries/projectCategories.js @@ -113,4 +113,152 @@ module.exports = class ProjectCategories { } }) } + + /** + * Update single category document. + * @method + * @name updateOne + * @param {Object} filterQuery - filtered Query. + * @param {Object} updateData - update data. + * @returns {Object} - Updated category data. + */ + static updateOne(filterQuery, updateData) { + return new Promise(async (resolve, reject) => { + try { + let updatedCategory = await database.models.projectCategories.updateOne(filterQuery, updateData) + return resolve(updatedCategory) + } catch (error) { + return reject(error) + } + }) + } + + /** + * Find single category document. + * @method + * @name findOne + * @param {Object} filterQuery - filtered Query. + * @param {Object} projection - fields to project. + * @returns {Object} - Category data. + */ + static findOne(filterQuery, projection = {}) { + return new Promise(async (resolve, reject) => { + try { + let category = await database.models.projectCategories.findOne(filterQuery, projection).lean() + return resolve(category) + } catch (error) { + return reject(error) + } + }) + } + + /** + * Get all descendants of a category using path. + * @method + * @name getDescendants + * @param {String} categoryId - Category ID. + * @param {String} tenantId - Tenant ID. + * @returns {Array} - Descendant categories. + */ + static getDescendants(categoryId, tenantId) { + return new Promise(async (resolve, reject) => { + try { + // If the collection does not maintain a `path` field, compute descendants + // by walking children via `parent_id`. This performs breadth-first + // traversal and returns all descendant documents. + const root = await database.models.projectCategories.findOne({ _id: categoryId, tenantId }).lean() + + if (!root) return resolve([]) + + const descendants = [] + let queue = [root._id] + + while (queue.length) { + // Find direct children of all nodes in the current queue + const children = await database.models.projectCategories + .find({ parent_id: { $in: queue }, tenantId, isDeleted: false }) + .lean() + + if (!children || children.length === 0) break + + // Add to results and prepare next level + descendants.push(...children) + queue = children.map((c) => c._id) + } + + return resolve(descendants) + } catch (error) { + return reject(error) + } + }) + } + + /** + * Get leaf categories (categories with no children). + * @method + * @name getLeafCategories + * @param {Object} filterQuery - filtered Query. + * @returns {Array} - Leaf categories. + */ + static getLeafCategories(filterQuery) { + return new Promise(async (resolve, reject) => { + try { + // Treat as leaf when either: + // - hasChildCategories is explicitly false + // - hasChildCategories field is missing + // - children array is missing or empty + const leafFilter = { + $and: [ + filterQuery, + { + $or: [ + { hasChildCategories: false }, + { hasChildCategories: { $exists: false } }, + { children: { $exists: true, $size: 0 } }, + { children: { $exists: false } }, + ], + }, + ], + } + + let leafCategories = await database.models.projectCategories.find(leafFilter).lean() + return resolve(leafCategories) + } catch (error) { + return reject(error) + } + }) + } + + /** + * List project categories with pagination. + * @method + * @name list + * @param {Object} query - Filter query. + * @param {Object} projection - Fields to select. + * @param {Object} sort - Sort options. + * @param {Number} skip - Skip count. + * @param {Number} limit - Limit count. + * @returns {Array} - List of project categories. + */ + static list(query, projection = {}, sort = {}, skip, limit) { + return new Promise(async (resolve, reject) => { + try { + let projectCategoriesData = await database.models.projectCategories + .find(query, projection) + .sort(sort) + .skip(skip) + .limit(limit) + .lean() + + let count = await database.models.projectCategories.countDocuments(query) + + return resolve({ + data: projectCategoriesData, + count: count, + }) + } catch (error) { + return reject(error) + } + }) + } } diff --git a/document/hierarchicalCategories/HIERARCHICAL_CATEGORIES_DOCUMENTATION.md b/document/hierarchicalCategories/HIERARCHICAL_CATEGORIES_DOCUMENTATION.md new file mode 100644 index 00000000..46f478ba --- /dev/null +++ b/document/hierarchicalCategories/HIERARCHICAL_CATEGORIES_DOCUMENTATION.md @@ -0,0 +1,758 @@ +# šŸ“ Hierarchical Project Categories - Complete Documentation + +## šŸŽÆ Overview + +This document provides comprehensive technical documentation for implementing **hierarchical categories** in the BRAC project. This feature transforms the flat category structure into a multi-level hierarchy with parent-child relationships, ensuring backward compatibility while introducing efficient tree traversal capabilities. + +### Key Features + +- āœ… **Multi-level Hierarchy**: Configurable depth (default: 3 levels). +- āœ… **Hierarchical Structure**: Optimized for efficient tree traversal and queries. +- āœ… **Backward Compatibility**: Fully compatible with existing API clients. +- āœ… **Template Sync**: Automatic background synchronization of project templates when categories are updated or moved. +- āœ… **API Aliases**: Supports both concise `/api/categories/*` and traditional `/project/v1/projectCategories/*` routes. +- āœ… **Data Integrity**: cascading deletes, cycle detection, and strict validation. + +--- + +## šŸ”„ Endpoint Mapping + +The system uses library endpoints for all category operations. + +### Library Endpoints (Primary) + +All category operations use the library controller. + +- Base Path: `/project/v1/library/categories/*` + +| Action | Library Route (Primary) | +| ---------------------- | ---------------------------------------------------------------------------------------------------------- | +| **List** | `GET /project/v1/library/categories/list` | +| **Create** | `POST /project/v1/library/categories/create` | +| **Get Single** | `GET /project/v1/library/categories/:id` | +| **Update / Move** | `PATCH /project/v1/library/categories/:id` or `POST /project/v1/library/categories/update/:id` | +| **Delete** | `DELETE /project/v1/library/categories/delete/:id` | +| **Category Hierarchy** | `GET /project/v1/library/categories/hierarchy/:id` | +| **Leaves** | `GET /project/v1/library/categories/leaves` | +| **Bulk Create** | `POST /project/v1/library/categories/bulk` | +| **Projects** | `GET /project/v1/library/categories/projects/:id?ids=id1,id2,id3` (supports single or comma-separated IDs) | + +> **Note**: Legacy `update` uses `POST` method in some clients, while new endpoints use `PATCH`. Both are supported on the legacy route if implemented, but strictly `PATCH` on new routes is recommended. To move a category, include `parent_id` in the update request body. For the Projects endpoint, you can provide categoryIds as: path parameter (single ID), query string (comma-separated), or request body (array). + +--- + +## šŸ” Authentication & Token Requirements + +All category APIs require proper authentication and tenant/organization identification. + +### JWT Token Structure Requirements + +The JWT token must contain the following structure in the payload: + +```json +{ + "data": { + "id": 2003, + "name": "user name", + "session_id": 22706, + "organization_ids": ["33"], + "organization_codes": ["tan90"], + "tenant_code": "shikshalokam", + "organizations": [ + { + "id": 33, + "name": "tan90", + "code": "tan90", + "tenant_code": "shikshalokam", + "roles": [ + { + "id": 23, + "title": "mentee", + "label": "mentee", + "status": "ACTIVE" + } + ] + } + ] + } +} +``` + +### Critical Token Fields + +**Required for Authentication:** + +- `tenant_code`: Tenant identifier (e.g., "shikshalokam") +- `organization_ids`: Array of organization IDs (e.g., ["33"]) +- `organizations[0].roles`: Array of user roles with title field + +**Token Processing Notes:** + +- Tenant ID extracted from: `decodedToken.data.tenant_code` +- Organization ID extracted from: `decodedToken.data.organization_ids[0]` +- User roles extracted from: `decodedToken.data.organizations[0].roles` + +### Authentication Methods + +1. **User Token Authentication** (Recommended for public APIs): + + ```http + Headers: + x-auth-token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... + ``` + + - Token contains user details, tenant, and organization information + - Automatically extracts `tenantCode` and `orgCode` from token payload + - Used for user-facing applications + +2. **Admin Token Authentication** (For admin/internal APIs): + ```http + Headers: + internal-access-token: Fqn0m0HQ0gXydRtBCg5l + tenantId: brac + orgId: brac_gbl + ``` + - Requires explicit tenant and organization headers + - Used for administrative operations + +### Authentication Header Format + +**Important:** Header name must be exactly `x-auth-token` (capital X) + +```bash +# Correct +curl -H "x-auth-token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." + +# Incorrect (will fail) +curl -H "x-auth-token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." +``` + +### Tenant & Organization Extraction + +The system automatically handles tenant and organization context: + +- **From User Token**: Extracts from JWT payload (`tenant_code`, `organization_ids[0]`) +- **From Headers**: Uses `tenantId`/`tenantCode` and `orgId`/`orgCode` headers +- **Fallback**: Uses user details from authenticated session + +### Example Usage + +```bash +# Using User Token (Public API) - Working Example +curl --location 'http://localhost:5003/project/v1/library/categories/list' \ +--header 'x-auth-token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJkYXRhIjp7ImlkIjoyMDAzLCJuYW1lIjoidGFuZnVuY29mZmljaWFsIHNsZGlyZWN0b3IiLCJzZXNzaW9uX2lkIjoyMjcwNiwib3JnYW5pemF0aW9uX2lkcyI6WyIzMyJdLCJvcmdhbml6YXRpb25fY29kZXMiOlsidGFuOTAiXSwidGVuYW50X2NvZGUiOiJzaGlrc2hhbG9rYW0iLCJvcmdhbml6YXRpb25zIjpbeyJpZCI6MzMsIm5hbWUiOiJ0YW45MCIsImNvZGUiOiJ0YW45MCIsImRlc2NyaXB0aW9uIjoiVGFuOTAgc3BlY2lhbGl6ZXMgaW4gcHJvdmlkaW5nIGVkdWNhdGlvbmFsIFNURUFNIiwic3RhdHVzIjoiQUNUSVZFIiwicmVsYXRlZF9vcmdzIjpbMzRdLCJ0ZW5hbnRfY29kZSI6InNoaWtzaGFsb2thbSIsIm1ldGEiOm51bGwsImNyZWF0ZWRfYnkiOjEsInVwZGF0ZWRfYnkiOjE3MDksInJvbGVzIjpbeyJpZCI6MjMsInRpdGxlIjoibWVudGVlIiwibGFiZWwiOiJtZW50ZWUiLCJ1c2VyX3R5cGUiOjAsInN0YXR1cyI6IkFDVElWRSIsIm9yZ2FuaXphdGlvbl9pZCI6MTAsInZpc2liaWxpdHkiOiJQVUJMSUMiLCJ0ZW5hbnRfY29kZSI6InNoaWtzaGFsb2thbSIsInRyYW5zbGF0aW9ucyI6bnVsbH1dfV19LCJpYXQiOjE3NjU4NjUzMDYsImV4cCI6MTc2NTk1MTcwNn0.TRuLHBD5sjkIgowCVnQC_3GgSZJnbJhpXU3rQKhfIdE' + +# Using Admin Token (Admin API) +curl --location 'http://localhost:5003/project/v1/library/categories/list' \ +--header 'internal-access-token: Fqn0m0HQ0gXydRtBCg5l' \ +--header 'tenantId: brac' \ +--header 'orgId: brac_gbl' \ +--header 'Content-Type: application/json' + +# Test all endpoints with working token +# (note) Global full-tree hierarchy endpoint removed; use category-specific hierarchy: +# curl --location 'http://localhost:5003/project/v1/library/categories//hierarchy' --header 'x-auth-token: YOUR_TOKEN' +``` + +### Validation Examples + +**Create with Parent Validation:** + +```bash +# Create child category (validates parent exists) +curl --location 'http://localhost:5003/project/v1/library/categories/create' \ +--header 'x-auth-token: YOUR_TOKEN' \ +--header 'Content-Type: application/json' \ +--data '{ + "name": "Livestock", + "externalId": "livestock", + "parentId": "693ffb88159e0b0eaa4cc328" +}' +``` + +**Move with Validation:** + +```bash +# Move category (validates new parent exists, prevents circular references) +curl --location --request PATCH 'http://localhost:5003/project/v1/library/categories/move/693ffb64159e0b0eaa4cc314' \ +--header 'x-auth-token: YOUR_TOKEN' \ +--header 'Content-Type: application/json' \ +--data '{ + "newParentId": "693ffb88159e0b0eaa4cc328" +}' +``` + +**Delete Category:** + +```bash +# Delete category (validates no projects/children/templates) +curl --location --request DELETE 'http://localhost:5003/project/v1/library/categories/delete/693ffb64159e0b0eaa4cc314' \ +--header 'x-auth-token: YOUR_TOKEN' +``` + +### Quick Test Commands + +```bash +# Test basic list +curl --location 'http://localhost:5003/project/v1/library/categories/list' --header 'x-auth-token: YOUR_TOKEN' + +# (removed) Test complete hierarchy endpoint — use category-specific hierarchy (`:id/hierarchy`) + +# Test category-specific hierarchy +curl --location 'http://localhost:5003/project/v1/library/categories/693ffb64159e0b0eaa4cc314/hierarchy' --header 'x-auth-token: YOUR_TOKEN' + +# Test leaves +curl --location 'http://localhost:5003/project/v1/library/categories/leaves' --header 'x-auth-token: YOUR_TOKEN' + +# Test projects by single category +curl --location 'http://localhost:5003/project/v1/library/categories/projects/693ffb64159e0b0eaa4cc314?page=1&limit=10' --header 'x-auth-token: YOUR_TOKEN' + +# Test projects by multiple categories (comma-separated query string) +curl --location 'http://localhost:5003/project/v1/library/categories/projects?ids=694a31935b9cdcad6475ebd2,64f2b3c4d5e6f7g8h9i0j1k2&page=1&limit=10' --header 'x-auth-token: YOUR_TOKEN' + +# Test projects by multiple categories (POST with array) +curl --location 'http://localhost:5003/project/v1/library/categories/projects' \ +--header 'x-auth-token: YOUR_TOKEN' \ +--header 'Content-Type: application/json' \ +--data '{ + "categoryIds": ["694a31935b9cdcad6475ebd2", "64f2b3c4d5e6f7g8h9i0j1k2"], + "page": 1, + "limit": 10 +}' +``` + +--- + +## šŸš€ API Reference + +### 1. List Categories + +Retrieves categories with optional filtering and pagination. + +**Request:** + +```http +GET /project/v1/library/categories/list?page=1&limit=20&level=0&parentId=64f1... +Headers: + x-auth-token: +``` + +**Response:** + +```json +{ + "message": "Categories fetched successfully", + "result": [ + { + "_id": "64f1...", + "externalId": "agriculture", + "name": "Agriculture", + "level": 0, + "hasChildCategories": true, + "sequenceNumber": 1 + } + ], + "count": 15 +} +``` + +### 2. Get Single Category + +Retrieves details of a specific category. + +**Request:** + +```http +GET /project/v1/library/categories/:id +Headers: + x-auth-token: +``` + +**Response:** + +```json +{ + "message": "Category fetched successfully", + "result": { + "_id": "64f1...", + "externalId": "agriculture", + "name": "Agriculture", + "level": 0, + "parent_id": null, + "hasChildCategories": true, + "sequenceNumber": 1, + "evidences": [...], + "createdAt": "2023-09-01T10:00:00Z" + } +} +``` + +### 3. Get Category-Specific Hierarchy + +Retrieves the hierarchy subtree starting from a specific category. + +**Request:** + +```http +GET /project/v1/library/categories/:id/hierarchy +Headers: + x-auth-token: +``` + +**Response:** + +```json +{ + "message": "Category hierarchy fetched successfully", + "result": { + "tree": { + "_id": "64f1...", + "name": "Agriculture", + "level": 0, + "children": [ + { + "_id": "64f2...", + "name": "Crops", + "level": 1, + "children": [] + } + ] + }, + "totalCategories": 2 + } +} +``` + +**Response:** + +```json +{ + "message": "Category hierarchy fetched successfully", + "result": { + "tree": [ + { + "_id": "64f1...", + "name": "Agriculture", + "level": 0, + "children": [ + { + "_id": "64f2...", + "name": "Crops", + "level": 1, + "children": [] + } + ] + } + ] + } +} +``` + +### 4. Create Category + +**Request:** + +```http +POST /project/v1/library/categories/create +Content-Type: application/json +Headers: + x-auth-token: + tenantId: + orgId: + +{ + "externalId": "cat-irrigation", + "name": "Irrigation", + "parentId": "64f1...", + "sequenceNumber": 1 +} +``` + +_Note: Omit `parentId` to create a root category._ + +### 5. Update Category (including Move) + +Updates category details and/or moves it to a new parent. + +**Request (Update fields only):** + +```http +PATCH /project/v1/library/categories/:id +Content-Type: application/json +Headers: + x-auth-token: + tenantId: + orgId: + +{ + "name": "Updated Name", + "externalId": "new-external-id" +} +``` + +**Request (Move to new parent):** + +```http +PATCH /project/v1/library/categories/:id +Content-Type: application/json +Headers: + x-auth-token: + tenantId: + orgId: + +{ + "parent_id": "64f5..." +} +``` + +**Request (Update + Move combined):** + +```http +PATCH /project/v1/library/categories/:id +Content-Type: application/json +Headers: + x-auth-token: + tenantId: + orgId: + +{ + "name": "Updated Name", + "parent_id": "64f5..." +} +``` + +_Note: When moving a category, circular reference checks are performed. Cannot move a category to itself or into its own descendant._ + +### 6. Delete Category + +Deletes a category after comprehensive validation. + +**Request:** + +```http +DELETE /project/v1/library/categories/delete/:id +Headers: + x-auth-token: +``` + +**Validation Checks (in order):** + +1. **Projects Check**: Ensures category and all children have no associated projects +2. **Children Check**: Ensures category has no child categories +3. **Templates Check**: Ensures no templates reference the category + +**Success Response:** + +```json +{ + "message": "Category deleted successfully", + "result": { + "deletedCategory": { + "_id": "64f1...", + "name": "Agriculture", + "externalId": "agriculture" + } + } +} +``` + +**Error Response (Has Projects):** + +```json +{ + "status": 400, + "message": "Category or its children are used by 5 projects", + "result": { + "categoriesWithProjects": [ + { + "categoryName": "Agriculture", + "projectCount": 3, + "projectTitles": ["Smart Farming", "Crop Management"] + } + ] + } +} +``` + +### 7. Get Leaf Categories + +**Request:** + +```http +GET /project/v1/library/categories/leaves +Headers: + x-auth-token: +``` + +### 8. Bulk Create Categories + +**Request:** + +```http +POST /project/v1/library/categories/bulk +Headers: + x-auth-token: +Content-Type: application/json + +{ + "categories": [ + { + "externalId": "crops", + "name": "Crops", + "parentExternalId": "agriculture" + }, + { + "externalId": "livestock", + "name": "Livestock", + "parentExternalId": "agriculture" + } + ] +} +``` + +### 9. Get Projects by Category (Single or Multiple) + +**Request (Single Category via Path):** + +```http +GET /project/v1/library/categories/projects/:categoryId?page=1&limit=10&search=irrigation +Headers: + x-auth-token: +``` + +**Request (Multiple Categories via Query String):** + +```http +GET /project/v1/library/categories/projects?ids=64f1a2b3c4d5e6f7g8h9i0j1,64f2b3c4d5e6f7g8h9i0j1k2,64f3c4d5e6f7g8h9i0j1k2l3&page=1&limit=20&search=agriculture +Headers: + x-auth-token: +``` + +**Request (Multiple Categories via Request Body - POST):** + +```http +POST /project/v1/library/categories/projects +Headers: + x-auth-token: +Content-Type: application/json + +{ + "categoryIds": [ + "64f1a2b3c4d5e6f7g8h9i0j1", + "64f2b3c4d5e6f7g8h9i0j1k2", + "64f3c4d5e6f7g8h9i0j1k2l3" + ], + "page": 1, + "limit": 20, + "search": "agriculture" +} +``` + +**Response (Single or Multiple):** + +```json +{ + "message": "Successfully fetched projects", + "result": { + "data": [ + { + "_id": "64f2...", + "title": "Smart Irrigation System", + "description": "IoT-based irrigation management", + "averageRating": 4.5, + "noOfRatings": 12, + "categories": [ + { + "_id": "64f1a2b3c4d5e6f7g8h9i0j1", + "name": "Agriculture", + "externalId": "agriculture" + } + ] + }, + { + "_id": "64f3...", + "title": "Livestock Management", + "description": "Digital livestock tracking", + "averageRating": 4.2, + "noOfRatings": 9, + "categories": [ + { + "_id": "64f2b3c4d5e6f7g8h9i0j1k2", + "name": "Livestock", + "externalId": "livestock" + } + ] + } + ], + "count": 45, + "totalProjects": 45, + "categoriesQueried": 3 + } +} +``` + +**Parameters:** + +- `ids` (query string): Comma-separated category IDs for multiple categories (e.g., `?ids=id1,id2,id3`) +- `categoryIds` (request body): Array of category IDs for POST requests +- `:categoryId` (path): Single category ID for GET requests +- `page` (optional): Page number for pagination (default: 1) +- `limit` (optional): Number of projects per page (default: 10, max: 50) +- `search` (optional): Search term to filter projects by title/description + +**Note:** The same endpoint supports all three input formats. Choose the one that best fits your client implementation. + +--- + +## šŸ“Š Database Schema Changes + +### `projectCategories` Model + +**Location:** `models/project-categories.js` + +| Field | Type | Description | +| ---------------------- | --------- | ---------------------------------------------------------------------------------- | +| `parent_id` | ObjectId | Reference to parent category (null for root) | +| `hasChildCategories` | Boolean | Optimization flag for leaf detection | +| (removed) `childCount` | (removed) | Field removed; use `hasChildCategories` and `children` array to determine children | + +--- + +## āš™ļø Configuration + +**Location:** `config/hierarchy.config.js` + +```javascript +module.exports = { + maxHierarchyDepth: 3, // Maximum allowed depth (0-3) + validation: { + maxNameLength: 100, + }, +} +``` + +--- + +## šŸ”„ Migration Strategy + +To migrate existing flat categories to the hierarchical structure: + +**Script:** `migrations/addHierarchyFields/addHierarchyFields.js` + +**Commands:** + +```bash +# 1. Dry Run (Recommended) +node migrations/addHierarchyFields/addHierarchyFields.js --dry-run + +# 2. Production Run +node migrations/addHierarchyFields/addHierarchyFields.js +``` + +**Effect**: All existing categories become root categories (level 0). You can then manually move them into a hierarchy using the `move` endpoint or `bulk` operations. + +--- + +## šŸ“ Module Structure + +``` +module/ +└── library/ + └── categories/ + ā”œā”€ā”€ helper.js # Core logic (Move, Create, Delete, Hierarchy) + └── validator/ + └── v1.js +controllers/ +└── v1/ + └── library/ + └── categories.js # Library controller +models/ +└── project-categories.js # Mongoose schema +databaseQueries/ +└── projectCategories.js # Database abstraction +``` + +## šŸ”§ Troubleshooting Common Issues + +### Authentication Errors + +**Error:** `"Required field token is missing"` + +- **Cause:** Header name incorrect or token not sent +- **Solution:** Use `x-auth-token` (capital X) header name +- **Example:** `curl -H "x-auth-token: your_token"` + +**Error:** `"TenantId and OrgnizationId required in the token"` + +- **Cause:** Token missing required fields or wrong field names +- **Solution:** Ensure token contains `tenant_code` and `organization_ids` fields +- **Check:** Decode your JWT token to verify structure + +**Error:** `"Cannot read properties of undefined (reading 'map')"` + +- **Cause:** Token structure doesn't match expected format +- **Solution:** Ensure roles are in `organizations[0].roles` array +- **Fix Applied:** Middleware now correctly extracts roles from nested structure + +### Token Structure Validation + +Use this command to decode and verify your token structure: + +```bash +node -e " +const token = 'your_jwt_token_here'; +const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString()); +console.log('Token Structure:', JSON.stringify(payload, null, 2)); +" +``` + +### Middleware Updates Applied + +The following fixes have been implemented in `generics/middleware/authenticator.js`: + +1. **Tenant Field Mapping:** + + - Changed from `decodedToken.data.tenant_id` to `decodedToken.data.tenant_code` + +2. **Organization Field Mapping:** + + - Changed from `decodedToken.data.organization_id` to `decodedToken.data.organization_ids[0]` + +3. **Roles Extraction:** + + - Changed from `decodedToken.data.roles` to `decodedToken.data.organizations[0].roles` + +4. **Header Case Sensitivity:** + - Added support for both `x-auth-token` and `x-auth-token` + +## šŸ“‹ Operations & Validation Matrix + +### Category Operations Table + +| Operation | Endpoint | What Gets Updated | Validation Checks | +| ------------------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------- | +| **Create Category** | `POST /categories` | • Auto-sets level
• Updates parent's hasChildCategories | • Parent exists
• Max depth not exceeded
• Unique externalId
• Valid tenant/org | +| **Update Category** | `PATCH /categories/{id}` | • Updates specified fields
• If parent_id included: moves to new parent, recalculates levels, updates old/new parent hasChildCategories | • Category exists
• Valid field values
• If moving: new parent exists, not to own descendant | +| **Delete Category** | `DELETE /categories/{id}` | • Sets isDeleted: true
• Updates parent's hasChildCategories if last child | • No children exist
• No projects use category/children
• No templates reference this category | + +### Data Integrity Rules Table + +| Rule | Enforced By | Description | Example | +| -------------------------- | --------------------- | --------------------------------------------------------------- | -------------------------------------------------------------------------------- | +| **Unique ExternalId** | Database + Validation | externalId must be unique within tenant | Cannot create two categories with externalId="education" | +| **Valid Parent** | Validation | parent_id must exist and not be deleted | Cannot set parent_id to non-existent category | +| **Max Depth** | Validation | Cannot exceed configured max hierarchy depth (default: 3) | Cannot create level 4 category if max depth is 3 | +| **No Circular References** | Validation | Cannot move category to its own descendant | Cannot move "Agriculture" under "Livestock" if Livestock is child of Agriculture | +| **Delete Protection** | Validation | Cannot delete if has children, projects, or template references | Cannot delete "Livelihood" if it has "Agriculture" child or active projects | +| **Tenant Isolation** | Query Filters | All operations filtered by tenantId from JWT | User from tenant "brac" cannot see categories from tenant "shikshagraha" | +| **Soft Delete** | Application Logic | Deleted categories have isDeleted=true | Deleted categories remain in database but excluded from queries | + +## āš ļø Critical Implementation Notes + +1. **Circular References**: The `move` logic prevents moving a category into its own descendant. +2. **Orphans**: `getCategoryHierarchy` gracefully handles orphan nodes (nodes whose parent is missing) by treating them as roots for display when returning a subtree. +3. **Data Integrity**: `delete` performs validation checks and prevents deletion if projects, children, or templates reference the category. +4. **Library Controller**: All library endpoints (`/project/v1/library/categories/*`) are handled by `controllers/v1/library/categories.js`, which uses the `library/categories/helper.js` for core logic. +5. **Token Compatibility**: Middleware has been updated to handle the new token structure with nested organization roles. +6. **Multi-Category Projects**: The `POST /project/v1/library/categories/projects/list` endpoint allows fetching projects from multiple categories with standard pagination. Use this endpoint for multi-category project queries. +7. **Parent Validation**: All create and move operations validate parent existence before proceeding with hierarchy calculations. diff --git a/generics/kafka/producers.js b/generics/kafka/producers.js index 63e806f1..d7ee7734 100644 --- a/generics/kafka/producers.js +++ b/generics/kafka/producers.js @@ -183,10 +183,40 @@ const pushUserCoursesToKafka = function (message) { }) } +/** + * Push category change event to Kafka. + * @function + * @name pushCategoryChangeEvent + * @param {Object} message - The message payload to be pushed to Kafka. + * @returns {Promise} Kafka push status response. + */ +const pushCategoryChangeEvent = function (message) { + return new Promise(async (resolve, reject) => { + try { + const categoryChangeTopic = + process.env.KAFKA_CATEGORY_TOPIC && process.env.ENABLE_CATEGORY_KAFKA_EVENTS != 'OFF' + ? process.env.KAFKA_CATEGORY_TOPIC + : 'category_change_topic' + + let kafkaPushStatus = await pushMessageToKafka([ + { + topic: categoryChangeTopic, + messages: JSON.stringify(message), + }, + ]) + + return resolve(kafkaPushStatus) + } catch (error) { + return reject(error) + } + }) +} + module.exports = { pushProjectToKafka: pushProjectToKafka, pushUserActivitiesToKafka: pushUserActivitiesToKafka, pushProgramOperationEvent: pushProgramOperationEvent, pushResourceDeleteKafkaEvent: pushResourceDeleteKafkaEvent, pushUserCoursesToKafka: pushUserCoursesToKafka, + pushCategoryChangeEvent: pushCategoryChangeEvent, } diff --git a/generics/middleware/addTenantAndOrgInRequest.js b/generics/middleware/addTenantAndOrgInRequest.js index c0647cde..25d0083f 100644 --- a/generics/middleware/addTenantAndOrgInRequest.js +++ b/generics/middleware/addTenantAndOrgInRequest.js @@ -17,7 +17,11 @@ module.exports = async function (req, res, next) { 'userProjects/details', 'users/solutions', ] - let normalUserInternalAccessPath = ['/programs/publishToLibrary', '/programs/ProgramUpdateForLibrary'] + let normalUserInternalAccessPath = [ + '/programs/publishToLibrary', + '/programs/ProgramUpdateForLibrary', + 'library/categories', + ] let addTenantAndOrgDetails = false diff --git a/migrations/addCategoryHierarchyFields/README.md b/migrations/addCategoryHierarchyFields/README.md new file mode 100644 index 00000000..0cb6daf7 --- /dev/null +++ b/migrations/addCategoryHierarchyFields/README.md @@ -0,0 +1,41 @@ +# Hierarchy Migration Script + +This migration script converts existing flat category structure to hierarchical structure. + +## Usage + +### Dry Run (Recommended First) + +```bash +node migrations/addHierarchyFields/addHierarchyFields.js --dry-run +``` + +### Production Run + +```bash +node migrations/addHierarchyFields/addHierarchyFields.js +``` + +### Tenant-Specific Migration + +```bash +node migrations/addHierarchyFields/addHierarchyFields.js --tenant=shikshalokam +``` + +## What It Does + +1. Finds all existing categories +2. Sets them as root categories (level 0, parent_id: null) +3. Initializes hierarchy fields: + - `parent_id`: null + - `hasChildCategories`: false + - (removed) `childCount`: no longer used; use `hasChildCategories` for leaf checks + - `sequenceNumber`: sequential number + +## Important Notes + +- **Backup your database** before running in production +- Always run with `--dry-run` first to verify +- Migration is idempotent (safe to run multiple times) +- Existing categories will become root-level categories +- You can manually organize them into hierarchies after migration diff --git a/migrations/addCategoryHierarchyFields/addHierarchyFields.js b/migrations/addCategoryHierarchyFields/addHierarchyFields.js new file mode 100644 index 00000000..683a178f --- /dev/null +++ b/migrations/addCategoryHierarchyFields/addHierarchyFields.js @@ -0,0 +1,143 @@ +/** + * Migration: Add Hierarchy Fields to Existing Categories + * Description: Converts flat category structure to hierarchical + * Author: Implementation Team + * Usage: node migrations/addHierarchyFields/addHierarchyFields.js [--tenant=tenantId] [--dry-run] + */ + +require('module-alias/register') +require('dotenv').config() + +// Setup globals +require('@root/config/globals')() +require('@root/config/connections') + +const projectCategoriesQueries = require(DB_QUERY_BASE_PATH + '/projectCategories') + +/** + * Migrate existing categories to hierarchical structure + * @param {String} tenantId - Optional tenant ID to filter + * @param {Boolean} dryRun - If true, only simulate without making changes + */ +async function migrateToHierarchy(tenantId = null, dryRun = false) { + try { + console.log('='.repeat(60)) + console.log('Starting Hierarchy Migration') + console.log(`Mode: ${dryRun ? 'DRY RUN (no changes will be made)' : 'PRODUCTION'}`) + if (tenantId) { + console.log(`Tenant Filter: ${tenantId}`) + } + console.log('='.repeat(60)) + + // Filter to get only categories that don't have hierarchy fields yet (safe for multiple runs) + const filter = tenantId ? { tenantId, isDeleted: false } : { isDeleted: false } + + // Check if parent_id field exists to determine if already migrated + const categories = await projectCategoriesQueries.categoryDocuments(filter, [ + '_id', + 'tenantId', + 'orgId', + 'externalId', + 'name', + 'parent_id', // Include to check if already migrated + ]) + + // Filter out categories that already have parent_id fields (safe for multiple runs) + const categoriesToMigrate = categories.filter( + (category) => category.parent_id === undefined || category.parent_id === null + ) + + console.log(`Found ${categories.length} total categories`) + console.log(`Found ${categoriesToMigrate.length} categories to migrate (without hierarchy fields)`) + + if (categories.length > 0 && categoriesToMigrate.length === 0) { + console.log('āœ… All categories already have hierarchy fields. Migration not needed.') + return { success: true, count: 0, alreadyMigrated: true } + } + + if (categoriesToMigrate.length === 0) { + console.log('No categories found. Migration complete.') + return { success: true, count: 0 } + } + + let migratedCount = 0 + let errorCount = 0 + + for (const category of categoriesToMigrate) { + try { + const updateData = { + parent_id: null, // All existing = roots + hasChildCategories: false, // Will update after child creation + sequenceNumber: migratedCount, + children: [], + metaInformation: { icon: '' }, + } + + if (!dryRun) { + await projectCategoriesQueries.updateOne({ _id: category._id }, { $set: updateData }) + } + + migratedCount++ + if (migratedCount % 100 === 0) { + console.log(`Progress: ${migratedCount}/${categoriesToMigrate.length} categories processed`) + } + } catch (error) { + errorCount++ + console.error(`Error migrating category ${category._id} (${category.externalId}):`, error.message) + } + } + + console.log('='.repeat(60)) + console.log('Migration Summary:') + console.log(`Total Categories: ${categories.length}`) + console.log(`Categories Needing Migration: ${categoriesToMigrate.length}`) + console.log(`Successfully Migrated: ${migratedCount}`) + console.log(`Errors: ${errorCount}`) + if (dryRun) { + console.log('\nāš ļø DRY RUN MODE - No changes were made to the database') + } else { + console.log('\nāœ… Migration completed successfully!') + } + console.log('='.repeat(60)) + + return { + success: true, + count: migratedCount, + errors: errorCount, + } + } catch (error) { + console.error('āŒ Migration failed:', error) + throw error + } +} + +// Main execution +async function main() { + try { + // Parse command line arguments + const args = process.argv.slice(2) + let tenantId = null + let dryRun = false + + args.forEach((arg) => { + if (arg.startsWith('--tenant=')) { + tenantId = arg.split('=')[1] + } else if (arg === '--dry-run') { + dryRun = true + } + }) + + // Run migration + const result = await migrateToHierarchy(tenantId, dryRun) + + process.exit(result.success ? 0 : 1) + } catch (error) { + console.error('Migration script error:', error) + process.exit(1) + } +} + +// Direct execution (consistent with other migration files) +main() + +module.exports = { migrateToHierarchy } diff --git a/models/project-categories.js b/models/project-categories.js index 290a2917..d64fbd34 100644 --- a/models/project-categories.js +++ b/models/project-categories.js @@ -2,7 +2,7 @@ * name : project-categories.js. * author : Aman Karki. * created-date : 14-July-2020. - * Description : Schema for project categories. + * Description : Schema for project categories with hierarchical support. */ module.exports = { @@ -15,6 +15,7 @@ module.exports = { name: { type: String, required: true, + maxlength: 100, }, createdBy: { type: String, @@ -24,9 +25,31 @@ module.exports = { type: String, default: 'SYSTEM', }, + parent_id: { + type: 'ObjectId', + ref: 'projectCategories', + default: null, + index: true, // CRITICAL for hierarchy queries + }, + hasChildCategories: { + type: Boolean, + default: false, + index: true, // Quick leaf identification + }, + children: { + type: Array, + default: [], + }, + sequenceNumber: { + type: Number, + default: 0, + index: true, + }, + // ========================================== isDeleted: { type: Boolean, default: false, + index: true, }, isVisible: { type: Boolean, @@ -34,12 +57,18 @@ module.exports = { }, status: { type: String, + enum: ['active', 'inactive', 'archived'], default: 'active', + index: true, }, icon: { type: String, default: '', }, + description: { + type: String, + default: '', + }, noOfProjects: { type: Number, default: 0, @@ -66,11 +95,19 @@ module.exports = { default: [], index: true, }, + metaInformation: { + type: Object, + default: {}, + }, }, compoundIndex: [ { name: { externalId: 1, tenantId: 1 }, indexType: { unique: true }, }, + { + name: { parent_id: 1, tenantId: 1, orgId: 1, sequenceNumber: 1 }, + indexType: {}, // For fetching sorted children + }, ], } diff --git a/models/project-templates.js b/models/project-templates.js index 2a003277..fc86cf44 100644 --- a/models/project-templates.js +++ b/models/project-templates.js @@ -18,14 +18,28 @@ module.exports = { }, categories: [ { - _id: 'ObjectId', + _id: { + type: 'ObjectId', + ref: 'projectCategories', + required: true, + index: true, + }, externalId: { type: String, index: true, }, name: String, + syncAt: { + type: Date, + default: Date.now, + index: true, + }, }, ], + // Per-category sync timestamp. Tracks when this template's category entry + // was last synchronized with category changes (e.g., name/update/remove). + // Useful when a template has multiple categories and consumers update + // category-specific sync timestamps independently. description: { type: String, default: '', diff --git a/module/library/categories/helper.js b/module/library/categories/helper.js index 2bfab450..d6e122da 100644 --- a/module/library/categories/helper.js +++ b/module/library/categories/helper.js @@ -10,26 +10,27 @@ // const sessionHelpers = require(GENERIC_HELPERS_PATH+"/sessions"); const projectCategoriesQueries = require(DB_QUERY_BASE_PATH + '/projectCategories') const projectTemplateQueries = require(DB_QUERY_BASE_PATH + '/projectTemplates') -const projectTemplateTaskQueries = require(DB_QUERY_BASE_PATH + '/projectTemplateTask') -const moment = require('moment-timezone') +const orgExtensionQueries = require(DB_QUERY_BASE_PATH + '/organizationExtension') const filesHelpers = require(MODULES_BASE_PATH + '/cloud-services/files/helper') const axios = require('axios') +const { ObjectId } = require('mongodb') +const moment = require('moment-timezone') +const _ = require('lodash') const entitiesService = require(GENERICS_FILES_PATH + '/services/entity-management') +const projectTemplateTaskQueries = require(DB_QUERY_BASE_PATH + '/projectTemplateTask') const projectAttributesQueries = require(DB_QUERY_BASE_PATH + '/projectAttributes') -const solutionAndProjectTemplateUtils = require(GENERICS_FILES_PATH + '/helpers/solutionAndProjectTemplateUtils') -const orgExtensionQueries = require(DB_QUERY_BASE_PATH + '/organizationExtension') +const kafkaProducersHelper = require(GENERICS_FILES_PATH + '/kafka/producers') /** * LibraryCategoriesHelper * @class */ - module.exports = class LibraryCategoriesHelper { /** * List of library projects. * @method * @name projects - * @param categoryId - category external id. + * @param categoryIds - category external id. * @param pageSize - Size of page. * @param pageNo - Recent page no. * @param search - search text. @@ -44,7 +45,7 @@ module.exports = class LibraryCategoriesHelper { */ static projects( - categoryId, + categoryIds, pageSize, pageNo, search, @@ -156,8 +157,14 @@ module.exports = class LibraryCategoriesHelper { */ matchQuery = applyVisibilityConditions(matchQuery, orgExtension, userDetails) - if (categoryId && categoryId !== '') { - matchQuery['$match']['categories.externalId'] = categoryId + if (categoryIds) { + // Ensure categoryIds is an array (handles both single string or array) + const idsToFilter = Array.isArray(categoryIds) ? categoryIds : [categoryIds] + + if (idsToFilter.length > 0) { + // Use $in to support multiple IDs + matchQuery['$match']['categories.externalId'] = { $in: idsToFilter } + } } let aggregateData = [] @@ -504,60 +511,170 @@ module.exports = class LibraryCategoriesHelper { } /** - * Update categories + * Update category * @method * @name update - * @param filterQuery - Filter query. - * @param updateData - Update data. - * @param files - files - * @param userDetails - user related information - * @returns {Object} updated data + * @param {Object} filterQuery - Filter query + * @param {Object} updateData - Update data + * @param {Object} files - Files + * @param {Object} req - Express request object (controller passes the full `req`) + * @returns {Object} Updated category */ - - static update(filterQuery, updateData, files, userDetails) { + static update(req) { return new Promise(async (resolve, reject) => { try { - let matchQuery = { _id: filterQuery._id } - matchQuery['tenantId'] = userDetails.tenantAndOrgInfo.tenantId + // Extract inputs from req + const filterQuery = { _id: req.params._id } + const updateData = req.body || {} + const files = req.files || {} + const userDetails = req.userDetails + + // Find category to update + let matchQuery = { tenantId: userDetails.tenantAndOrgInfo.tenantId, isDeleted: false } + if (ObjectId.isValid(filterQuery._id)) { + matchQuery['$or'] = [{ _id: new ObjectId(filterQuery._id) }, { externalId: filterQuery._id }] + } else { + matchQuery['externalId'] = filterQuery._id + } + // Remove _id from filterQuery as we constructed matchQuery + delete filterQuery._id let categoryData = await projectCategoriesQueries.categoryDocuments(matchQuery, 'all') - // Throw error if category is not found - if ( - !categoryData || - !(categoryData.length > 0) || - !(Object.keys(categoryData[0]).length > 0) || - categoryData[0]._id == '' - ) { + + if (!categoryData || !categoryData.length > 0 || !categoryData[0]._id) { throw { status: HTTP_STATUS_CODE.bad_request.status, message: CONSTANTS.apiResponses.CATEGORY_NOT_FOUND, } } - let evidenceUploadData = await handleEvidenceUpload(files, userDetails.userInformation.userId) - evidenceUploadData = evidenceUploadData.data + // Extract parent_id if provided (move operation) + const newParentId = updateData.parent_id || null + const hasParentChange = updateData.parent_id !== undefined + + // Handle evidence upload if files provided + if (files && files.cover_image) { + let evidenceUploadData = await handleEvidenceUpload(files, userDetails.userInformation.userId) + evidenceUploadData = evidenceUploadData.data - // Update the sequence numbers - updateData['evidences'] = [] + updateData['evidences'] = [] - if (categoryData[0].evidences && categoryData[0].evidences.length > 0) { - for (const evidence of evidenceUploadData) { - evidence.sequence += categoryData[0].evidences.length - categoryData[0].evidences.push(evidence) + if (categoryData[0].evidences && categoryData[0].evidences.length > 0) { + for (const evidence of evidenceUploadData) { + evidence.sequence += categoryData[0].evidences.length + categoryData[0].evidences.push(evidence) + } + updateData['evidences'] = categoryData[0].evidences + } else { + updateData['evidences'] = evidenceUploadData } - updateData['evidences'] = categoryData[0].evidences - } else { - updateData['evidences'] = evidenceUploadData } - // delete tenantId & orgId attached in req.body to avoid adding manupulative data + // Check for duplicate name if name is being updated + if (updateData.name) { + const parentId = categoryData[0].parent_id + const duplicateCheck = await projectCategoriesQueries.findOne( + { + name: updateData.name, + tenantId: userDetails.tenantAndOrgInfo.tenantId, + parent_id: parentId, + isDeleted: false, + _id: { $ne: categoryData[0]._id }, // Exclude current doc + }, + { _id: 1 } + ) + + if (duplicateCheck) { + throw { + status: HTTP_STATUS_CODE.bad_request.status, + message: + CONSTANTS.apiResponses.CATEGORY_NAME_EXISTS || + 'Category with this name already exists in this level', + } + } + } + + // Handle parent_id change (move operation) if provided + if (hasParentChange) { + // Prevent circular reference + if (newParentId) { + const categoryId = categoryData[0]._id + if (newParentId.toString() === categoryId.toString()) { + throw { + status: HTTP_STATUS_CODE.bad_request.status, + message: 'Cannot move category to itself', + } + } + const descendants = await projectCategoriesQueries.getDescendants( + categoryId, + userDetails.tenantAndOrgInfo.tenantId + ) + const descendantIds = descendants.map((d) => d._id.toString()) + if (descendantIds.includes(newParentId.toString())) { + throw { + status: HTTP_STATUS_CODE.bad_request.status, + message: 'Cannot move category to its own descendant', + } + } + } + + // Get old parent + const oldParentId = categoryData[0].parent_id + const categoryId = categoryData[0]._id + + // Get all descendants (for syncing templates later) + const descendants = await projectCategoriesQueries.getDescendants( + categoryId, + userDetails.tenantAndOrgInfo.tenantId + ) + + // Update old parent: decrement count and remove from children array + if (oldParentId) { + await this.updateParentHasChildCategories( + oldParentId, + userDetails.tenantAndOrgInfo.tenantId, + -1 + ) + await projectCategoriesQueries.updateOne( + { _id: oldParentId }, + { $pull: { children: categoryId } } + ) + this.syncTemplatesForCategory(oldParentId, userDetails.tenantAndOrgInfo.tenantId).catch( + console.error + ) + } + + // Update new parent: increment count and add to children array + if (newParentId) { + await this.updateParentHasChildCategories(newParentId, userDetails.tenantAndOrgInfo.tenantId, 1) + await projectCategoriesQueries.updateOne( + { _id: newParentId }, + { $addToSet: { children: categoryId } } + ) + this.syncTemplatesForCategory(newParentId, userDetails.tenantAndOrgInfo.tenantId).catch( + console.error + ) + } + + // Sync moved category and all descendants + this.syncTemplatesForCategory(categoryId, userDetails.tenantAndOrgInfo.tenantId).catch( + console.error + ) + descendants.forEach((descendant) => { + this.syncTemplatesForCategory(descendant._id, userDetails.tenantAndOrgInfo.tenantId).catch( + console.error + ) + }) + } + + // Remove tenantId, orgId, hasChildCategories from updateData delete updateData.tenantId delete updateData.orgId + delete updateData.hasChildCategories + delete updateData.parent_id - filterQuery['tenantId'] = userDetails.tenantAndOrgInfo.tenantId - - // Update the category - let categoriesUpdated = await projectCategoriesQueries.updateMany(filterQuery, updateData) + // Update category - use the constructed matchQuery so only the targeted category is updated + let categoriesUpdated = await projectCategoriesQueries.updateMany(matchQuery, { $set: updateData }) if (!categoriesUpdated) { throw { @@ -566,13 +683,27 @@ module.exports = class LibraryCategoriesHelper { } } + // Sync templates if name or externalId changed + if (updateData.name || updateData.externalId) { + // Trigger template sync (can be async) + this.syncTemplatesForCategory(categoryData[0]._id, userDetails.tenantAndOrgInfo.tenantId).catch( + console.error + ) + } + return resolve({ success: true, message: CONSTANTS.apiResponses.PROJECT_CATEGORIES_UPDATED, + data: { + categoryId: categoryData[0]._id, + movedCategory: hasParentChange ? categoryData[0]._id : null, + newParentId: hasParentChange ? newParentId : null, + }, }) } catch (error) { - return resolve({ + return reject({ success: false, + status: error.status || HTTP_STATUS_CODE.internal_server_error.status, message: error.message, data: {}, }) @@ -637,7 +768,7 @@ module.exports = class LibraryCategoriesHelper { if (currentTask.parentId && currentTask.parentId !== '') { if (!taskData[currentTask.parentId.toString()]) { - taskData[currentTask.parentId.toString()].children = [] + taskData[currentTask.parentId.toString()] = { children: [] } // Initialize if not present } taskData[currentTask.parentId.toString()].children.push( @@ -670,29 +801,27 @@ module.exports = class LibraryCategoriesHelper { } /** - * create categories + * Create category * @method * @name create - * @param categoryData - categoryData. - * @param files - files. - * @param userDetails - user decoded token details. - * @returns {Object} category details + * @param {Object} categoryData - Category data + * @param {Object} files - Files + * @param {Object} userDetails - User details + * @returns {Object} Created category */ - - static create(categoryData, files, userDetails) { + static async create(categoryData, files, userDetails) { return new Promise(async (resolve, reject) => { try { + // Extract tenant & org details const tenantId = userDetails.tenantAndOrgInfo.tenantId const orgId = userDetails.tenantAndOrgInfo.orgId - // Check if organization extension exists for the loggedin user - let orgExtension - orgExtension = await orgExtensionQueries.orgExtenDocuments({ + // Validate org extension + const orgExtension = await orgExtensionQueries.orgExtenDocuments({ tenantId, orgId: orgId[0], }) - //Throw error if org policy is not found if (!orgExtension || orgExtension.length === 0) { throw { success: false, @@ -701,22 +830,38 @@ module.exports = class LibraryCategoriesHelper { } } - // Check if the category already exists - let filterQuery = {} - filterQuery['externalId'] = categoryData.externalId.toString() - filterQuery['tenantId'] = tenantId + const parentId = categoryData.parentId || categoryData.parent_id || null + + // Duplicate category check — always enforce duplicate name prevention + const nameFilter = { + name: categoryData.name, + tenantId: tenantId, + isDeleted: false, + parent_id: parentId ? new ObjectId(parentId) : null, + } + const duplicateName = await projectCategoriesQueries.findOne(nameFilter, { _id: 1 }) + if (duplicateName) { + throw { + success: false, + status: HTTP_STATUS_CODE.bad_request.status, + message: + CONSTANTS.apiResponses.CATEGORY_ALREADY_EXISTS || + 'Category with this name already exists in this level', + } + } + + // Legacy check for externalId (global uniqueness) + const filterQuery = { + externalId: categoryData.externalId?.toString(), + tenantId: tenantId, + } - const checkIfCategoryExist = await projectCategoriesQueries.categoryDocuments(filterQuery, [ + const existingCategory = await projectCategoriesQueries.categoryDocuments(filterQuery, [ '_id', 'externalId', ]) - // Throw error if the category already exists - if ( - checkIfCategoryExist.length > 0 && - Object.keys(checkIfCategoryExist[0]).length > 0 && - checkIfCategoryExist[0]._id != '' - ) { + if (existingCategory.length > 0) { throw { success: false, status: HTTP_STATUS_CODE.bad_request.status, @@ -724,28 +869,51 @@ module.exports = class LibraryCategoriesHelper { } } - // Fetch the signed urls from handleEvidenceUpload function - const evidences = await handleEvidenceUpload(files, userDetails.userInformation.userId) - categoryData['evidences'] = evidences.data + // Validate parent + const parent = await this.validateParent(parentId, tenantId) - // add tenantId and orgId - categoryData['tenantId'] = tenantId - categoryData['orgId'] = orgId[0] - categoryData['visibleToOrganizations'] = orgId + // Upload evidences + const evidences = await handleEvidenceUpload(files, userDetails.userInformation.userId) + categoryData.evidences = evidences.data + + // Add required fields before creation + categoryData.tenantId = tenantId + categoryData.orgId = orgId[0] + categoryData.hasChildCategories = false + categoryData.sequenceNumber = categoryData.sequenceNumber || 0 + categoryData.description = categoryData.description || '' // Ensure description is set with empty default + + // Create category + let createdCategory = await projectCategoriesQueries.create(categoryData) + + // Update parent counters and add to children array + if (parentId) { + await this.updateParentHasChildCategories(parentId, tenantId, 1) + // add to parent's children array + await projectCategoriesQueries.updateOne( + { _id: parentId }, + { $addToSet: { children: createdCategory._id } } + ) + this.syncTemplatesForCategory(parentId, tenantId).catch(console.error) + } - let projectCategoriesData = await projectCategoriesQueries.create(categoryData) + createdCategory = await projectCategoriesQueries.findOne({ _id: createdCategory._id }) - if (!projectCategoriesData._id) { - throw { - status: HTTP_STATUS_CODE.bad_request.status, - message: CONSTANTS.apiResponses.PROJECT_CATEGORIES_NOT_ADDED, + // Normalize icon: prefer root `icon`, fallback to legacy `metaInformation.icon` + if (createdCategory) { + if ( + (createdCategory.icon === undefined || createdCategory.icon === '') && + createdCategory.metaInformation && + createdCategory.metaInformation.icon !== undefined + ) { + createdCategory.icon = createdCategory.metaInformation.icon } } return resolve({ success: true, message: CONSTANTS.apiResponses.PROJECT_CATEGORIES_ADDED, - data: projectCategoriesData._id, + data: createdCategory, }) } catch (error) { return reject({ @@ -759,56 +927,897 @@ module.exports = class LibraryCategoriesHelper { } /** - * list categories + * List categories with hierarchy support * @method * @name list - * @param {Object} req - user decoded token details - * @returns {Object} category details + * @param {Object} req - Request object + * @returns {Object} Categories list */ - static list(req) { return new Promise(async (resolve, reject) => { try { let tenantId = req.userDetails.userInformation.tenantId let organizationId = req.userDetails.userInformation.organizationId let query = { - visibleToOrganizations: { $in: [organizationId] }, + // visibleToOrganizations: { $in: [organizationId] } // We have handle this in below condition, + tenantId: tenantId, + status: CONSTANTS.common.ACTIVE_STATUS, + isDeleted: false, } - // create query to fetch assets - query['tenantId'] = tenantId + // Filter by parentId if provided (supports both parentId and parent_id) + const parentIdParam = req.query.parentId || req.query.parent_id + if (parentIdParam) { + query.parent_id = parentIdParam + } else if (req.query.rootOnly === 'true' || req.query.rootOnly === true) { + // Root categories only + query.parent_id = null + } + + // Filter by noOfProjects if provided + if (req.query.noOfProjects !== undefined) { + const noOfProjects = parseInt(req.query.noOfProjects) + if (!isNaN(noOfProjects)) { + query.noOfProjects = noOfProjects + } + } // handle currentOrgOnly filter - if (req.query['currentOrgOnly']) { - let currentOrgOnly = UTILS.convertStringToBoolean(req.query['currentOrgOnly']) + if (req.query.currentOrgOnly) { + let currentOrgOnly = UTILS.convertStringToBoolean(req.query.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, [ + // Pagination logic + const pageSize = req.pageSize + const skip = pageSize * (req.pageNo - 1) + const sort = { sequenceNumber: 1, name: 1 } + + // Use new paginated list query + let projectCategories = await projectCategoriesQueries.list( + query, + { + externalId: 1, + name: 1, + description: 1, + icon: 1, + 'metaInformation.icon': 1, + updatedAt: 1, + noOfProjects: 1, + parent_id: 1, + hasChildCategories: 1, + sequenceNumber: 1, + }, + sort, + skip, + pageSize + ) + + if (projectCategories.data.length === 0) { + return resolve({ + success: true, + message: CONSTANTS.apiResponses.PROJECT_CATEGORIES_FETCHED || 'Categories fetched successfully', + data: [], + count: 0, + }) + } + + // Normalize icon from metaInformation and ensure sequenceNumber exists for compatibility + const normalizedData = projectCategories.data.map((cat) => { + const copy = { ...cat } + // Prefer root icon; if missing, fallback to legacy metaInformation.icon + if ( + (copy.icon === undefined || copy.icon === '') && + copy.metaInformation && + copy.metaInformation.icon !== undefined + ) { + copy.icon = copy.metaInformation.icon + } + copy.sequenceNumber = copy.sequenceNumber || 0 + return copy + }) + + return resolve({ + success: true, + message: CONSTANTS.apiResponses.PROJECT_CATEGORIES_FETCHED || 'Categories fetched successfully', + data: normalizedData, + count: projectCategories.count, + }) + } catch (error) { + return reject({ + success: false, + status: error.status || HTTP_STATUS_CODE.internal_server_error.status, + message: error.message, + data: {}, + }) + } + }) + } + + /** + * Update parent's hasChildCategories + * @method + * @name updateParentHasChildCategories + * @param {ObjectId} parentId - Parent category ID + * @param {String} tenantId - Tenant ID + * @param {Number} increment - Increment value (1 or -1) + */ + static async updateParentHasChildCategories(parentId, tenantId, increment = 1) { + if (!parentId) return + + try { + const parent = await projectCategoriesQueries.findOne({ _id: parentId, tenantId }) + if (parent) { + const existingChildren = Array.isArray(parent.children) ? parent.children.length : 0 + const newChildCount = Math.max(0, existingChildren + increment) + await projectCategoriesQueries.updateOne( + { _id: parentId, tenantId }, + { + $set: { + hasChildCategories: newChildCount > 0, + }, + } + ) + } + } catch (error) { + console.error('Error updating parent counts:', error) + } + } + + /** + * Validate parent category + * @method + * @name validateParent + * @param {ObjectId} parentId - Parent category ID + * @param {String} tenantId - Tenant ID + * @returns {Object} Parent category + */ + static async validateParent(parentId, tenantId) { + if (!parentId) return null + + const parent = await projectCategoriesQueries.findOne({ + _id: parentId, + tenantId, + isDeleted: false, + }) + + if (!parent) { + throw { + status: HTTP_STATUS_CODE.bad_request.status, + message: 'PARENT_CATEGORY_NOT_FOUND', + } + } + + return parent + } + + /** + * Get hierarchy for a specific category (subtree starting from category) + * @method + * @name getCategoryHierarchy + * @param {Object} req - Request object (contains params, headers, query, body and userDetails) + * @returns {Object} Category subtree + */ + static getCategoryHierarchy(req) { + return new Promise(async (resolve, reject) => { + try { + const categoryId = req.params._id + let tenantId = + req.headers['tenantId'] || + req.body.tenantId || + req.query.tenantId || + req.query.tenantCode || + req.userDetails?.userInformation?.tenantId + + // Find the category + let matchQuery = { tenantId: tenantId, isDeleted: false } + if (ObjectId.isValid(categoryId)) { + matchQuery['$or'] = [{ _id: new ObjectId(categoryId) }, { externalId: categoryId }] + } else { + matchQuery['externalId'] = categoryId + } + + const category = await projectCategoriesQueries.findOne(matchQuery) + + if (!category) { + throw { + status: HTTP_STATUS_CODE.bad_request.status, + message: CONSTANTS.apiResponses.CATEGORY_NOT_FOUND || 'Category not found', + } + } + + // Get all descendant categories recursively + const descendantIds = await this.getAllDescendantIds(category._id, tenantId) + const allCategoryIds = [category._id, ...descendantIds] + + // Convert all IDs to ObjectId for query + const objectIdArray = allCategoryIds.map((id) => { + if (id instanceof ObjectId) return id + if (ObjectId.isValid(id)) return new ObjectId(id) + return id + }) + + // Get all categories in the subtree + let query = { + tenantId: tenantId, + _id: { $in: objectIdArray }, + status: CONSTANTS.common.ACTIVE_STATUS, + isDeleted: false, + } + + let allCategories = await projectCategoriesQueries.categoryDocuments(query, [ + '_id', 'externalId', 'name', + 'description', 'icon', - 'updatedAt', - 'noOfProjects', + 'metaInformation.icon', + 'parent_id', + 'hasChildCategories', + 'sequenceNumber', ]) - if (!categoryData.length > 0) { + // Build tree structure starting from the requested category + const categoryMap = {} + let rootCategory = null + + // Create map of all categories + allCategories.forEach((cat) => { + const catIdStr = cat._id.toString() + categoryMap[catIdStr] = { ...cat, children: [] } + const categoryIdStr = category._id.toString() + if (catIdStr === categoryIdStr) { + rootCategory = categoryMap[catIdStr] + } + }) + + // Build tree - only add children that are in our map + allCategories.forEach((cat) => { + const categoryNode = categoryMap[cat._id.toString()] + if (cat.parent_id) { + // Handle both ObjectId and string formats + let parentIdStr + if (cat.parent_id instanceof ObjectId) { + parentIdStr = cat.parent_id.toString() + } else if (cat.parent_id._id) { + parentIdStr = cat.parent_id._id.toString() + } else { + parentIdStr = cat.parent_id.toString() + } + + if (categoryMap[parentIdStr]) { + categoryMap[parentIdStr].children.push(categoryNode) + } + } + }) + + // Sort by sequenceNumber + const sortBySequenceNumber = (categoryNode) => { + if (categoryNode.children && categoryNode.children.length > 0) { + categoryNode.children.sort((a, b) => (a.sequenceNumber || 0) - (b.sequenceNumber || 0)) + categoryNode.children.forEach((child) => { + if (child.children && child.children.length > 0) { + sortBySequenceNumber(child) + } + }) + } + } + + // normalize icon field from metaInformation to top-level for backward compatibility + const normalizeIcon = (categoryNode) => { + if ( + (categoryNode.icon === undefined || categoryNode.icon === '') && + categoryNode.metaInformation && + categoryNode.metaInformation.icon !== undefined + ) { + categoryNode.icon = categoryNode.metaInformation.icon + } + if (categoryNode.children && categoryNode.children.length) { + categoryNode.children.forEach((child) => normalizeIcon(child)) + } + } + + if (rootCategory) { + sortBySequenceNumber(rootCategory) + // Ensure icon exists at root (fallback from legacy metaInformation.icon) + normalizeIcon(rootCategory) + const applyFallback = (node) => { + if ( + (node.icon === undefined || node.icon === '') && + node.metaInformation && + node.metaInformation.icon !== undefined + ) { + node.icon = node.metaInformation.icon + } + if (node.children && node.children.length) { + node.children.forEach((c) => applyFallback(c)) + } + } + applyFallback(rootCategory) + } + + return resolve({ + success: true, + message: 'Category hierarchy fetched successfully', + data: { + tree: rootCategory, + totalCategories: allCategories.length, + }, + }) + } catch (error) { + return reject({ + success: false, + status: error.status || HTTP_STATUS_CODE.internal_server_error.status, + message: error.message, + data: {}, + }) + } + }) + } + + /** + * Get leaf categories + * @method + * @name getLeaves + * @param {Object} req - Request object + * @returns {Object} Leaf categories + */ + static getLeaves(req) { + return new Promise(async (resolve, reject) => { + try { + let tenantId = + req.headers['tenantId'] || + req.body.tenantId || + req.query.tenantId || + req.query.tenantCode || + req.userDetails.userInformation.tenantId + let orgId = + req.headers['orgId'] || + req.body.orgId || + req.query.orgId || + req.query.orgCode || + req.userDetails.userInformation.organizationId + + let query = { + tenantId: tenantId, + // visibleToOrganizations: { $in: [orgId] }, + status: CONSTANTS.common.ACTIVE_STATUS, + isDeleted: false, + hasChildCategories: false, + } + + // Pagination logic using defaults + const pageSize = req.pageSize + const skip = pageSize * (req.pageNo - 1) + const sort = { sequenceNumber: 1, name: 1 } + + // Use list query with pagination + let leafCategoriesResult = await projectCategoriesQueries.list( + query, + { + externalId: 1, + name: 1, + description: 1, + 'metaInformation.icon': 1, + parent_id: 1, + hasChildCategories: 1, + sequenceNumber: 1, + }, + sort, + skip, + pageSize + ) + // Normalize icon from metaInformation + const normalizedData = leafCategoriesResult.data.map((cat) => { + const copy = { ...cat } + if ( + (copy.icon === undefined || copy.icon === '') && + copy.metaInformation && + copy.metaInformation.icon !== undefined + ) { + copy.icon = copy.metaInformation.icon + } + return copy + }) + + return resolve({ + success: true, + message: 'Leaf categories fetched successfully', + data: normalizedData, + count: leafCategoriesResult.count, + }) + } catch (error) { + return reject({ + success: false, + status: error.status || HTTP_STATUS_CODE.internal_server_error.status, + message: error.message, + data: {}, + }) + } + }) + } + + /** + * Get all descendant category IDs for a given category (recursive) + * @method + * @name getAllDescendantIds + * @param {ObjectId} categoryId - Parent category ID + * @param {String} tenantId - Tenant ID + * @returns {Array} Array of descendant category IDs + */ + static async getAllDescendantIds(categoryId, tenantId) { + try { + const allDescendantIds = [] + const processedIds = new Set() + + // Recursive function to get all descendants + const getDescendants = async (parentId) => { + // Normalize parentId to string for comparison + const parentIdStr = parentId instanceof ObjectId ? parentId.toString() : parentId.toString() + + // Avoid infinite loops + if (processedIds.has(parentIdStr)) { + return + } + processedIds.add(parentIdStr) + + // Convert to ObjectId for query - MongoDB can match ObjectId with ObjectId or string + const parentObjectId = + parentId instanceof ObjectId + ? parentId + : ObjectId.isValid(parentId) + ? new ObjectId(parentId) + : parentId + + // Query for direct children - try both ObjectId and string formats + const children = await projectCategoriesQueries.categoryDocuments( + { + tenantId: tenantId, + $or: [{ parent_id: parentObjectId }, { parent_id: parentIdStr }], + isDeleted: false, + status: CONSTANTS.common.ACTIVE_STATUS, + }, + ['_id', 'parent_id'] + ) + + for (const child of children) { + const childIdStr = child._id.toString() + // Only add if not already in the list + if (!allDescendantIds.some((id) => id.toString() === childIdStr)) { + allDescendantIds.push(child._id) + // Recursively get children of this child + await getDescendants(child._id) + } + } + } + + await getDescendants(categoryId) + return allDescendantIds + } catch (error) { + console.error('Error getting descendant IDs:', error) + return [] + } + } + + /** + * Check if categories have any projects associated + * @method + * @name checkCategoriesHaveProjects + * @param {Array} categoryIds - Array of category IDs to check + * @param {String} tenantId - Tenant ID + * @returns {Object} Result with hasProjects flag and details + */ + static async checkCategoriesHaveProjects(categoryIds, tenantId) { + try { + // Build aggregation pipeline to count projects for each category + const pipeline = [ + { + $match: { + tenantId: tenantId, + isReusable: true, + status: CONSTANTS.common.PUBLISHED_STATUS, + isDeleted: false, + 'categories._id': { $in: categoryIds }, + }, + }, + { + $unwind: '$categories', + }, + { + $match: { + 'categories._id': { $in: categoryIds }, + }, + }, + { + $group: { + _id: '$categories._id', + categoryName: { $first: '$categories.name' }, + projectCount: { $sum: 1 }, + projectTitles: { $push: '$title' }, + }, + }, + ] + + const results = await projectTemplateQueries.getAggregate(pipeline) + + if (!results || results.length === 0) { + return { + hasProjects: false, + totalProjects: 0, + categoriesWithProjects: [], + } + } + + const totalProjects = results.reduce((sum, cat) => sum + cat.projectCount, 0) + const categoriesWithProjects = results.map((cat) => ({ + categoryId: cat._id, + categoryName: cat.categoryName, + projectCount: cat.projectCount, + projectTitles: cat.projectTitles.slice(0, 5), // Limit to first 5 project names + })) + + return { + hasProjects: true, + totalProjects, + categoriesWithProjects, + } + } catch (error) { + console.error('Error checking categories for projects:', error) + return { + hasProjects: false, + totalProjects: 0, + categoriesWithProjects: [], + } + } + } + + /** + * Delete category + * @method + * @name delete + * @param {Object} req - Express request object. `req.params._id` should contain categoryId and `req.userDetails` contains user info + * @returns {Object} Delete result + */ + static delete(req) { + return new Promise(async (resolve, reject) => { + try { + // extract categoryId and userDetails from req + const categoryId = req.params?._id || req.params?.id || null + const userDetails = req.userDetails || {} + + // derive tenantId and orgId from userDetails + const tenantId = + userDetails?.tenantAndOrgInfo?.tenantId || userDetails?.userInformation?.tenantId || null + const orgId = Array.isArray(userDetails?.tenantAndOrgInfo?.orgId) + ? userDetails.tenantAndOrgInfo.orgId[0] + : userDetails?.tenantAndOrgInfo?.orgId || userDetails?.userInformation?.organizationId || null + + if (!tenantId) { throw { - status: HTTP_STATUS_CODE.ok.status, - message: CONSTANTS.apiResponses.LIBRARY_CATEGORIES_NOT_FOUND, + status: HTTP_STATUS_CODE.bad_request.status, + message: 'Tenant ID is required', } } + // 1. Get category details + let matchQuery = { tenantId: tenantId, isDeleted: false } + if (ObjectId.isValid(categoryId)) { + matchQuery['$or'] = [{ _id: new ObjectId(categoryId) }, { externalId: categoryId }] + } else { + matchQuery['externalId'] = categoryId + } + + const category = await projectCategoriesQueries.findOne(matchQuery) + + if (!category) { + throw { + status: HTTP_STATUS_CODE.bad_request.status, + message: CONSTANTS.apiResponses.CATEGORY_NOT_FOUND, + } + } + + // 2. Validate deletion eligibility + const allCategoryIds = await this.getAllDescendantIds(category._id, tenantId) + allCategoryIds.push(category._id) // Include the category itself + + // Check if any category (parent or children) has projects + const projectsCheck = await this.checkCategoriesHaveProjects(allCategoryIds, tenantId) + if (projectsCheck.hasProjects) { + throw { + status: HTTP_STATUS_CODE.bad_request.status, + message: `Category or its children are used by ${projectsCheck.totalProjects} projects`, + } + } + + // Check if has children + if (category.hasChildCategories) { + throw { + status: HTTP_STATUS_CODE.bad_request.status, + message: 'Has child categories. Delete children first.', + } + } + + // Check if referenced by templates + const templates = await projectTemplateQueries.templateDocument( + { + 'categories._id': category._id, + tenantId, + isDeleted: false, + }, + ['_id', 'title'] + ) + + if (templates && templates.length > 0) { + throw { + status: HTTP_STATUS_CODE.bad_request.status, + message: `Referenced by ${templates.length} templates`, + } + } + + // 3. Soft delete the category + await projectCategoriesQueries.updateOne( + { _id: category._id, tenantId }, + { $set: { isDeleted: true, deletedAt: new Date() } } + ) + + // 4. Remove category from all templates + const templatesUpdated = await this.removeCategoryFromTemplates(category._id, tenantId) + + // 5. Update parent counts + if (category.parent_id) { + await this.updateParentHasChildCategories(category.parent_id, tenantId, -1) + // remove from parent's children array + await projectCategoriesQueries.updateOne( + { _id: category.parent_id }, + { $pull: { children: category._id } } + ) + } + return resolve({ success: true, - message: CONSTANTS.apiResponses.PROJECT_CATEGORIES_FETCHED, - data: categoryData, + message: CONSTANTS.apiResponses.CATEGORY_DELETED || 'Category deleted successfully', + data: { + categoryId: category._id, + templatesUpdated: templatesUpdated, + }, + }) + } catch (error) { + return reject({ + success: false, + status: error.status || HTTP_STATUS_CODE.internal_server_error.status, + message: error.message, + data: {}, + }) + } + }) + } + + /** + * Remove category from all templates + * @method + * @name removeCategoryFromTemplates + * @param {ObjectId} categoryId - Category ID + * @param {String} tenantId - Tenant ID + * @returns {Number} Number of templates updated + */ + static async removeCategoryFromTemplates(categoryId, tenantId) { + try { + // Find all templates with this category + const templates = await projectTemplateQueries.templateDocument( + { + 'categories._id': categoryId, + tenantId, + isDeleted: false, + }, + ['_id', 'categories'] + ) + + console.log(`Removing category ${categoryId} from ${templates.length} templates`) + + // Remove category from each template + for (const template of templates) { + const updatedCategories = template.categories.filter( + (cat) => cat._id && cat._id.toString() !== categoryId.toString() + ) + + await projectTemplateQueries.updateProjectTemplateDocument( + { _id: template._id }, + { + $set: { + categories: updatedCategories, + }, + } + ) + } + + return templates.length + } catch (error) { + console.error('Error removing category from templates:', error) + throw error + } + } + + /** + * Bulk create categories + * @method + * @name bulkCreate + * @param {Array} categories - Array of category data + * @param {Object} userDetails - User details + * @returns {Object} Bulk create result + */ + static bulkCreate(categories, userDetails) { + return new Promise(async (resolve, reject) => { + try { + // derive tenant & org from userDetails + const tenantId = userDetails?.tenantAndOrgInfo?.tenantId + const orgId = Array.isArray(userDetails?.tenantAndOrgInfo?.orgId) + ? userDetails.tenantAndOrgInfo.orgId[0] + : userDetails?.tenantAndOrgInfo?.orgId + + let created = 0 + let failed = 0 + const errors = [] + + for (const categoryData of categories) { + try { + // Find parent by externalId if parentExternalId provided + let parentId = null + if (categoryData.parentExternalId) { + const parent = await projectCategoriesQueries.findOne( + { externalId: categoryData.parentExternalId, tenantId }, + { _id: 1 } + ) + if (parent) { + parentId = parent._id + } else { + throw { + message: + CONSTANTS.apiResponses.PARENT_CATEGORY_NOT_FOUND || 'Parent category not found', + status: HTTP_STATUS_CODE.bad_request.status, + } + } + } + + categoryData.parentId = parentId + categoryData.tenantId = tenantId + categoryData.orgId = orgId + // categoryData.visibleToOrganizations = [orgId] + + // Create category + const result = await this.create(categoryData, null, userDetails) + if (result.success) { + created++ + } else { + failed++ + errors.push({ category: categoryData.externalId, error: result.message }) + } + } catch (error) { + failed++ + errors.push({ category: categoryData.externalId, error: error.message }) + } + } + + return resolve({ + success: true, + data: { + created, + failed, + errors, + }, }) } catch (error) { + return reject({ + success: false, + status: error.status || HTTP_STATUS_CODE.internal_server_error.status, + message: error.message, + data: {}, + }) + } + }) + } + + /** + * Sync templates for a category (background job) + * @method + * @name syncTemplatesForCategory + * @param {ObjectId} categoryId - Category ID + * @param {String} tenantId - Tenant ID + */ + static async syncTemplatesForCategory(categoryId, tenantId) { + try { + const category = await projectCategoriesQueries.findOne({ _id: categoryId, tenantId }) + if (!category) return + + // Find all templates with this category + const templates = await projectTemplateQueries.templateDocument( + { + 'categories._id': categoryId, + tenantId, + isDeleted: false, + }, + ['_id', 'categories'] + ) + + // Instead of updating templates directly here, publish a kafka event so + // downstream template sync workers can handle updates in a decoupled way. + for (const template of templates) { + const message = { + templateId: template._id, + tenantId: tenantId, + category: { + _id: category._id, + name: category.name, + externalId: category.externalId, + isLeaf: !category.hasChildCategories, + syncAt: new Date(), + }, + action: 'category_updated', + } + + // fire and forget - log error if kafka push fails + kafkaProducersHelper.pushCategoryChangeEvent(message).catch((err) => { + console.error('Failed to push category change event for template', template._id, err) + }) + } + } catch (error) { + console.error('Error syncing templates for category:', error) + } + } + + static details(categoryId, userDetails) { + return new Promise(async (resolve, reject) => { + try { + const tenantId = + userDetails?.tenantAndOrgInfo?.tenantId || userDetails?.userInformation?.tenantId || null + + if (!tenantId) { + throw { + status: HTTP_STATUS_CODE.bad_request.status, + message: 'Tenant ID is required', + } + } + + let matchQuery = { + tenantId: tenantId, + isDeleted: false, + } + + if (ObjectId.isValid(categoryId)) { + matchQuery['$or'] = [{ _id: new ObjectId(categoryId) }, { externalId: categoryId }] + } else { + matchQuery['externalId'] = categoryId + } + + const category = await projectCategoriesQueries.findOne(matchQuery) + + if (!category) { + throw { + status: HTTP_STATUS_CODE.bad_request.status, + message: CONSTANTS.apiResponses.CATEGORY_NOT_FOUND, + } + } + + // Normalize icon: prefer root `icon`, fallback to legacy `metaInformation.icon` + if (category) { + if ( + (category.icon === undefined || category.icon === '') && + category.metaInformation && + category.metaInformation.icon !== undefined + ) { + category.icon = category.metaInformation.icon + } + } + return resolve({ + success: true, + message: CONSTANTS.apiResponses.PROJECT_CATEGORIES_FETCHED, + data: category, + }) + } catch (error) { + return reject({ success: false, + status: error.status || HTTP_STATUS_CODE.internal_server_error.status, message: error.message, data: {}, }) @@ -990,6 +1999,16 @@ function handleEvidenceUpload(files, userId) { } } */ + +/** + * Apply visibility conditions to the match query. + * @method + * @name applyVisibilityConditions + * @param {Object} matchQuery - The current match query. + * @param {Object} orgExtension - Organization extension document. + * @param {Object} userDetails - User details. + * @returns {Object} Updated match query. + */ function applyVisibilityConditions(matchQuery, orgExtension, userDetails) { let matchConditions = [] diff --git a/module/library/categories/validator/v1.js b/module/library/categories/validator/v1.js index ec843e6c..b12165e9 100644 --- a/module/library/categories/validator/v1.js +++ b/module/library/categories/validator/v1.js @@ -6,17 +6,120 @@ */ module.exports = (req) => { - let projectsValidator = { + let libraryCategoriesValidator = { + /** + * Create: Validate required fields for new category + * - externalId: required, unique + * - name: required + * - parentId: optional (null = root category) + * - icon: optional + * - sequenceNumber: optional + */ create: function () { req.checkBody('externalId').exists().withMessage('externalId is required') req.checkBody('name').exists().withMessage('name is required') + if (req.body.parentId) { + req.checkBody('parentId').isMongoId().withMessage('parentId must be a valid MongoDB ObjectId') + } + if (req.body.description !== undefined) { + req.checkBody('description').isString().withMessage('description must be a string') + } + if (req.body.sequenceNumber !== undefined) { + req.checkBody('sequenceNumber').isInt().withMessage('sequenceNumber must be an integer') + } }, + + /** + * Update: Validate category ID and optional fields + */ update: function () { req.checkParams('_id').exists().withMessage('required category id') + if (req.body.name !== undefined) { + req.checkBody('name').notEmpty().withMessage('name cannot be empty') + } + if (req.body.externalId !== undefined) { + req.checkBody('externalId').notEmpty().withMessage('externalId cannot be empty') + } + if (req.body.description !== undefined) { + req.checkBody('description').isString().withMessage('description must be a string') + } + }, + + /** + * Details: Validate category ID + */ + details: function () { + req.checkParams('_id').exists().withMessage('required category id') + }, + + /** + * Delete: Validate category ID + */ + delete: function () { + req.checkParams('_id').exists().withMessage('required category id') + }, + + /** + * Bulk: Validate categories array + */ + bulk: function () { + req.checkBody('categories').exists().withMessage('categories array is required') + req.checkBody('categories').isArray().withMessage('categories must be an array') + req.checkBody('categories').notEmpty().withMessage('categories array cannot be empty') + }, + + /** + * List: Optional query parameters validation + */ + list: function () { + if (req.query.parentId) { + req.checkQuery('parentId').isMongoId().withMessage('parentId must be a valid MongoDB ObjectId') + } + if (req.query.page) { + req.checkQuery('page').isInt({ min: 1 }).withMessage('page must be a positive integer') + } + if (req.query.limit) { + req.checkQuery('limit').isInt({ min: 1, max: 100 }).withMessage('limit must be between 1 and 100') + } + }, + + /** + * (removed) Hierarchy validator: full-tree hierarchy endpoint removed + */ + + /** + * CategoryHierarchy: Validate category ID + */ + categoryHierarchy: function () { + req.checkParams('_id').exists().withMessage('required category id') + if (req.query.maxDepth) { + req.checkQuery('maxDepth').isInt({ min: 1, max: 10 }).withMessage('maxDepth must be between 1 and 10') + } + }, + + /** + * Leaves: Optional query parameters + */ + leaves: function () { + if (req.query.page) { + req.checkQuery('page').isInt({ min: 1 }).withMessage('page must be a positive integer') + } + if (req.query.limit) { + req.checkQuery('limit').isInt({ min: 1, max: 100 }).withMessage('limit must be between 1 and 100') + } + }, + + /** + * Projects: Validate category IDs (supports single, comma-separated, or array) + */ + projects: function () { + // Validation happens in controller - supports multiple input formats + // Format 1: Path parameter (:id) - single ID + // Format 2: Query string (?ids=id1,id2,id3) - comma-separated }, } - if (projectsValidator[req.params.method]) { - projectsValidator[req.params.method]() + if (libraryCategoriesValidator[req.params.method]) { + libraryCategoriesValidator[req.params.method]() } } diff --git a/module/project/templates/helper.js b/module/project/templates/helper.js index a9c189c5..c3bafb15 100644 --- a/module/project/templates/helper.js +++ b/module/project/templates/helper.js @@ -14,7 +14,6 @@ const { ObjectId } = require('mongodb') // Dependencies -const libraryCategoriesHelper = require(MODULES_BASE_PATH + '/library/categories/helper') const coreService = require(GENERICS_FILES_PATH + '/services/core') // const kafkaProducersHelper = require(GENERICS_FILES_PATH + "/kafka/producers"); const learningResourcesHelper = require(MODULES_BASE_PATH + '/learningResources/helper') @@ -442,15 +441,13 @@ module.exports = class ProjectTemplatesHelper { return category._id }) - let updatedCategories = await libraryCategoriesHelper.update( + await projectCategoriesQueries.updateMany( { _id: { $in: categories }, }, { $inc: { noOfProjects: 1 }, - }, - {}, - userDetails + } ) if (!updatedCategories.success) { currentData['_SYSTEM_ID'] = updatedCategories.message @@ -560,7 +557,7 @@ module.exports = class ProjectTemplatesHelper { return category._id }) - let updatedCategories = await libraryCategoriesHelper.update( + await projectCategoriesQueries.updateMany( { _id: { $in: categories }, }, @@ -580,7 +577,7 @@ module.exports = class ProjectTemplatesHelper { return category._id }) - let categoriesUpdated = await libraryCategoriesHelper.update( + await projectCategoriesQueries.updateMany( { _id: { $in: categoriesIds }, }, diff --git a/module/userProjects/helper.js b/module/userProjects/helper.js index d163177e..e85b0c14 100644 --- a/module/userProjects/helper.js +++ b/module/userProjects/helper.js @@ -6,6 +6,7 @@ */ // Dependencies +// Library categories helper const libraryCategoriesHelper = require(MODULES_BASE_PATH + '/library/categories/helper') const projectTemplatesHelper = require(MODULES_BASE_PATH + '/project/templates/helper') const { v4: uuidv4 } = require('uuid') @@ -1285,14 +1286,14 @@ module.exports = class UserProjectsHelper { // get solutions details based on solutionTypes /* ******Sample response************* - { - "success": true, + { + "success": true, data:{ - "programId": "685140cbf891ccf74e05baf9", - "observationId": "685146542054fe175c7150c8", - "solutionId": "685140d1ffc25f705c56e99e", - } - } + "programId": "685140cbf891ccf74e05baf9", + "observationId": "685146542054fe175c7150c8", + "solutionId": "685140d1ffc25f705c56e99e", + } + } */ const getSolutionDetails = { @@ -4324,7 +4325,7 @@ module.exports = class UserProjectsHelper { * @name deleteUserPIIData * @param {userDeleteEvent} - userDeleteEvent message object * { - "entity": "user", + "entity": "user", "eventType": "delete", "entityId": 101, "changes": {}, @@ -4335,7 +4336,7 @@ module.exports = class UserProjectsHelper { "deleted": true, "id": 101, "username" : "user_shqwq1ssddw" - } + } * @returns {Promise} success Data. */ static deleteUserPIIData(userDeleteEvent) {