diff --git a/controllers/v1/program/users.js b/controllers/v1/program/users.js new file mode 100644 index 00000000..dabf73de --- /dev/null +++ b/controllers/v1/program/users.js @@ -0,0 +1,111 @@ +/* + * name : users.js + * author : copilot + * created-date : 2025-12-30 + * Description : Program -> Users controller to expose programUsers APIs under /program/users + */ + +// Dependencies +const programUsersHelper = require(MODULES_BASE_PATH + '/programUsers/helper') + +module.exports = class Users extends Abstract { + constructor() { + super('programUsers') + } + + static get name() { + return 'programUsers' + } + + async create(req) { + try { + const result = await programUsersHelper.create(req.body, req.userDetails) + return result + } catch (error) { + return { + status: error.status || HTTP_STATUS_CODE.internal_server_error.status, + message: error.message || HTTP_STATUS_CODE.internal_server_error.message, + errorObject: error, + } + } + } + + async update(req) { + try { + const result = await programUsersHelper.update(req.params._id, req.body, req.userDetails) + return result + } catch (error) { + return { + status: error.status || HTTP_STATUS_CODE.internal_server_error.status, + message: error.message || HTTP_STATUS_CODE.internal_server_error.message, + errorObject: error, + } + } + } + + async read(req) { + try { + if (req.params.programId && req.params.userId && !req.params._id) { + const result = await programUsersHelper.readByProgramAndUserId( + req.params.programId, + req.params.userId, + req.userDetails + ) + return result + } else if (req.params._id) { + const result = await programUsersHelper.read(req.params._id, req.userDetails) + return result + } else { + throw { + status: HTTP_STATUS_CODE.bad_request.status, + message: 'Invalid parameters. Use either /:_id or /:programId/:userId', + } + } + } catch (error) { + return { + status: error.status || HTTP_STATUS_CODE.internal_server_error.status, + message: error.message || HTTP_STATUS_CODE.internal_server_error.message, + errorObject: error, + } + } + } + + async list(req) { + try { + const result = await programUsersHelper.list(req) + return result + } catch (error) { + return { + status: error.status || HTTP_STATUS_CODE.internal_server_error.status, + message: error.message || HTTP_STATUS_CODE.internal_server_error.message, + errorObject: error, + } + } + } + + async delete(req) { + try { + const result = await programUsersHelper.deleteResource(req.params._id, req.userDetails) + return result + } catch (error) { + return { + status: error.status || HTTP_STATUS_CODE.internal_server_error.status, + message: error.message || HTTP_STATUS_CODE.internal_server_error.message, + errorObject: error, + } + } + } + + async getByProgramId(req) { + try { + const result = await programUsersHelper.getByProgramId(req.params._id, req.query, req.userDetails) + return result + } catch (error) { + return { + status: error.status || HTTP_STATUS_CODE.internal_server_error.status, + message: error.message || HTTP_STATUS_CODE.internal_server_error.message, + errorObject: error, + } + } + } +} diff --git a/controllers/v1/programUsers.js b/controllers/v1/programUsers.js deleted file mode 100644 index 35d8ef63..00000000 --- a/controllers/v1/programUsers.js +++ /dev/null @@ -1,22 +0,0 @@ -/** - * name : programUsers.js - * author : Ankit Shahu - * created-date : 9-Jan-2023 - * Description : PII data related controller. -*/ - -/** - * programUsers - * @class - */ -module.exports = class ProgramUsers extends Abstract { - constructor() { - super("programUsers"); - } - - static get name() { - return "programUsers"; - } - -}; - diff --git a/databaseQueries/programUsers.js b/databaseQueries/programUsers.js index 479b8b8e..bb6be4a5 100644 --- a/databaseQueries/programUsers.js +++ b/databaseQueries/programUsers.js @@ -4,49 +4,259 @@ * created-date : 07-04-2023 * Description : program users helper for DB interactions. */ -module.exports = class programUsers { - - /** - * program users details. - * @method - * @name programUsersDocument - * @param {Array} [filterData = "all"] - program users filter query. - * @param {Array} [fieldsArray = "all"] - projected fields. - * @param {Array} [skipFields = "none"] - field not to include - * @returns {Array} program users details. - */ - - static programUsersDocument( - filterData = "all", - fieldsArray = "all", - skipFields = "none" - ) { - return new Promise(async (resolve, reject) => { - try { - - let queryObject = (filterData != "all") ? filterData : {}; - let projection = {} - - if (fieldsArray != "all") { - fieldsArray.forEach(field => { - projection[field] = 1; - }); - } - - if( skipFields !== "none" ) { - skipFields.forEach(field=>{ - projection[field] = 0; - }); - } - - let programJoinedData = await database.models.programUsers - .find(queryObject, projection) - .lean(); - return resolve(programJoinedData); - } catch (error) { - return reject(error); - } - }); - } - -}; + +module.exports = class ProgramUsers { + /** + * Get program users documents. + * @method + * @name programUsersDocument + * @param {Object} [filterData = "all"] - program users filter query. + * @param {Array} [fieldsArray = "all"] - projected fields. + * @param {Array} [skipFields = "none"] - fields not to include. + * @returns {Array} program users details. + */ + static async programUsersDocument(filterData = 'all', fieldsArray = 'all', skipFields = 'none') { + try { + let queryObject = filterData != 'all' ? filterData : {} + let projection = {} + + if (fieldsArray != 'all') { + fieldsArray.forEach((field) => { + projection[field] = 1 + }) + } + + if (skipFields !== 'none') { + skipFields.forEach((field) => { + projection[field] = 0 + }) + } + + let programJoinedData = await database.models.programUsers.find(queryObject, projection).lean() + return programJoinedData + } catch (error) { + throw error + } + } + + /** + * Create a new program user document. + * @method + * @name create + * @param {Object} data - program user data to create. + * @returns {Object} newly created program user document. + */ + static async create(data) { + try { + let programUserDocument = await database.models.programUsers.create(data) + return programUserDocument + } catch (error) { + throw error + } + } + + /** + * Find a single program user document. + * @method + * @name findOne + * @param {Object} [filterData = "all"] - program users filter query. + * @param {Array} [fieldsArray = "all"] - projected fields. + * @param {Array} [skipFields = "none"] - fields not to include. + * @returns {Object} program user details. + */ + static async findOne(filterData = 'all', fieldsArray = 'all', skipFields = 'none') { + try { + let queryObject = filterData != 'all' ? filterData : {} + let projection = {} + + if (fieldsArray != 'all') { + fieldsArray.forEach((field) => { + projection[field] = 1 + }) + } + + if (skipFields !== 'none') { + skipFields.forEach((field) => { + projection[field] = 0 + }) + } + + let programUserData = await database.models.programUsers.findOne(queryObject, projection).lean() + return programUserData + } catch (error) { + throw error + } + } + + /** + * Update a single program user document. + * @method + * @name findOneAndUpdate + * @param {Object} filterData - filter query. + * @param {Object} updateData - data to update. + * @param {Object} [options = { new: true }] - options for the update. + * @returns {Object} updated program user document. + */ + static async findOneAndUpdate(filterData, updateData, options = { new: true }) { + try { + let updatedDocument = await database.models.programUsers + .findOneAndUpdate(filterData, updateData, options) + .lean() + return updatedDocument + } catch (error) { + throw error + } + } + + /** + * Update multiple program user documents. + * @method + * @name updateMany + * @param {Object} filterData - filter query. + * @param {Object} updateData - data to update. + * @returns {Object} update result. + */ + static async updateMany(filterData, updateData) { + try { + let updateResult = await database.models.programUsers.updateMany(filterData, updateData) + return updateResult + } catch (error) { + throw error + } + } + + /** + * Delete a single program user document. + * @method + * @name deleteOne + * @param {Object} filterData - filter query. + * @returns {Object} deletion result. + */ + static async deleteOne(filterData) { + try { + let deleteResult = await database.models.programUsers.deleteOne(filterData) + return deleteResult + } catch (error) { + throw error + } + } + + /** + * Delete multiple program user documents. + * @method + * @name deleteMany + * @param {Object} filterData - filter query. + * @returns {Object} deletion result. + */ + static async deleteMany(filterData) { + try { + let deleteResult = await database.models.programUsers.deleteMany(filterData) + return deleteResult + } catch (error) { + throw error + } + } + + /** + * Count program user documents. + * @method + * @name count + * @param {Object} [filterData = {}] - filter query. + * @returns {Number} count of documents. + */ + static async count(filterData = {}) { + try { + let count = await database.models.programUsers.countDocuments(filterData) + return count + } catch (error) { + throw error + } + } + + /** + * Get program users with pagination. + * @method + * @name list + * @param {Object} [filterData = {}] - filter query. + * @param {Array} [fieldsArray = "all"] - projected fields. + * @param {Array} [skipFields = "none"] - fields not to include. + * @param {Number} [page = 1] - page number. + * @param {Number} [limit = 20] - records per page. + * @param {Object} [sortData = { createdAt: -1 }] - sort criteria. + * @returns {Object} paginated program users data with count. + */ + static async list( + filterData = {}, + fieldsArray = 'all', + skipFields = 'none', + page = 1, + limit = 20, + sortData = { createdAt: -1 } + ) { + try { + let projection = {} + + if (fieldsArray != 'all') { + fieldsArray.forEach((field) => { + projection[field] = 1 + }) + } + + if (skipFields !== 'none') { + skipFields.forEach((field) => { + projection[field] = 0 + }) + } + + const skip = (page - 1) * limit + + const [data, totalCount] = await Promise.all([ + database.models.programUsers.find(filterData, projection).sort(sortData).skip(skip).limit(limit).lean(), + database.models.programUsers.countDocuments(filterData), + ]) + + return { + data, + totalCount, + page, + limit, + totalPages: Math.ceil(totalCount / limit), + } + } catch (error) { + throw error + } + } + + /** + * Aggregate program users. + * @method + * @name aggregate + * @param {Array} pipeline - aggregation pipeline. + * @returns {Array} aggregation result. + */ + static async aggregate(pipeline) { + try { + let result = await database.models.programUsers.aggregate(pipeline) + return result + } catch (error) { + throw error + } + } + + /** + * Bulk write operations for program users. + * @method + * @name bulkWrite + * @param {Array} operations - array of bulk write operations. + * @param {Object} [options = {}] - bulk write options. + * @returns {Object} bulk write result. + */ + static async bulkWrite(operations, options = {}) { + try { + let result = await database.models.programUsers.bulkWrite(operations, options) + return result + } catch (error) { + throw error + } + } +} diff --git a/document/programUsers/README.md b/document/programUsers/README.md new file mode 100644 index 00000000..92a85a8c --- /dev/null +++ b/document/programUsers/README.md @@ -0,0 +1,605 @@ +# Program Users API Documentation + +## Overview + +The Program Users module provides APIs to manage user-program mappings with status tracking, metadata storage, and hierarchical category tracking. This module is designed to track a user's journey through a program from onboarding to graduation. + +## Table of Contents + +1. [Schema](#schema) +2. [Status Flow](#status-flow) +3. [API Endpoints](#api-endpoints) +4. [Authentication](#authentication) +5. [Sample Requests](#sample-requests) +6. [Error Handling](#error-handling) + +--- + +## Schema + +### Collection: `programUsers` + +| Field | Type | Required | Description | +| --------------------- | -------- | -------- | ------------------------------------------------------------------------- | +| `programId` | ObjectId | Yes | Reference to the program | +| `userId` | String | Yes | User identifier | +| `userProfile` | Object | Yes | User profile information | +| `userRoleInformation` | Object | No | User's role in the program | +| `metadata` | Object | No | Hierarchical category/template/task tracking (optional, empty by default) | +| `appInformation` | Object | No | Application details | +| `consentShared` | Boolean | No | Whether consent is shared (default: false) | +| `resourcesStarted` | Boolean | No | Whether resources are started (default: false) | +| `status` | String | No | User's status in program (default: NOT_ONBOARDED) | +| `prevStatus` | String | No | Previous status for transition tracking | +| `statusReason` | String | No | Reason for status change | +| `tenantId` | String | Yes | Tenant identifier (from token) | +| `orgId` | String | Yes | Organization identifier (from token) | +| `createdBy` | String | Yes | Created by user ID (from token) | +| `updatedBy` | String | Yes | Updated by user ID (from token) | + +### Indexes + +| Index | Type | Purpose | +| ------------------------------ | -------- | ---------------------------- | +| `(userId, programId)` | Unique | Prevent duplicate mappings | +| `(programId, status)` | Compound | Filter by program and status | +| `(tenantId, orgId)` | Compound | Multi-tenancy filtering | +| `(tenantId, orgId, programId)` | Compound | Combined filtering | +| `(createdBy, programId)` | Compound | Filter by creator | +| `(userId, status)` | Compound | Filter user by status | + +--- + +## Status Flow + +### Available Statuses + +``` +NOT_ONBOARDED → ONBOARDED → IN_PROGRESS → COMPLETED → GRADUATED + ↓ ↓ ↓ + DROPPED_OUT DROPPED_OUT DROPPED_OUT +``` + +### Status Values + +| Status | Description | +| --------------- | -------------------------------------------- | +| `NOT_ONBOARDED` | Initial state when user is mapped to program | +| `ONBOARDED` | User has completed onboarding | +| `IN_PROGRESS` | User is actively working on the program | +| `COMPLETED` | User has completed all requirements | +| `GRADUATED` | User has graduated (terminal state) | +| `DROPPED_OUT` | User has dropped out (terminal state) | + +### Transition Rules + +1. **Sequential Progress**: Status must follow the order: `NOT_ONBOARDED → ONBOARDED → IN_PROGRESS → COMPLETED → GRADUATED` +2. **No Rollback**: Cannot transition to a previous status +3. **No Skipping**: Cannot skip intermediate statuses +4. **DROPPED_OUT**: Can transition to `DROPPED_OUT` from any status except `GRADUATED` +5. **Terminal States**: `GRADUATED` and `DROPPED_OUT` are terminal - no further transitions allowed + +### Valid Transitions + +| Current Status | Valid Next Statuses | +| -------------- | ------------------------ | +| NOT_ONBOARDED | ONBOARDED, DROPPED_OUT | +| ONBOARDED | IN_PROGRESS, DROPPED_OUT | +| IN_PROGRESS | COMPLETED, DROPPED_OUT | +| COMPLETED | GRADUATED, DROPPED_OUT | +| GRADUATED | (none - terminal) | +| DROPPED_OUT | (none - terminal) | + +--- + +## API Endpoints + +### Base URL + +``` +/project/v1/program/users +``` + +### Authentication + +All endpoints require authentication via token in header: + +``` +x-auth-token: +``` + +The token is decoded to extract: + +- `userId` - User identifier +- `tenantId` - Tenant identifier +- `organizationId` (orgId) - Organization identifier + +### Endpoints Summary + +| Method | Endpoint | Description | +| ------ | -------------------------- | ---------------------------------------------------- | +| POST | `/create` | Create new program user | +| PATCH | `/update/:_id` | Update program user (status, metadata, other fields) | +| GET | `/read/:_id` | Get program user by ID | +| GET | `/read/:programId/:userId` | Read particular program user by program and user ID | +| POST | `/list` | List program users with filters | +| DELETE | `/delete/:_id` | Delete program user | +| GET | `/getByProgramId/:_id` | Get users by program ID | + +--- + +## Sample Requests + +### 1. Create Program User + +```bash +POST /project/v1/program/users/create +Headers: + x-auth-token: + Content-Type: application/json + +Body: +{ + "programId": "507f1f77bcf86cd799439012", + "userId": "user-uuid-123", // Optional - uses token userId if not provided + "userProfile": { + "firstName": "John", + "lastName": "Doe", + "email": "john@example.com" + }, + "userRoleInformation": { + "role": "teacher" + }, + "status": "NOT_ONBOARDED", // Optional - defaults to NOT_ONBOARDED + "metadata": {} // Optional - empty by default +} + +Response: +{ + "success": true, + "message": "Program user created successfully", + "result": { + "_id": "507f1f77bcf86cd799439011", + "programId": "507f1f77bcf86cd799439012", + "userId": "user-uuid-123", + "status": "NOT_ONBOARDED", + ... + } +} +``` + +### 2. Update (Status or Metadata) + +Note: The separate `updateStatus` API has been removed. Use the consolidated `PATCH /project/v1/program/users/update/:_id` endpoint for status and metadata updates. + +Use the single update endpoint to modify status, metadata, or other mutable fields. When changing `status`, the request MUST include a non-empty `statusReason` string explaining the reason for the change. + +Behavior details: + +- **Status validation & bypass:** Status transitions are validated to ensure they follow the allowed flow (no rollback, no skipping). By default, invalid transitions are rejected. However, the API allows controlled bypasses: + + - **Admins / Tenant Admins:** bypass validation automatically. + - **Supervisors:** may bypass only when the request includes `"force": true` in the body. Use bypasses sparingly and with audit controls. + +- **Metadata updates (deep merge behavior):** When `metadata` is provided in the `PATCH /update/:_id` body, the server performs a **deep recursive merge** between the existing `metadata` object and the incoming `metadata` object. This means: + + - **Nested objects** are merged recursively at all levels. + - **Arrays and primitives** are replaced (not merged) with the incoming values. + - **Other categories** in metadata are preserved untouched. + + **Example:** If existing metadata has `{ externalIdOfBoardingCompletionCategory: {...}, externalIdOfAnotherCategory: {...} }` and you send `{ externalIdOfBoardingCompletionCategory: { tasks: [...newTasks] } }`, the result will merge the tasks into the existing boarding category while preserving the other category and all other fields. + +- **Audit recommendation:** Because bypasses allow skipping status validation, it is recommended to record an audit entry whenever a bypass occurs. Useful audit fields: `performedBy`, `performedByRoles`, `prevStatus`, `newStatus`, `statusReason`, `force` (true/false), and `timestamp`. + +```bash +PATCH /project/v1/program/users/update/507f1f77bcf86cd799439011 +Headers: + x-auth-token: + Content-Type: application/json + +Body (update status): +{ + "status": "ONBOARDED", + "statusReason": "Completed onboarding process" +} + +Body (update metadata): +{ + "metadata": { + "externalIdOfBoardingCompletionCategory": { + "templateExternalId": "onboarding-template-001", + "tasks": [ + { "taskId": "task-001", "completed": true, "completedAt": "2024-12-18T10:00:00Z" } + ] + } + } +} + +Response (Success): +{ + "success": true, + "message": "Program user updated successfully", + "result": { + "_id": "507f1f77bcf86cd799439011", + "status": "ONBOARDED", + ... + } +} +``` + +### 3. List Program Users + +Supports both page-based and offset-based pagination. + +#### Query Parameters (Pagination Only) + +- `page`: Page number (default: 1) - **use for page-based pagination** +- `limit`: Items per page (default: 10, max: 100) +- `offset`: Number of documents to skip (default: 0) - **use for offset-based pagination instead of page** + +#### Filter Parameters (Pass in POST Body) + +- `programId`: Filter by program ID +- `userId`: Filter by user ID +- `status`: Filter by status (supports single or multiple comma-separated values) +- `createdBy`: Filter by creator +- `templateExternalId`: Filter by template external ID (supports single or multiple comma-separated values) + +**Note:** `orgId` is automatically extracted from the authentication token and cannot be overridden. + +#### Basic List with Page-Based Pagination + +```bash +POST /project/v1/program/users/list?page=1&limit=10 +Headers: + x-auth-token: + Content-Type: application/json + +Body: +{ + "programId": "507f1f77bcf86cd799439012", + "status": "ONBOARDED" +} + +Response: +{ + "success": true, + "message": "Program users fetched successfully", + "result": [...], + "count": 100, + "totalCount": 100, + "page": 1, + "limit": 10, + "totalPages": 10 +} +``` + +#### List with Offset-Based Pagination + +```bash +POST /project/v1/program/users/list?offset=0&limit=10 +Headers: + x-auth-token: + Content-Type: application/json + +Body: +{ + "programId": "507f1f77bcf86cd799439012" +} + +Response: +{ + "success": true, + "message": "Program users fetched successfully", + "result": [...], + "count": 100, + "totalCount": 100, + "page": 1, + "limit": 10, + "offset": 0, + "totalPages": 10 +} +``` + +#### Filter by Single Status + +```bash +POST /project/v1/program/users/list?page=1&limit=10 +Headers: + x-auth-token: + Content-Type: application/json + +Body: +{ + "programId": "507f1f77bcf86cd799439012", + "status": "ONBOARDED" +} +``` + +#### Filter by Multiple Statuses + +```bash +POST /project/v1/program/users/list?page=1&limit=10 +Headers: + x-auth-token: + Content-Type: application/json + +Body: +{ + "programId": "507f1f77bcf86cd799439012", + "status": "ONBOARDED,IN_PROGRESS,COMPLETED" +} +``` + +#### Filter by Single templateExternalId + +The `templateExternalId` filter allows you to search for program users based on the templates they have in their metadata. Pass as an array or string format. + +**Array format (recommended):** + +```bash +POST /project/v1/program/users/list?page=1&limit=10 +Headers: + x-auth-token: + Content-Type: application/json + +Body: +{ + "programId": "507f1f77bcf86cd799439012", + "templateExternalId": ["onboarding-template-002"] +} +``` + +**String format:** + +```bash +POST /project/v1/program/users/list?page=1&limit=10 +Headers: + x-auth-token: + Content-Type: application/json + +Body: +{ + "programId": "507f1f77bcf86cd799439012", + "templateExternalId": "onboarding-template-002" +} +``` + +#### Filter by Multiple templateExternalIds + +**Array format (recommended):** + +```bash +POST /project/v1/program/users/list?page=1&limit=10 +Headers: + x-auth-token: + Content-Type: application/json + +Body: +{ + "programId": "507f1f77bcf86cd799439012", + "templateExternalId": ["onboarding-template-002", "onboarding-template-003"] +} +``` + +**Comma-separated string format:** + +```bash +POST /project/v1/program/users/list?page=1&limit=10 +Headers: + x-auth-token: + Content-Type: application/json + +Body: +{ + "programId": "507f1f77bcf86cd799439012", + "templateExternalId": "onboarding-template-002,onboarding-template-003" +} +``` + +#### Combination of Multiple Filters + +```bash +POST /project/v1/program/users/list?page=1&limit=10 +Headers: + x-auth-token: + Content-Type: application/json + +Body: +{ + "programId": "507f1f77bcf86cd799439012", + "status": "ONBOARDED,IN_PROGRESS", + "templateExternalId": ["onboarding-template-002", "training-template-001"], + "userId": "user-uuid-1223" +} +``` + +#### Response Example + +```json +{ + "success": true, + "message": "Program users fetched successfully", + "result": [ + { + "_id": "6944e556ba76a72c3bb73003", + "programId": "507f1f77bcf86cd799439012", + "userId": "user-uuid-1223", + "status": "ONBOARDED", + "metadata": { + "externalIdOfBoardingCompletionCategory": { + "templateExternalId": "onboarding-template-002", + "tasks": [ + { + "taskId": "task-001", + "completed": true, + "completedAt": "2024-12-18T10:00:00Z" + } + ] + } + } + } + ], + "count": 50, + "totalCount": 50, + "page": 1, + "limit": 10, + "totalPages": 5 +} +``` + +### 5. Delete Program User (Standard DELETE Pattern) + +```bash +DELETE /project/v1/program/users/delete/507f1f77bcf86cd799439011 +Headers: + x-auth-token: + +Path Parameters: + - _id: Program user ID to delete + +Response (Success): +{ + "success": true, + "message": "Program user resource deleted successfully", + "result": { + "_id": "507f1f77bcf86cd799439011", + "deletedAt": "2024-12-18T10:30:00Z" + } +} + +Response (Not Found): +{ + "success": false, + "status": 404, + "message": "Program user not found or already deleted" +} +``` + +--- + +## Metadata Structure + +The `metadata` field supports hierarchical tracking of categories, templates, and tasks: + +```javascript +{ + "metadata": { + // Level 0 Category - Onboarding + "externalIdOfBoardingCompletionCategory": { + "templateExternalId": "template-123", + "tasks": [ + { "taskId": "ObjectId", "completed": true, "completedAt": "ISO Date" } + ] + }, + + // Level 0 Category - Pathways (with nested subcategories) + "externalIdPathwayCategory": { + "templateExternalId": "template-456", + "tasks": [...], + + // Level 1 - Social Empowerment + "externalIdSocialEmpowermentCategory": { + "templateExternalId": "template-789", + "tasks": [...] + }, + + // Level 1 - Livelihoods (with nested) + "externalIdLivelihoodsCategory": { + "templateExternalId": "template-abc", + "tasks": [...], + + // Level 2 - Agriculture + "externalIdAgricultureCategory": { + "templateExternalId": "template-def", + "tasks": [...], + + // Level 3 - Crop Farming + "externalIdCropFarmingCategory": { + "templateExternalId": "template-ghi", + "tasks": [...] + } + } + } + }, + + // Observation Info + "observationInfo": { + "midlineSurveyCount": 0, + "midlineSurveyComplete": false, + "graduationReadinessSurveyComplete": false, + "endlineSurveyComplete": false + }, + + // Certificate Info + "certificateInfo": { + "certificateIssued": false, + "certificateIssuedDate": null + } + } +} +``` + +--- + +## Error Handling + +### Common Error Responses + +| Status Code | Message | Description | +| ----------- | ----------------------------------- | ---------------------------- | +| 400 | Program user ID is required | Missing \_id parameter | +| 400 | User already exists in this program | Duplicate userId + programId | +| 400 | Invalid status transition | Status rules violated | +| 404 | Program user not found | No matching document | +| 500 | Internal server error | Unexpected error | + +### Status Transition Errors + +```json +{ + "success": false, + "status": 400, + "message": "Rollback not allowed. Cannot transition from ONBOARDED to NOT_ONBOARDED. Status must progress forward.", + "data": { + "currentStatus": "ONBOARDED", + "attemptedStatus": "NOT_ONBOARDED", + "validNextStatuses": ["IN_PROGRESS", "DROPPED_OUT"] + } +} +``` + +--- + +## Files Structure + +``` +project-service/ +├── models/ +│ └── programUsers.js # Schema definition +├── databaseQueries/ +│ └── programUsers.js # Database operations +├── module/ +│ └── programUsers/ +│ ├── helper.js # Business logic +│ └── validator/ +│ └── v1.js # Request validation +├── controllers/ +│ └── v1/ +│ └── program/users.js # API endpoints (canonical) +├── generics/ +│ └── constants/ +│ └── api-responses.js # Response messages +└── document/ + └── programUsers/ + └── README.md # This documentation +``` + +--- + +## Version History + +| Version | Date | Changes | +| ------- | ---------- | --------------------------------------------------------------------- | +| 1.1.0 | 2024-12-18 | Added readByProgramAndUserId and deleteResource APIs | +| 1.0.0 | 2024-12-18 | Initial implementation with CRUD, status validation, metadata support | diff --git a/generics/constants/api-responses.js b/generics/constants/api-responses.js index 216e3eb3..cab89fe1 100644 --- a/generics/constants/api-responses.js +++ b/generics/constants/api-responses.js @@ -322,4 +322,28 @@ module.exports = { ACCESS_TOKEN_EXPIRED: 'Access Token Expired!! Please Login Again.', USER_SERVICE_DOWN_CODE: 'USER_SERVICE_DOWN', USER_SERVICE_DOWN: 'User service is down', + + // Program Users API Responses + PROGRAM_USER_CREATED: 'Program user created successfully', + PROGRAM_USER_NOT_CREATED: 'Failed to create program user', + PROGRAM_USER_UPDATED: 'Program user updated successfully', + PROGRAM_USER_NOT_UPDATED: 'Failed to update program user', + PROGRAM_USER_FETCHED: 'Program user fetched successfully', + PROGRAM_USER_NOT_FOUND: 'Program user not found', + PROGRAM_USER_DELETED: 'Program user deleted successfully', + PROGRAM_USER_NOT_DELETED: 'Failed to delete program user', + PROGRAM_USERS_FETCHED: 'Program users fetched successfully', + PROGRAM_USER_ALREADY_EXISTS: 'User already exists in this program', + PROGRAM_USER_ID_REQUIRED: 'Program user ID is required', + PROGRAM_ID_REQUIRED: 'Program ID is required', + STATUS_REQUIRED: 'Status is required', + STATUS_REASON_REQUIRED: 'Status reason is required', + METADATA_REQUIRED: 'Metadata is required', + PROGRAM_USER_STATUS_UPDATED: 'Program user status updated successfully', + PROGRAM_USER_STATUS_NOT_UPDATED: 'Failed to update program user status', + PROGRAM_USER_METADATA_UPDATED: 'Program user metadata updated successfully', + PROGRAM_USER_METADATA_NOT_UPDATED: 'Failed to update program user metadata', + STATUS_FLOW_FETCHED: 'Status flow fetched successfully', + MOCK_DATA_GENERATED: 'Mock data generated successfully', + INVALID_STATUS_TRANSITION: 'Invalid status transition', } diff --git a/generics/middleware/authenticator.js b/generics/middleware/authenticator.js index 33b9a9a8..d5aaf0d3 100644 --- a/generics/middleware/authenticator.js +++ b/generics/middleware/authenticator.js @@ -173,9 +173,21 @@ module.exports = async function (req, res, next, token = '') { // If using native authentication, verify the JWT using the secret key decodedToken = jwt.verify(token, process.env.ACCESS_TOKEN_SECRET) } catch (err) { - // If verification fails, send an unauthorized response - rspObj.errCode = CONSTANTS.apiResponses.TOKEN_MISSING_CODE - rspObj.errMsg = CONSTANTS.apiResponses.TOKEN_MISSING_MESSAGE + // If verification fails, send an unauthorized response with clear error message + // Set appropriate error message based on error type + if (err.name === 'TokenExpiredError') { + rspObj.errCode = 'ERR_TOKEN_EXPIRED' + rspObj.errMsg = 'Token has expired. Please login again to get a new token.' + } else if (err.name === 'JsonWebTokenError') { + rspObj.errCode = 'ERR_TOKEN_INVALID' + rspObj.errMsg = `Invalid token: ${err.message}. Please check ACCESS_TOKEN_SECRET matches the secret used to sign this token.` + } else if (err.name === 'NotBeforeError') { + rspObj.errCode = 'ERR_TOKEN_NOT_ACTIVE' + rspObj.errMsg = 'Token is not yet active.' + } else { + rspObj.errCode = 'ERR_TOKEN_VERIFICATION_FAILED' + rspObj.errMsg = `Token verification failed: ${err.message}` + } rspObj.responseCode = HTTP_STATUS_CODE['unauthorized'].status return res.status(HTTP_STATUS_CODE['unauthorized'].status).send(respUtil(rspObj)) } diff --git a/models/programUsers.js b/models/programUsers.js index b276a3b8..e0e48399 100644 --- a/models/programUsers.js +++ b/models/programUsers.js @@ -1,38 +1,121 @@ +/** + * name : programUsers.js + * author : Ankit Shahu + * created-date : 9-Jan-2023 + * Description : Program users schema with status tracking and hierarchical metadata + */ + module.exports = { - name: "programUsers", - schema: { - programId: { - type : "ObjectId", - required: true, - index: true - }, - userId: { - type: String, - index: true, - required: true, - }, - resourcesStarted: { - type: Boolean, - index: true, - default: false - }, - userProfile: { - type : Object, - required: true - }, - userRoleInformation: Object, - appInformation: Object, - consentShared: { - type: Boolean, - default: false - } - }, - compoundIndex: [ - { - "name" :{ userId: 1, programId: 1 }, - "indexType" : { unique: true } - } - ] -}; - - \ No newline at end of file + name: 'programUsers', + schema: { + programId: { + type: 'ObjectId', + required: true, + index: true, + }, + userId: { + type: String, + index: true, + required: true, + }, + resourcesStarted: { + type: Boolean, + index: true, + default: false, + }, + userProfile: { + type: Object, + required: true, + }, + userRoleInformation: Object, + appInformation: Object, + consentShared: { + type: Boolean, + default: false, + }, + metadata: { + type: Object, + default: {}, + }, + // User status in the program + status: { + type: String, + enum: ['NOT_ONBOARDED', 'ONBOARDED', 'IN_PROGRESS', 'COMPLETED', 'GRADUATED', 'DROPPED_OUT'], + default: 'NOT_ONBOARDED', + index: true, + }, + + // Previous status for tracking status transitions + prevStatus: { + type: String, + default: null, + }, + + // Reason for status change (e.g., dropout reason) + statusReason: { + type: String, + default: null, + }, + + // Created by user ID + createdBy: { + type: String, + required: true, + index: true, + }, + + // Updated by user ID + updatedBy: { + type: String, + required: true, + index: true, + }, + + // Tenant ID for multi-tenancy + tenantId: { + type: String, + required: true, + index: true, + }, + + // Organization ID + orgId: { + type: String, + required: true, + index: true, + }, + }, + + compoundIndex: [ + // Unique constraint on userId + programId combination + { + name: { userId: 1, programId: 1 }, + indexType: { unique: true }, + }, + // For filtering by program and status + { + name: { programId: 1, status: 1 }, + indexType: {}, + }, + // For filtering by tenant and org + { + name: { tenantId: 1, orgId: 1 }, + indexType: {}, + }, + // For filtering by tenant, org and program + { + name: { tenantId: 1, orgId: 1, programId: 1 }, + indexType: {}, + }, + // For filtering by createdBy + { + name: { createdBy: 1, programId: 1 }, + indexType: {}, + }, + // For filtering by userId and status + { + name: { userId: 1, status: 1 }, + indexType: {}, + }, + ], +} diff --git a/module/programUsers/helper.js b/module/programUsers/helper.js index 809a496f..883479b7 100644 --- a/module/programUsers/helper.js +++ b/module/programUsers/helper.js @@ -6,24 +6,154 @@ */ // Dependencies +const programUsersQueries = require(DB_QUERY_BASE_PATH + '/programUsers') +const ObjectId = require('mongodb').ObjectID + +/** + * Status order for validation + * Status can only progress forward, no rollback allowed + * DROPPED_OUT can happen from any state except GRADUATED + */ +const STATUS_ORDER = ['NOT_ONBOARDED', 'ONBOARDED', 'IN_PROGRESS', 'COMPLETED', 'GRADUATED', 'DROPPED_OUT'] /** * ProgramUsersHelper * @class */ -const programUsersQueries = require(DB_QUERY_BASE_PATH + '/programUsers') - module.exports = class ProgramUsersHelper { /** - * check if user joined a program or not and consentShared + * Deep merge two objects recursively. + * Used for metadata merging so that when a user sends partial metadata + * (e.g., only one category), it merges into existing metadata at all levels. + * @static + * @param {Object} target - existing object + * @param {Object} source - incoming object to merge + * @returns {Object} merged object + */ + static deepMerge(target, source) { + if (!source || typeof source !== 'object') return target + if (!target || typeof target !== 'object') return source + + const result = { ...target } + + Object.keys(source).forEach((key) => { + if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) { + // Recursively merge nested objects + result[key] = this.deepMerge(result[key] || {}, source[key]) + } else { + // For primitives and arrays, replace with source value + result[key] = source[key] + } + }) + + return result + } + + /** + * Validate status transition + * Rules: + * 1. Status must follow order: NOT_ONBOARDED → ONBOARDED → IN_PROGRESS → COMPLETED → GRADUATED + * 2. No rollback allowed (can't go backwards) + * 3. Can't skip states (e.g., NOT_ONBOARDED can't go directly to COMPLETED) + * 4. DROPPED_OUT can happen from any state except GRADUATED and DROPPED_OUT + * 5. Once GRADUATED or DROPPED_OUT, no further transitions allowed + * @method + * @name validateStatusTransition + * @param {String} currentStatus - Current status + * @param {String} newStatus - New status to transition to + * @returns {Object} { valid: boolean, message: string } + */ + static validateStatusTransition(currentStatus, newStatus) { + // If same status, no transition needed + if (currentStatus === newStatus) { + return { valid: true, message: 'No status change' } + } + + const currentIndex = STATUS_ORDER.indexOf(currentStatus) + const newIndex = STATUS_ORDER.indexOf(newStatus) + + // Invalid status values + if (currentIndex === -1 || newIndex === -1) { + return { valid: false, message: 'Invalid status value' } + } + + // DROPPED_OUT and GRADUATED are terminal states - no further transitions + if (currentStatus === 'DROPPED_OUT') { + return { valid: false, message: 'Cannot transition from DROPPED_OUT. It is a terminal state.' } + } + + if (currentStatus === 'GRADUATED') { + return { valid: false, message: 'Cannot transition from GRADUATED. It is a terminal state.' } + } + + // Allow DROPPED_OUT from any state except GRADUATED (already handled above) + if (newStatus === 'DROPPED_OUT') { + return { valid: true, message: 'Status transition to DROPPED_OUT is valid' } + } + + // Cannot transition to DROPPED_OUT as it's handled above, so check normal flow + // Exclude DROPPED_OUT from normal flow checks + const normalFlowStatuses = STATUS_ORDER.slice(0, 5) // Exclude DROPPED_OUT + const currentNormalIndex = normalFlowStatuses.indexOf(currentStatus) + const newNormalIndex = normalFlowStatuses.indexOf(newStatus) + + // Rollback not allowed + if (newNormalIndex < currentNormalIndex) { + return { + valid: false, + message: `Rollback not allowed. Cannot transition from ${currentStatus} to ${newStatus}. Status must progress forward.`, + } + } + + // Skip not allowed - must be exactly next status + if (newNormalIndex !== currentNormalIndex + 1) { + const expectedNextStatus = normalFlowStatuses[currentNormalIndex + 1] + return { + valid: false, + message: `Cannot skip status. Current: ${currentStatus}, Expected next: ${expectedNextStatus}, Attempted: ${newStatus}`, + } + } + + return { valid: true, message: 'Status transition is valid' } + } + + /** + * Get valid next statuses for a given current status + * @method + * @name getValidNextStatuses + * @param {String} currentStatus - Current status + * @returns {Array} Array of valid next statuses + */ + static getValidNextStatuses(currentStatus) { + const normalFlowStatuses = STATUS_ORDER.slice(0, 5) // Exclude DROPPED_OUT + const currentIndex = normalFlowStatuses.indexOf(currentStatus) + + if (currentStatus === 'DROPPED_OUT' || currentStatus === 'GRADUATED') { + return [] // Terminal states + } + + const validStatuses = [] + + // Next status in normal flow + if (currentIndex < normalFlowStatuses.length - 1) { + validStatuses.push(normalFlowStatuses[currentIndex + 1]) + } + + // DROPPED_OUT is always valid (except from GRADUATED which is handled above) + validStatuses.push('DROPPED_OUT') + + return validStatuses + } + + /** + * Check if user joined a program or not and consentShared * @method * @name checkForUserJoinedProgramAndConsentShared * @param {String} programId - Program Id. * @param {String} userId - User Id * @returns {Object} result. */ - static checkForUserJoinedProgramAndConsentShared(programId, userId) { return new Promise(async (resolve, reject) => { try { @@ -33,7 +163,7 @@ module.exports = class ProgramUsersHelper { programId: programId, } - //Check data present in programUsers collection. + // Check data present in programUsers collection. let programUsers = await programUsersQueries.programUsersDocument(query, ['_id', 'consentShared']) result.joinProgram = programUsers.length > 0 ? true : false result.consentShared = programUsers.length > 0 ? programUsers[0].consentShared : false @@ -43,4 +173,693 @@ module.exports = class ProgramUsersHelper { } }) } + + /** + * Create a new program user mapping. + * userId, tenantId, orgId are extracted from the decoded token (userDetails) + * @method + * @name create + * @param {Object} bodyData - request body data. + * @param {Object} userDetails - logged in user details (from decoded token). + * @returns {Object} created program user document. + */ + static create(bodyData, userDetails) { + return new Promise(async (resolve, reject) => { + try { + // Extract userId, tenantId, orgId from decoded token + const tokenUserId = userDetails.userInformation.userId + const tenantId = userDetails.userInformation.tenantId + const orgId = userDetails.userInformation.organizationId + + // Use userId from body if provided, else use from token + const userId = bodyData.userId || tokenUserId + + // Prepare program user data + const programUserData = { + programId: ObjectId(bodyData.programId), + userId: userId, + userProfile: bodyData.userProfile, + userRoleInformation: bodyData.userRoleInformation || {}, + appInformation: bodyData.appInformation || {}, + consentShared: bodyData.consentShared || false, + resourcesStarted: bodyData.resourcesStarted || false, + status: bodyData.status || 'NOT_ONBOARDED', + prevStatus: null, + statusReason: bodyData.statusReason || null, + // Metadata is optional - if passed use it, else keep empty + metadata: bodyData.metadata || {}, + tenantId: tenantId, + orgId: orgId, + createdBy: tokenUserId, + updatedBy: tokenUserId, + } + + // Check if user already exists in this program + const existingUser = await programUsersQueries.findOne({ + userId: userId, + programId: ObjectId(bodyData.programId), + }) + + if (existingUser && existingUser._id) { + return resolve({ + success: false, + status: HTTP_STATUS_CODE.bad_request.status, + message: CONSTANTS.apiResponses.PROGRAM_USER_ALREADY_EXISTS, + }) + } + + // Create the program user document + const createdProgramUser = await programUsersQueries.create(programUserData) + + if (!createdProgramUser || !createdProgramUser._id) { + return resolve({ + success: false, + status: HTTP_STATUS_CODE.bad_request.status, + message: CONSTANTS.apiResponses.PROGRAM_USER_NOT_CREATED, + }) + } + + return resolve({ + success: true, + message: CONSTANTS.apiResponses.PROGRAM_USER_CREATED, + data: createdProgramUser, + result: createdProgramUser, + }) + } catch (error) { + return resolve({ + success: false, + message: error.message, + status: HTTP_STATUS_CODE.internal_server_error.status, + }) + } + }) + } + + /** + * Update a program user mapping. + * @method + * @name update + * @param {String} _id - program user id. + * @param {Object} bodyData - request body data. + * @param {Object} userDetails - logged in user details (from decoded token). + * @returns {Object} updated program user document. + */ + static update(_id, bodyData, userDetails) { + return new Promise(async (resolve, reject) => { + try { + // Validate _id field + _id = _id === ':_id' ? null : _id + + if (!_id) { + return resolve({ + success: false, + status: HTTP_STATUS_CODE.bad_request.status, + message: CONSTANTS.apiResponses.PROGRAM_USER_ID_REQUIRED, + }) + } + + // Extract from decoded token + const tokenUserId = userDetails.userInformation.userId + const tenantId = userDetails.userInformation.tenantId + + // Build filter + const filter = { + _id: ObjectId(_id), + tenantId: tenantId, + } + + // Get current program user for status transition tracking + const currentProgramUser = await programUsersQueries.findOne(filter) + + if (!currentProgramUser || !currentProgramUser._id) { + return resolve({ + success: false, + status: HTTP_STATUS_CODE.not_found.status, + message: CONSTANTS.apiResponses.PROGRAM_USER_NOT_FOUND, + }) + } + + // Validate status transition if status is being changed + if (bodyData.status && bodyData.status !== currentProgramUser.status) { + // Ensure reason for status change is provided + if ( + !bodyData.statusReason || + (typeof bodyData.statusReason === 'string' && bodyData.statusReason.trim() === '') + ) { + return resolve({ + success: false, + status: HTTP_STATUS_CODE.bad_request.status, + message: CONSTANTS.apiResponses.STATUS_REASON_REQUIRED, + }) + } + // Allow override by admins or tenant admins, or by supervisors when explicit 'force' is provided + const roles = + (userDetails && userDetails.userInformation && userDetails.userInformation.roles) || [] + const isAdmin = + roles.includes(CONSTANTS.common.ADMIN_ROLE) || roles.includes(CONSTANTS.common.TENANT_ADMIN) + const isSupervisor = roles.some( + (r) => typeof r === 'string' && r.toLowerCase().includes('supervisor') + ) + const allowBypass = isAdmin || (isSupervisor && bodyData.force === true) + + if (!allowBypass) { + const validation = this.validateStatusTransition(currentProgramUser.status, bodyData.status) + if (!validation.valid) { + const validNextStatuses = this.getValidNextStatuses(currentProgramUser.status) + return resolve({ + success: false, + status: HTTP_STATUS_CODE.bad_request.status, + message: validation.message, + data: { + currentStatus: currentProgramUser.status, + attemptedStatus: bodyData.status, + validNextStatuses: validNextStatuses, + }, + }) + } + } else { + // Bypass allowed: continue and let updateData record prevStatus as usual + } + } + + // Prepare update data + const updateData = { $set: {} } + + // Prevent updating protected fields + delete bodyData.tenantId + delete bodyData.orgId + delete bodyData.createdBy + delete bodyData.programId + delete bodyData.userId + + // If status is being changed, track the previous status + if (bodyData.status && bodyData.status !== currentProgramUser.status) { + updateData.$set.prevStatus = currentProgramUser.status + } + + // Set updatedBy from token + updateData.$set.updatedBy = tokenUserId + + // Add remaining fields to update + // If metadata is provided, deep merge with existing metadata instead of replacing + if (bodyData.metadata && typeof bodyData.metadata === 'object') { + const existingMetadata = currentProgramUser.metadata || {} + const mergedMetadata = this.deepMerge(existingMetadata, bodyData.metadata) + updateData.$set.metadata = mergedMetadata + // remove from bodyData so it's not copied again + delete bodyData.metadata + } + Object.keys(bodyData).forEach((key) => { + updateData.$set[key] = bodyData[key] + }) + + const updatedProgramUser = await programUsersQueries.findOneAndUpdate(filter, updateData, { new: true }) + + if (!updatedProgramUser || !updatedProgramUser._id) { + return resolve({ + success: false, + status: HTTP_STATUS_CODE.bad_request.status, + message: CONSTANTS.apiResponses.PROGRAM_USER_NOT_UPDATED, + }) + } + + return resolve({ + success: true, + message: CONSTANTS.apiResponses.PROGRAM_USER_UPDATED, + data: updatedProgramUser, + result: updatedProgramUser, + }) + } catch (error) { + return resolve({ + success: false, + message: error.message, + status: HTTP_STATUS_CODE.internal_server_error.status, + }) + } + }) + } + + /** + * Get program user details by ID. + * @method + * @name read + * @param {String} _id - program user id. + * @param {Object} userDetails - logged in user details (from decoded token). + * @returns {Object} program user document. + */ + static read(_id, userDetails) { + return new Promise(async (resolve, reject) => { + try { + // Validate _id field + _id = _id === ':_id' ? null : _id + + if (!_id) { + return resolve({ + success: false, + status: HTTP_STATUS_CODE.bad_request.status, + message: CONSTANTS.apiResponses.PROGRAM_USER_ID_REQUIRED, + }) + } + + const tenantId = userDetails.userInformation.tenantId + + const filter = { + _id: ObjectId(_id), + tenantId: tenantId, + } + + const programUser = await programUsersQueries.findOne(filter) + + if (!programUser || !programUser._id) { + return resolve({ + success: false, + status: HTTP_STATUS_CODE.not_found.status, + message: CONSTANTS.apiResponses.PROGRAM_USER_NOT_FOUND, + }) + } + + // Add valid next statuses to response + programUser.validNextStatuses = this.getValidNextStatuses(programUser.status) + + return resolve({ + success: true, + message: CONSTANTS.apiResponses.PROGRAM_USER_FETCHED, + data: programUser, + result: programUser, + }) + } catch (error) { + return resolve({ + success: false, + message: error.message, + status: HTTP_STATUS_CODE.internal_server_error.status, + }) + } + }) + } + + /** + * List program users with filters and pagination. + * @method + * @name list + * @param {Object} req - Express request object with query, body, userDetails + * @returns {Object} paginated list of program users. + */ + static list(req) { + return new Promise(async (resolve, reject) => { + try { + // Extract from request + const queryParams = req.query || {} + const bodyData = req.body || {} + const userDetails = req.userDetails || {} + + // Extract pagination and filter params + const pageSize = req.pageSize || parseInt(queryParams.limit) || parseInt(bodyData?.limit) || 20 + const pageNo = req.pageNo || parseInt(queryParams.page) || parseInt(bodyData?.page) || 1 + const searchText = req.searchText || queryParams.search || bodyData?.search || '' + const sortData = queryParams.sort || bodyData?.sort || { createdAt: -1 } + + const tenantId = userDetails.userInformation.tenantId + + // 1. Build base filter query + const filter = { + tenantId: tenantId, + } + + // 2. Add filters from body + if (bodyData && Object.keys(bodyData).length > 0) { + Object.keys(bodyData).forEach((key) => { + if (['page', 'limit', 'offset', 'templateExternalId'].includes(key)) { + return + } + + if (key === 'programId') { + filter.programId = ObjectId(bodyData.programId) + } else if (key === 'status') { + const statusValue = bodyData.status + const statuses = + typeof statusValue === 'string' + ? statusValue.split(',').map((s) => s.trim()) + : Array.isArray(statusValue) + ? statusValue + : [statusValue] + + filter.status = statuses.length === 1 ? statuses[0] : { $in: statuses } + } else if (['userId', 'createdBy', 'orgId'].includes(key)) { + filter[key] = bodyData[key] + } else if ( + ![ + 'userProfile', + 'userRoleInformation', + 'appInformation', + 'consentShared', + 'resourcesStarted', + 'metadata', + 'prevStatus', + 'statusReason', + 'deleted', + 'tenantId', + 'updatedBy', + 'createdAt', + 'updatedAt', + ].includes(key) + ) { + filter[key] = bodyData[key] + } + }) + } + + // 3. Handle templateExternalId filter (The ObjectToArray Fix) + let templateExternalIds = [] + if (bodyData && bodyData.templateExternalId) { + const bodyValue = bodyData.templateExternalId + if (Array.isArray(bodyValue)) { + templateExternalIds = bodyValue.filter((id) => (id && id.trim ? id.trim() : id)).filter(Boolean) + } else if (typeof bodyValue === 'string') { + templateExternalIds = bodyValue + .split(',') + .map((id) => id.trim()) + .filter(Boolean) + } else { + templateExternalIds = [bodyValue] + } + } + + if (templateExternalIds.length > 0) { + /* Logic: Convert metadata object to array to ignore dynamic keys. + Matches templateExternalId at Level 1 or Level 2 of metadata. + */ + filter.$expr = { + $gt: [ + { + $size: { + $filter: { + input: { $objectToArray: { $ifNull: ['$metadata', {}] } }, + as: 'item', + cond: { + $or: [ + // Check Level 1: metadata.dynamicKey.templateExternalId + { $in: ['$$item.v.templateExternalId', templateExternalIds] }, + // Check Level 2: metadata.dynamicKey.subDynamicKey.templateExternalId + { + $anyElementTrue: { + $map: { + input: { + $cond: [ + { $eq: [{ $type: '$$item.v' }, 'object'] }, + { $objectToArray: '$$item.v' }, + [], + ], + }, + as: 'subItem', + in: { + $in: [ + '$$subItem.v.templateExternalId', + templateExternalIds, + ], + }, + }, + }, + }, + ], + }, + }, + }, + }, + 0, + ], + } + } + + // 4. Execute Query + const finalSort = typeof sortData === 'string' ? { createdAt: -1 } : sortData || { createdAt: -1 } + const result = await programUsersQueries.list(filter, 'all', 'none', pageNo, pageSize, finalSort) + + return resolve({ + success: true, + message: 'Program users fetched successfully', + data: result.data, + result: result.data, + count: result.totalCount, + totalCount: result.totalCount, + page: result.page, + limit: result.limit, + totalPages: result.totalPages, + }) + } catch (error) { + console.error('List Error:', error) + return resolve({ + success: false, + message: error.message, + status: 500, + }) + } + }) + } + + /** + * Delete a program user mapping. + * @method + * @name delete + * @param {String} _id - program user id. + * @param {Object} userDetails - logged in user details (from decoded token). + * @returns {Object} deletion result. + */ + static delete(_id, userDetails) { + return new Promise(async (resolve, reject) => { + try { + // Validate _id field + _id = _id === ':_id' ? null : _id + + if (!_id) { + return resolve({ + success: false, + status: HTTP_STATUS_CODE.bad_request.status, + message: CONSTANTS.apiResponses.PROGRAM_USER_ID_REQUIRED, + }) + } + + const tenantId = userDetails.userInformation.tenantId + + const filter = { + _id: ObjectId(_id), + tenantId: tenantId, + } + + // Check if the program user exists + const programUser = await programUsersQueries.findOne(filter) + + if (!programUser || !programUser._id) { + return resolve({ + success: false, + status: HTTP_STATUS_CODE.not_found.status, + message: CONSTANTS.apiResponses.PROGRAM_USER_NOT_FOUND, + }) + } + + // Delete the program user + const deleteResult = await programUsersQueries.deleteOne(filter) + + if (deleteResult.deletedCount === 0) { + return resolve({ + success: false, + status: HTTP_STATUS_CODE.bad_request.status, + message: CONSTANTS.apiResponses.PROGRAM_USER_NOT_DELETED, + }) + } + + return resolve({ + success: true, + message: CONSTANTS.apiResponses.PROGRAM_USER_DELETED, + data: { _id: _id }, + }) + } catch (error) { + return resolve({ + success: false, + message: error.message, + status: HTTP_STATUS_CODE.internal_server_error.status, + }) + } + }) + } + + /** + * Get program users by program ID. + * @method + * @name getByProgramId + * @param {String} programId - program id. + * @param {Object} queryParams - query parameters. + * @param {Object} userDetails - logged in user details (from decoded token). + * @returns {Object} list of program users. + */ + static getByProgramId(programId, queryParams, userDetails) { + return new Promise(async (resolve, reject) => { + try { + if (!programId || programId === ':_id') { + return resolve({ + success: false, + status: HTTP_STATUS_CODE.bad_request.status, + message: CONSTANTS.apiResponses.PROGRAM_ID_REQUIRED, + }) + } + + const tenantId = userDetails.userInformation.tenantId + + const filter = { + programId: ObjectId(programId), + tenantId: tenantId, + } + + // Add optional status filter + if (queryParams.status) { + filter.status = queryParams.status + } + + // Pagination + const page = parseInt(queryParams.page) || 1 + const limit = parseInt(queryParams.limit) || 10 + + const result = await programUsersQueries.list(filter, 'all', 'none', page, limit, { createdAt: -1 }) + + return resolve({ + success: true, + message: CONSTANTS.apiResponses.PROGRAM_USERS_FETCHED, + data: result.data, + result: result.data, + count: result.totalCount, + totalCount: result.totalCount, + page: result.page, + limit: result.limit, + totalPages: result.totalPages, + }) + } catch (error) { + return resolve({ + success: false, + message: error.message, + status: HTTP_STATUS_CODE.internal_server_error.status, + }) + } + }) + } + + /** + * Read a program user by program ID and user ID + * @method + * @name readByProgramAndUserId + * @param {String} programId - Program ID + * @param {String} userId - User ID + * @param {Object} userDetails - logged in user details + * @returns {JSON} - program user details + */ + static readByProgramAndUserId(programId, userId, userDetails) { + return new Promise(async (resolve, reject) => { + try { + if (!programId) { + throw { + status: HTTP_STATUS_CODE.bad_request.status, + message: 'Program ID is required', + } + } + + if (!userId) { + throw { + status: HTTP_STATUS_CODE.bad_request.status, + message: 'User ID is required', + } + } + + let filterData = { + programId: new ObjectId(programId), + userId: userId, + tenantId: userDetails.userInformation.tenantId, + orgId: userDetails.userInformation.organizationId, + } + + let programUser = await programUsersQueries.findOne(filterData) + + if (!programUser || !programUser._id) { + throw { + status: HTTP_STATUS_CODE.not_found.status, + message: 'Program user not found', + } + } + + return resolve({ + success: true, + message: CONSTANTS.apiResponses.PROGRAM_USER_FETCHED, + data: programUser, + result: programUser, + }) + } catch (error) { + return resolve({ + status: error.status || HTTP_STATUS_CODE.internal_server_error.status, + message: error.message || HTTP_STATUS_CODE.internal_server_error.message, + errorObject: error, + }) + } + }) + } + + /** + * Delete a program user resource + * @method + * @name deleteResource + * @param {String} _id - Program user ID to delete + * @param {Object} userDetails - logged in user details + * @returns {JSON} - deletion response + */ + static deleteResource(_id, userDetails) { + return new Promise(async (resolve, reject) => { + try { + if (!_id) { + throw { + status: HTTP_STATUS_CODE.bad_request.status, + message: 'Program user ID is required', + } + } + + let filterData = { + _id: new ObjectId(_id), + tenantId: userDetails.userInformation.tenantId, + orgId: userDetails.userInformation.organizationId, + } + + let programUser = await programUsersQueries.findOne(filterData) + + if (!programUser || !programUser._id) { + throw { + status: HTTP_STATUS_CODE.not_found.status, + message: 'Program user not found', + } + } + + let deleteResult = await programUsersQueries.deleteOne(filterData) + + if (deleteResult.deletedCount === 0) { + throw { + status: HTTP_STATUS_CODE.not_found.status, + message: 'Program user not found or already deleted', + } + } + + return resolve({ + success: true, + message: CONSTANTS.apiResponses.PROGRAM_USER_DELETED, + data: { + _id: _id, + deletedAt: new Date().toISOString(), + }, + result: { + _id: _id, + deletedAt: new Date().toISOString(), + }, + }) + } catch (error) { + return resolve({ + status: error.status || HTTP_STATUS_CODE.internal_server_error.status, + message: error.message || HTTP_STATUS_CODE.internal_server_error.message, + errorObject: error, + }) + } + }) + } } diff --git a/module/programUsers/validator/v1.js b/module/programUsers/validator/v1.js new file mode 100644 index 00000000..dd2272d9 --- /dev/null +++ b/module/programUsers/validator/v1.js @@ -0,0 +1,197 @@ +/** + * name : v1.js + * author : System + * created-date : 18-Dec-2024 + * Description : Validators for program users API endpoints. + */ + +module.exports = (req) => { + let programUsersValidator = { + /** + * Validator for create endpoint + */ + create: function () { + req.checkBody('programId') + .exists() + .withMessage('programId is required') + .notEmpty() + .withMessage('programId cannot be empty') + .isMongoId() + .withMessage('programId must be a valid MongoDB ObjectId') + + // userId is optional - if not provided, will use token userId + if (req.body.userId) { + req.checkBody('userId') + .notEmpty() + .withMessage('userId cannot be empty') + .isString() + .withMessage('userId must be a string') + } + + req.checkBody('userProfile') + .exists() + .withMessage('userProfile is required') + .notEmpty() + .withMessage('userProfile cannot be empty') + + // Status is optional but if provided must be valid + if (req.body.status) { + req.checkBody('status') + .isIn(['NOT_ONBOARDED', 'ONBOARDED', 'IN_PROGRESS', 'COMPLETED', 'GRADUATED', 'DROPPED_OUT']) + .withMessage( + 'status must be one of: NOT_ONBOARDED, ONBOARDED, IN_PROGRESS, COMPLETED, GRADUATED, DROPPED_OUT' + ) + + // Require reason for status change + req.checkBody('statusReason') + .exists() + .withMessage('statusReason is required') + .notEmpty() + .withMessage('statusReason cannot be empty') + .isString() + .withMessage('statusReason must be a string') + } + + // Metadata is optional + if (req.body.metadata) { + req.checkBody('metadata') + .custom((val) => typeof val === 'object' && !Array.isArray(val) && val !== null) + .withMessage('metadata must be an object') + } + + // ConsentShared is optional but if provided must be boolean + if (req.body.consentShared !== undefined) { + req.checkBody('consentShared').isBoolean().withMessage('consentShared must be a boolean') + } + }, + + /** + * Validator for update endpoint + */ + update: function () { + req.checkParams('_id') + .exists() + .withMessage('_id is required') + .notEmpty() + .withMessage('_id cannot be empty') + .isMongoId() + .withMessage('_id must be a valid MongoDB ObjectId') + + // Status is optional but if provided must be valid + if (req.body.status) { + req.checkBody('status') + .isIn(['NOT_ONBOARDED', 'ONBOARDED', 'IN_PROGRESS', 'COMPLETED', 'GRADUATED', 'DROPPED_OUT']) + .withMessage( + 'status must be one of: NOT_ONBOARDED, ONBOARDED, IN_PROGRESS, COMPLETED, GRADUATED, DROPPED_OUT' + ) + + // Require reason for status change + req.checkBody('statusReason') + .exists() + .withMessage('statusReason is required') + .notEmpty() + .withMessage('statusReason cannot be empty') + .isString() + .withMessage('statusReason must be a string') + } + + // Metadata is optional + if (req.body.metadata) { + // FIXED: Replaced .isObject() with .custom() + req.checkBody('metadata') + .custom((val) => typeof val === 'object' && !Array.isArray(val) && val !== null) + .withMessage('metadata must be an object') + } + }, + + /** + * Validator for read endpoint + */ + read: function () { + req.checkParams('_id') + .exists() + .withMessage('_id is required') + .notEmpty() + .withMessage('_id cannot be empty') + .isMongoId() + .withMessage('_id must be a valid MongoDB ObjectId') + }, + + /** + * Validator for list endpoint + */ + list: function () { + // All query params are optional for list + if (req.query.programId) { + req.checkQuery('programId').isMongoId().withMessage('programId must be a valid MongoDB ObjectId') + } + + if (req.query.status) { + req.checkQuery('status') + .isIn(['NOT_ONBOARDED', 'ONBOARDED', 'IN_PROGRESS', 'COMPLETED', 'GRADUATED', 'DROPPED_OUT']) + .withMessage( + 'status must be one of: NOT_ONBOARDED, ONBOARDED, IN_PROGRESS, COMPLETED, GRADUATED, DROPPED_OUT' + ) + } + + if (req.query.page) { + req.checkQuery('page').isInt({ min: 1 }).withMessage('page must be a positive integer') + } + + if (req.query.limit) { + req.checkQuery('limit') + .isInt({ min: 1, max: 100 }) + .withMessage('limit must be a positive integer between 1 and 100') + } + }, + + /** + * Validator for delete endpoint + */ + delete: function () { + req.checkParams('_id') + .exists() + .withMessage('_id is required') + .notEmpty() + .withMessage('_id cannot be empty') + .isMongoId() + .withMessage('_id must be a valid MongoDB ObjectId') + }, + + /** + * Validator for getByProgramId endpoint + */ + getByProgramId: function () { + req.checkParams('_id') + .exists() + .withMessage('programId is required') + .notEmpty() + .withMessage('programId cannot be empty') + .isMongoId() + .withMessage('programId must be a valid MongoDB ObjectId') + + if (req.query.status) { + req.checkQuery('status') + .isIn(['NOT_ONBOARDED', 'ONBOARDED', 'IN_PROGRESS', 'COMPLETED', 'GRADUATED', 'DROPPED_OUT']) + .withMessage( + 'status must be one of: NOT_ONBOARDED, ONBOARDED, IN_PROGRESS, COMPLETED, GRADUATED, DROPPED_OUT' + ) + } + + if (req.query.page) { + req.checkQuery('page').isInt({ min: 1 }).withMessage('page must be a positive integer') + } + + if (req.query.limit) { + req.checkQuery('limit') + .isInt({ min: 1, max: 100 }) + .withMessage('limit must be a positive integer between 1 and 100') + } + }, + } + + // Execute the validator for the current method + if (programUsersValidator[req.params.method]) { + programUsersValidator[req.params.method]() + } +}