From 5b9012d3147b61faa401142d0aaddd8076c42a48 Mon Sep 17 00:00:00 2001 From: laxmanhalaki Date: Thu, 5 Feb 2026 21:01:31 +0530 Subject: [PATCH] fixed smaller issues like undeifned in mails worknote refresh and also some dealer claim rlated changes made stable for the non-templatized save as draft changes --- docs/MONGODB_V8_READINESS.md | 41 ++++++ docs/UNIFIED_REQUEST_ARCHITECTURE.md | 75 +++++++++++ src/controllers/conclusion.controller.ts | 117 ++--------------- src/controllers/pause.controller.ts | 37 +++++- src/controllers/workflow.controller.ts | 61 ++++++++- src/controllers/worknote.controller.ts | 22 ++-- src/models/mongoose/Dealer.schema.ts | 8 +- src/scripts/seed-test-dealer.mongo.ts | 76 +++++++---- src/scripts/verify-dealer-id-gen.ts | 40 ++++++ src/services/approval.service.ts | 27 +++- src/services/conclusion.service.ts | 137 ++++++++++++++++++++ src/services/dealerClaim.service.ts | 12 +- src/services/dealerClaimApproval.service.ts | 17 ++- src/services/emailNotification.service.ts | 43 +++--- src/services/notification.service.ts | 2 +- src/services/pause.service.ts | 54 ++++---- src/services/summary.service.ts | 4 + src/services/workflow.service.ts | 40 +++++- src/services/worknote.service.ts | 68 ++++++---- src/validators/workflow.validator.ts | 1 + verify_request.ts | 39 ++++++ 21 files changed, 687 insertions(+), 234 deletions(-) create mode 100644 docs/MONGODB_V8_READINESS.md create mode 100644 docs/UNIFIED_REQUEST_ARCHITECTURE.md create mode 100644 src/scripts/verify-dealer-id-gen.ts create mode 100644 src/services/conclusion.service.ts create mode 100644 verify_request.ts diff --git a/docs/MONGODB_V8_READINESS.md b/docs/MONGODB_V8_READINESS.md new file mode 100644 index 0000000..1bc92a8 --- /dev/null +++ b/docs/MONGODB_V8_READINESS.md @@ -0,0 +1,41 @@ +# MongoDB Atlas v8.0 Readiness Update + +**Date**: 2026-02-05 +**Project**: Royal Enfield Workflow Management System +**Subject**: Technical Audit and Readiness for MongoDB v8.0 Upgrade + +## Executive Summary +Following a comprehensive technical audit of the Workflow Management System backend, we have confirmed that the application layer is fully compatible with MongoDB Atlas v8.0. The current stack (Node.js 22, Mongoose 9) is optimized for the v8 engine, and the codebase has been verified to be free of any deprecated legacy features. + +## 💻 Tech Stack Compatibility + +| Component | Version | Readiness Status | +| :--- | :--- | :--- | +| **Node.js Runtime** | v22.x | Fully Compatible | +| **Mongoose ODM** | v9.1.5 | Native v8.0 Support | +| **Connection Driver** | MongoDB Node.js Driver v6+ equivalent | Verified | + +## 🔍 Codebase Audit Results + +### 1. Feature Deprecation Check +We have verified that the following legacy features, removed in v8.0, are **not used** in our codebase: +- **Map-Reduce**: All reporting and KPI logic has been migrated to the modern Aggregation Pipeline. +- **Legacy Group Command**: Using `$group` within aggregation pipelines instead. +- **$where Operator**: All dynamic queries have been refactored to use `$expr` or standard filters to improve performance and security. +- **geoHaystack Indexes**: Not utilized in the project. + +### 2. Connection Strategy +Our connection logic is designed for resilient SRV connectivity: +- Implements DNS resolution workarounds for reliable Atlas SRV lookups. +- Configured with robust timeout and selection parameters. + +## 🚀 Post-Upgrade Optimization Roadmap + +Once the cluster is upgraded to v8.0, the application team recommends the following optimizations: + +1. **Atlas Search Integration**: Migrate full-text search requirements from standard regex to Lucene-based Atlas Search. +2. **Encryption**: Evaluate **Queryable Encryption** for enhanced protection of sensitive workflow data. +3. **Performance Advisor**: Review Atlas Performance Advisor recommendations for any new compound index opportunities enabled by the v8 engine's improved query optimizer. + +## ✅ Conclusion +The application is **ready for upgrade**. No blockers have been identified in the current production codebase. diff --git a/docs/UNIFIED_REQUEST_ARCHITECTURE.md b/docs/UNIFIED_REQUEST_ARCHITECTURE.md new file mode 100644 index 0000000..b20a93c --- /dev/null +++ b/docs/UNIFIED_REQUEST_ARCHITECTURE.md @@ -0,0 +1,75 @@ +# Analysis: Dealer Claim & Unified Request Architecture + +This document analyzes the current architecture and proposes an efficient approach to unify Dealer Claims and Custom Requests while supporting specialized data capture and versioning. + +## Current State + +Both **Custom Requests** and **Dealer Claims** are already "unified" at the base level: +- **Primary Collection**: `workflow_requests` stores the core data (id, requestNumber, initiator, status, currentLevel). +- **Secondary Collection**: `dealer_claims` stores the business-specific metadata (proposal, expenses, invoices, etc.) and is linked via `requestId`. + +This architecture naturally supports showing both in the same list. + +## Proposed Efficient Approach + +To make these two paths truly "inline" and handle specialized steps efficiently, we recommend a **Metadata-Driven Activity System**. + +### 1. Unified Listing +The UI should continue to use the existing `listWorkflows` endpoints. The backend already returns `templateType`, which the frontend can use to decide which icon or detail view to render. + +### 2. Specialized Step Identification (Dual-Tag System) +To handle dynamic level shifts and accurately recognize the purpose of each step, we use two categories of tags on each `ApprovalLevel`. + +#### Category A: Action Tags (`stepAction`) +Defines **what** special behavior is required in this step. +- `DEALER_PROPOSAL`: Show proposal submission form. +- `EXPENSE_CAPTURE`: Show expense document upload form. +- `PROPOSAL_EVALUATION`: Show evaluation tools for the initiator/manager. +- `NONE`: Standard approve/reject UI. + +#### Category B: Persona Tags (`stepPersona`) +Defines **who** is acting in this step (role-based logic). +- `INITIATOR`: Used when the initiator acts as an approver (e.g., evaluating a dealer proposal). +- `DEPARTMENT_LEAD`: Standard leadership approval. +- `ADDITIONAL_APPROVER`: Distinguishes steps added manually from the template. + +#### How it works together: +| Level | Level Name | `stepAction` | `stepPersona` | UI Behavior | +| :--- | :--- | :--- | :--- | :--- | +| **1** | Dealer Proposal | `DEALER_PROPOSAL` | `DEALER` | Full Proposal Form | +| **2** | Initiator Review | `PROPOSAL_EVALUATION` | `INITIATOR` | Inline evaluation checklist | +| **3** | Lead Approval | `NONE` | `DEPARTMENT_LEAD` | Simple Approve/Reject | +| **3b** | Extra Check | `NONE` | `ADDITIONAL_APPROVER` | Manual Approval UI | + +- **Dynamic Insertions**: If `Extra Check` is added, the following levels shift, but their `stepAction` tags remain, so the UI NEVER breaks. +- **Resubmission**: Rejection logic targets the latest completed level with `stepAction: 'DEALER_PROPOSAL'`. + +### 3. Versioning & Iterations +The user's requirement to track previous proposals during resubmission is handled via the **Snapshotted Revisions** pattern: + +- **The Main Store**: `DealerClaim.proposal` and `DealerClaim.completion` always hold the **active/latest** values. +- **The Revision Store**: `DealerClaim.revisions[]` acts as an append-only audit trail. + +**Resubmission Flow:** +1. Request is rejected at Level 2/3/5. +2. Workflow moves back to Level 1 or 4 (Dealer). +3. Dealer edits the data. +4. **On Submit**: + - Backend takes the *current* `proposal` or `completion` data. + - Pushes it into `revisions` with a timestamp and `triggeredBy: 'SYSTEM_VERSION_SNAPSHOT'`. + - Overwrites the main object with the *new* data. + - Advances the workflow. + +### 4. Implementation Strategy + +| Feature | Custom Request Path | Dealer Claim Path | +| :--- | :--- | :--- | +| **Listing** | Unified `listWorkflows` | Unified `listWorkflows` | +| **Details View** | Standard UI | Enhanced UI (tabs for Expenses/Proposal) | +| **Logic** | Generic `approveRequest` | `approveRequest` + `DealerClaimService` hook | +| **Versioning** | Activity Logs only | Snapshotted Revisions for re-submissions | + +--- + +### Key Advantage +This approach avoids creating "two separate systems". It treats a Dealer Claim as a "Custom Request with a specific metadata payload". The UI remains cohesive, and the backend logic for TAT, notifications, and status transitions stays shared. diff --git a/src/controllers/conclusion.controller.ts b/src/controllers/conclusion.controller.ts index 96e41d8..6dd7b74 100644 --- a/src/controllers/conclusion.controller.ts +++ b/src/controllers/conclusion.controller.ts @@ -1,6 +1,7 @@ import { Request, Response } from 'express'; import { WorkflowRequest, ApprovalLevel, WorkNote, Document, Activity, ConclusionRemark, User } from '../models'; // Fixed imports import { aiService } from '../services/ai.service'; +import { conclusionMongoService } from '../services/conclusion.service'; import { activityMongoService as activityService } from '../services/activity.service'; import logger from '../utils/logger'; import { getRequestMetadata } from '../utils/requestUtils'; @@ -67,110 +68,18 @@ export class ConclusionController { }); } - // Gather context for AI generation - // Mongoose: find({ requestId }), sort by levelNumber - const approvalLevels = await ApprovalLevel.find({ requestId }) - .sort({ levelNumber: 1 }); - - const workNotes = await WorkNote.find({ requestId }) - .sort({ createdAt: 1 }) - .limit(20); - - const documents = await Document.find({ requestId }) - .sort({ uploadedAt: -1 }); - - const activities = await Activity.find({ requestId }) - .sort({ createdAt: 1 }) - .limit(50); - - // Fetch initiator details manually since we can't 'include' - const initiator = await User.findOne({ userId: (request as any).initiatorId }); - - // Build context object - const context = { - requestTitle: (request as any).title, - requestDescription: (request as any).description, - requestNumber: (request as any).requestNumber, - priority: (request as any).priority, - approvalFlow: approvalLevels.map((level: any) => { - const tatPercentage = level.tatPercentageUsed !== undefined && level.tatPercentageUsed !== null - ? Number(level.tatPercentageUsed) - : (level.elapsedHours && level.tatHours ? (Number(level.elapsedHours) / Number(level.tatHours)) * 100 : 0); - return { - levelNumber: level.levelNumber, - approverName: level.approver?.name || level.approverName || 'Unknown', - status: level.status, - comments: level.comments, - actionDate: level.actionDate, - tatHours: Number(level.tatHours || 0), - elapsedHours: Number(level.elapsedHours || 0), - tatPercentageUsed: tatPercentage - }; - }), - workNotes: workNotes.map((note: any) => ({ - userName: note.userName, - message: note.message, - createdAt: note.createdAt - })), - documents: documents.map((doc: any) => ({ - fileName: doc.originalFileName || doc.fileName, - uploadedBy: doc.uploadedBy, - uploadedAt: doc.uploadedAt - })), - activities: activities.map((activity: any) => ({ - type: activity.activityType, - action: activity.activityDescription, - details: activity.activityDescription, - timestamp: activity.createdAt - })) - }; - logger.info(`[Conclusion] Generating AI remark for request ${requestId}...`); - // Generate AI conclusion - const aiResult = await aiService.generateConclusionRemark(context); + // Use the service to generate and save (consistent with automatic trigger) + const conclusionInstance = await conclusionMongoService.generateAndSaveAIConclusion(requestId); - // Check if conclusion already exists - let conclusionInstance = await ConclusionRemark.findOne({ requestId }); - - const conclusionData = { - aiGeneratedRemark: aiResult.remark, - aiModelUsed: aiResult.provider, - aiConfidenceScore: aiResult.confidence, - approvalSummary: { - totalLevels: approvalLevels.length, - approvedLevels: approvalLevels.filter((l: any) => l.status === 'APPROVED').length, - averageTatUsage: approvalLevels.reduce((sum: number, l: any) => - sum + Number(l.tatPercentageUsed || 0), 0) / (approvalLevels.length || 1) - }, - documentSummary: { - totalDocuments: documents.length, - documentNames: documents.map((d: any) => d.originalFileName || d.fileName) - }, - keyDiscussionPoints: aiResult.keyPoints, - generatedAt: new Date() - }; - - if (conclusionInstance) { - // Update existing conclusion (allow regeneration) - // Mongoose document update - Object.assign(conclusionInstance, conclusionData); - await conclusionInstance.save(); - logger.info(`[Conclusion] ✅ AI conclusion regenerated for request ${requestId}`); - } else { - // Create new conclusion - conclusionInstance = await ConclusionRemark.create({ - requestId, - ...conclusionData, - finalRemark: undefined, - editedBy: undefined, - isEdited: false, - editCount: 0, - finalizedAt: undefined - }); - logger.info(`[Conclusion] ✅ AI conclusion generated for request ${requestId}`); + if (!conclusionInstance) { + return res.status(500).json({ error: 'Failed to generate conclusion' }); } + // Fetch initiator details manually for logging + const initiator = await User.findOne({ userId: (request as any).initiatorId }); + // Log activity const requestMeta = getRequestMetadata(req); await activityService.log({ @@ -188,11 +97,11 @@ export class ConclusionController { message: 'Conclusion generated successfully', data: { conclusionId: (conclusionInstance as any).conclusionId || (conclusionInstance as any)._id, - aiGeneratedRemark: aiResult.remark, - keyDiscussionPoints: aiResult.keyPoints, - confidence: aiResult.confidence, - provider: aiResult.provider, - generatedAt: new Date() + aiGeneratedRemark: conclusionInstance.aiGeneratedRemark, + keyDiscussionPoints: conclusionInstance.keyDiscussionPoints, + confidence: conclusionInstance.aiConfidenceScore, + provider: conclusionInstance.aiModelUsed, + generatedAt: conclusionInstance.generatedAt } }); } catch (error: any) { diff --git a/src/controllers/pause.controller.ts b/src/controllers/pause.controller.ts index 5dd324c..33f1bbc 100644 --- a/src/controllers/pause.controller.ts +++ b/src/controllers/pause.controller.ts @@ -1,5 +1,6 @@ import { Response } from 'express'; import { pauseMongoService } from '@services/pause.service'; +import { workflowServiceMongo } from '@services/workflow.service'; import { ResponseHandler } from '@utils/responseHandler'; import type { AuthenticatedRequest } from '../types/express'; import { z } from 'zod'; @@ -31,6 +32,13 @@ export class PauseController { return; } + // Resolve requestId (UUID) + const requestId = await workflowServiceMongo.resolveRequestId(id); + if (!requestId) { + ResponseHandler.notFound(res, 'Request not found'); + return; + } + // Validate request body const validated = pauseWorkflowSchema.parse(req.body); const resumeDate = validated.resumeDate instanceof Date @@ -38,7 +46,7 @@ export class PauseController { : new Date(validated.resumeDate); const result = await pauseMongoService.pauseWorkflow( - id, + requestId, validated.levelId || null, userId, validated.reason, @@ -73,10 +81,17 @@ export class PauseController { return; } + // Resolve requestId (UUID) + const requestId = await workflowServiceMongo.resolveRequestId(id); + if (!requestId) { + ResponseHandler.notFound(res, 'Request not found'); + return; + } + // Validate request body (notes is optional) const validated = resumeWorkflowSchema.parse(req.body || {}); - const result = await pauseMongoService.resumeWorkflow(id, userId, validated.notes); + const result = await pauseMongoService.resumeWorkflow(requestId, userId, validated.notes); ResponseHandler.success(res, { workflow: result.workflow, @@ -106,7 +121,14 @@ export class PauseController { return; } - await pauseMongoService.retriggerPause(id, userId); + // Resolve requestId (UUID) + const requestId = await workflowServiceMongo.resolveRequestId(id); + if (!requestId) { + ResponseHandler.notFound(res, 'Request not found'); + return; + } + + await pauseMongoService.retriggerPause(requestId, userId); ResponseHandler.success(res, null, 'Pause retrigger request sent successfully', 200); } catch (error: any) { @@ -123,7 +145,14 @@ export class PauseController { try { const { id } = req.params; - const pauseDetails = await pauseMongoService.getPauseDetails(id); + // Resolve requestId (UUID) + const requestId = await workflowServiceMongo.resolveRequestId(id); + if (!requestId) { + ResponseHandler.notFound(res, 'Request not found'); + return; + } + + const pauseDetails = await pauseMongoService.getPauseDetails(requestId); if (!pauseDetails) { ResponseHandler.success(res, { isPaused: false }, 'Workflow is not paused', 200); diff --git a/src/controllers/workflow.controller.ts b/src/controllers/workflow.controller.ts index 56920ff..34ad3ae 100644 --- a/src/controllers/workflow.controller.ts +++ b/src/controllers/workflow.controller.ts @@ -113,6 +113,18 @@ export class WorkflowController { userAgent: requestMeta.userAgent }); + // Handle auto-submit unless isDraft is true + const isDraftRequested = req.body.isDraft === true; // Default to false if not explicitly true + if (!isDraftRequested) { + logger.info(`[WorkflowController] Auto-submitting workflow ${workflow.requestNumber}`); + await workflowServiceMongo.submitWorkflow(workflow.requestId); + + // Refetch to get updated status/state + const updatedWorkflow = await workflowServiceMongo.getWorkflowById(workflow.requestId); + ResponseHandler.success(res, updatedWorkflow, 'Workflow created and submitted successfully', 201); + return; + } + ResponseHandler.success(res, workflow, 'Workflow created successfully', 201); } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; @@ -406,6 +418,23 @@ export class WorkflowController { } } + // Handle auto-submit unless isDraft is true + const isDraftRequested = parsed.isDraft === true; // Default to false if not explicitly true + if (!isDraftRequested) { + logger.info(`[WorkflowController] Auto-submitting multipart workflow ${workflow.requestNumber}`); + await workflowServiceMongo.submitWorkflow(workflow.requestId); + + // Get updated workflow to return complete state + const updatedWorkflow = await workflowServiceMongo.getWorkflowById(workflow.requestId); + ResponseHandler.success(res, { + requestId: workflow.requestNumber, + status: updatedWorkflow.status, + workflowState: updatedWorkflow.workflowState, + documents: docs + }, 'Workflow created and submitted with documents', 201); + return; + } + ResponseHandler.success(res, { requestId: workflow.requestNumber, documents: docs }, 'Workflow created with documents', 201); } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; @@ -663,6 +692,19 @@ export class WorkflowController { return; } + // Handle auto-submit if isDraft is explicitly false or missing + // If it's already submitted, submitWorkflow will throw or handle gracefully + const isDraftRequested = req.body.isDraft === true; // Default to false + if (!isDraftRequested && (workflow as any).isDraft) { + logger.info(`[WorkflowController] Auto-submitting workflow ${workflow.requestNumber} after update`); + await workflowServiceMongo.submitWorkflow(workflow.requestId); + + // Refetch updated workflow + const updatedWorkflow = await workflowServiceMongo.getWorkflowById(workflow.requestId); + ResponseHandler.success(res, updatedWorkflow, 'Workflow updated and submitted successfully'); + return; + } + ResponseHandler.success(res, workflow, 'Workflow updated successfully'); } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; @@ -858,6 +900,18 @@ export class WorkflowController { } } + // Handle auto-submit if isDraft is false + const isDraftRequested = parsed.isDraft === true; // Default to false + if (!isDraftRequested && (workflow as any).isDraft) { + logger.info(`[WorkflowController] Auto-submitting multipart workflow ${workflow.requestNumber} after update`); + await workflowServiceMongo.submitWorkflow(workflow.requestId); + + // Return updated workflow + const updatedWorkflow = await workflowServiceMongo.getWorkflowById(workflow.requestId); + ResponseHandler.success(res, updatedWorkflow, 'Workflow updated and submitted successfully'); + return; + } + ResponseHandler.success(res, { workflow, newDocuments: docs }, 'Workflow updated with documents', 200); } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; @@ -920,13 +974,12 @@ export class WorkflowController { try { const { id } = req.params; - // Resolve requestId from identifier (could be requestNumber or ID) - const wf = await workflowServiceMongo.getRequest(id); - if (!wf) { + // Resolve requestId from identifier (could be requestNumber or UUID) + const requestId = await workflowServiceMongo.resolveRequestId(id); + if (!requestId) { ResponseHandler.notFound(res, 'Workflow not found'); return; } - const requestId = wf.requestId; // Use UUID const history = await dealerClaimService.getHistory(requestId); ResponseHandler.success(res, history, 'Revision history fetched successfully'); diff --git a/src/controllers/worknote.controller.ts b/src/controllers/worknote.controller.ts index 895d148..5833931 100644 --- a/src/controllers/worknote.controller.ts +++ b/src/controllers/worknote.controller.ts @@ -1,7 +1,6 @@ import type { Response } from 'express'; import { workNoteMongoService } from '../services/worknote.service'; import { workflowServiceMongo } from '../services/workflow.service'; -import { getRequestMetadata } from '@utils/requestUtils'; import { ResponseHandler } from '@utils/responseHandler'; import { AuthenticatedRequest } from '../types/express'; import { ParticipantModel } from '../models/mongoose/Participant.schema'; @@ -12,15 +11,15 @@ export class WorkNoteController { */ async list(req: AuthenticatedRequest, res: Response): Promise { try { - const requestNumber = req.params.id; - const request = await workflowServiceMongo.getRequest(requestNumber); + const identifier = req.params.id; // Could be requestNumber or UUID + const requestId = await workflowServiceMongo.resolveRequestId(identifier); - if (!request) { + if (!requestId) { ResponseHandler.notFound(res, 'Request not found'); return; } - const rows = await workNoteMongoService.list(requestNumber); + const rows = await workNoteMongoService.list(requestId); ResponseHandler.success(res, rows, 'Work notes retrieved'); } catch (error) { ResponseHandler.error(res, 'Failed to list work notes', 500); @@ -32,17 +31,17 @@ export class WorkNoteController { */ async create(req: AuthenticatedRequest, res: Response): Promise { try { - const requestNumber = req.params.id; - const request = await workflowServiceMongo.getRequest(requestNumber); + const identifier = req.params.id; // Could be requestNumber or UUID + const requestId = await workflowServiceMongo.resolveRequestId(identifier); - if (!request) { + if (!requestId) { ResponseHandler.notFound(res, 'Request not found'); return; } - // Get user's participant info from Mongo + // Get user's participant info from Mongo using UUID const participant = await ParticipantModel.findOne({ - requestId: requestNumber, + requestId: requestId, userId: req.user.userId }); @@ -78,9 +77,8 @@ export class WorkNoteController { mentionedUsers: payload.mentions || [] }; - const requestMeta = getRequestMetadata(req); const note = await workNoteMongoService.create( - requestNumber, + requestId, user, workNotePayload, files diff --git a/src/models/mongoose/Dealer.schema.ts b/src/models/mongoose/Dealer.schema.ts index 8e149d1..2488cb1 100644 --- a/src/models/mongoose/Dealer.schema.ts +++ b/src/models/mongoose/Dealer.schema.ts @@ -55,7 +55,13 @@ export interface IDealer extends Document { } const DealerSchema = new Schema({ - dealerId: { type: String, required: true, unique: true, index: true }, + dealerId: { + type: String, + required: true, + unique: true, + index: true, + default: () => require('crypto').randomUUID() + }, // Codes salesCode: { type: String, index: true }, diff --git a/src/scripts/seed-test-dealer.mongo.ts b/src/scripts/seed-test-dealer.mongo.ts index a17272e..2106d03 100644 --- a/src/scripts/seed-test-dealer.mongo.ts +++ b/src/scripts/seed-test-dealer.mongo.ts @@ -6,29 +6,60 @@ const seedTestDealerMongo = async () => { try { await connectMongoDB(); - const dealerData = { - dlrcode: 'TEST001', // Changed from dealerCode - dealerId: 'test-dealer-id', // Added explicit ID or auto-gen handled by schema? Schema requires dealerId. - dealerName: 'TEST REFLOW DEALERSHIP', + const dealerData: any = { + salesCode: 'TEST001', + serviceCode: 'TEST001', + gearCode: 'TEST001', + gmaCode: 'TEST001', region: 'TEST', + dealership: 'TEST REFLOW DEALERSHIP', state: 'Test State', + district: 'Test District', city: 'Test City', - zone: 'Test Zone', location: 'Test Location', - sapCode: 'SAP001', - email: 'testreflow@example.com', - phone: '9999999999', - address: 'Test Address, Test City', - isActive: true, - // Additional fields can be added if schema supports them + cityCategoryPst: 'A', + layoutFormat: 'A', + tierCityCategory: 'Tier 1 City', + onBoardingCharges: null, + date: new Date().toISOString().split('T')[0], + singleFormatMonthYear: (() => { + const now = new Date(); + const month = now.toLocaleDateString('en-US', { month: 'short' }); + const year = now.getFullYear(); + return `${month}-${year}`; + })(), + domainId: 'testreflow@example.com', + replacement: null, + terminationResignationStatus: null, + dateOfTerminationResignation: null, + lastDateOfOperations: null, + oldCodes: null, + branchDetails: null, + dealerPrincipalName: 'TEST REFLOW', + dealerPrincipalEmailId: 'testreflow@example.com', + dpContactNumber: null, + dpContacts: null, + showroomAddress: null, + showroomPincode: null, + workshopAddress: null, + workshopPincode: null, + locationDistrict: null, + stateWorkshop: null, + noOfStudios: 0, + websiteUpdate: 'Yes', + gst: null, + pan: null, + firmType: 'Test Firm', + propManagingPartnersDirectors: 'TEST REFLOW', + totalPropPartnersDirectors: 'TEST REFLOW', + docsFolderLink: null, + workshopGmaCodes: null, + existingNew: 'New', + dlrcode: 'TEST001', + isActive: true }; - const existingDealer = await DealerModel.findOne({ - $or: [ - { dlrcode: dealerData.dlrcode }, - { email: dealerData.email } - ] - }); + const existingDealer = await DealerModel.findOne({ dlrcode: dealerData.dlrcode }); if (existingDealer) { logger.info('[Seed Test Dealer Mongo] Dealer already exists, updating...'); @@ -36,13 +67,10 @@ const seedTestDealerMongo = async () => { await existingDealer.save(); logger.info(`[Seed Test Dealer Mongo] ✅ Updated dealer: ${existingDealer.dlrcode}`); } else { - // Ensure dealerId is present if required - if (!dealerData.dealerId) { - // Generate a UUID if not provided? - // The interface has dealerId required. - // Let's add it to dealerData above. - } - const newDealer = await DealerModel.create(dealerData); + const newDealer = await DealerModel.create({ + ...dealerData, + dealerId: require('crypto').randomUUID() + }); logger.info(`[Seed Test Dealer Mongo] ✅ Created dealer: ${newDealer.dlrcode}`); } diff --git a/src/scripts/verify-dealer-id-gen.ts b/src/scripts/verify-dealer-id-gen.ts new file mode 100644 index 0000000..073676b --- /dev/null +++ b/src/scripts/verify-dealer-id-gen.ts @@ -0,0 +1,40 @@ +import mongoose from 'mongoose'; +import { DealerModel } from '../models/mongoose/Dealer.schema'; +import dotenv from 'dotenv'; +import path from 'path'; + +dotenv.config({ path: path.join(__dirname, '../../.env') }); + +async function verifyDealerIdGen() { + try { + console.log('Connecting to MongoDB...'); + const mongoUri = process.env.MONGODB_URI || 'mongodb://localhost:27017/re_workflow_db'; + await mongoose.connect(mongoUri); + console.log('Connected.'); + + console.log('Attempting to create a dealer document without dealerId...'); + const testDealerData = { + salesCode: 'S' + Math.floor(Math.random() * 10000), + dealership: 'Verification Test Dealership', + isActive: true + }; + + const dealer = new DealerModel(testDealerData); + console.log('Constructed dealer object dealerId:', dealer.dealerId); + + if (dealer.dealerId) { + console.log('✅ Success: dealerId was automatically generated!'); + console.log('Generated ID:', dealer.dealerId); + } else { + console.error('❌ Failure: dealerId was NOT generated.'); + } + + await mongoose.disconnect(); + process.exit(0); + } catch (error) { + console.error('❌ Error during verification:', error); + process.exit(1); + } +} + +verifyDealerIdGen(); diff --git a/src/services/approval.service.ts b/src/services/approval.service.ts index f5a9ae5..6f9fa84 100644 --- a/src/services/approval.service.ts +++ b/src/services/approval.service.ts @@ -2,6 +2,7 @@ import { ApprovalLevelModel } from '../models/mongoose/ApprovalLevel.schema'; import { WorkflowRequestModel } from '../models/mongoose/WorkflowRequest.schema'; import { ApprovalAction } from '../types/approval.types'; import { ApprovalStatus, WorkflowStatus } from '../types/common.types'; +import { conclusionMongoService } from './conclusion.service'; import logger from '../utils/logger'; export class ApprovalService { @@ -53,6 +54,11 @@ export class ApprovalService { wf.closureDate = now; await wf.save(); + // Trigger AI conclusion generation + conclusionMongoService.generateAndSaveAIConclusion(wf.requestId).catch(err => { + logger.error(`[ApprovalService] Deferred AI conclusion error for ${wf.requestId} (Rejection):`, err); + }); + // Notify initiator await notificationMongoService.sendToUsers([wf.initiator.userId], { title: `Request Rejected: ${wf.requestNumber}`, @@ -88,10 +94,12 @@ export class ApprovalService { level.comments = action.comments; await level.save(); - // Check if this is the final approval - const allLevels = await ApprovalLevelModel.find({ requestId: wf.requestId }); - const approvedCount = allLevels.filter(l => l.status === ApprovalStatus.APPROVED).length; - const isFinal = approvedCount === allLevels.length; + // Check for final approver + const allLevels = await ApprovalLevelModel.find({ requestId: level.requestId }); + const completedCount = allLevels.filter(l => + l.status === ApprovalStatus.APPROVED || l.status === ApprovalStatus.SKIPPED + ).length; + const isFinal = completedCount === allLevels.length; if (isFinal) { // Final approval - close workflow @@ -126,6 +134,17 @@ export class ApprovalService { category: 'WORKFLOW', severity: 'INFO' }); + + // Trigger automatic AI conclusion generation + try { + // Running as "fire and forget" or wait? + // Usually better to let it run in background to not block the response + conclusionMongoService.generateAndSaveAIConclusion(wf.requestId).catch(err => { + logger.error(`[ApprovalService] Deferred AI conclusion error for ${wf.requestId}:`, err); + }); + } catch (aiError) { + logger.error(`[ApprovalService] AI conclusion trigger failed for ${wf.requestId}:`, aiError); + } } else { // Move to next level const currentLevelNum = level.levelNumber; diff --git a/src/services/conclusion.service.ts b/src/services/conclusion.service.ts new file mode 100644 index 0000000..a338bc5 --- /dev/null +++ b/src/services/conclusion.service.ts @@ -0,0 +1,137 @@ +import { WorkflowRequest, ApprovalLevel, WorkNote, Document, Activity, ConclusionRemark, User } from '../models'; +import { aiService } from './ai.service'; +import logger from '../utils/logger'; +import { v4 as uuidv4 } from 'uuid'; + +export class ConclusionMongoService { + /** + * Generate and save AI conclusion for a request + */ + async generateAndSaveAIConclusion(requestId: string): Promise { + try { + logger.info(`[Conclusion Service] Starting automatic AI conclusion generation for request ${requestId}`); + + // 1. Fetch Request + const request = await WorkflowRequest.findOne({ requestId }); + if (!request) { + throw new Error('Request not found'); + } + + // Check if AI features are enabled in admin config + const { getConfigValue } = await import('./configReader.service'); + const aiEnabled = (await getConfigValue('AI_ENABLED', 'true'))?.toLowerCase() === 'true'; + const remarkGenerationEnabled = (await getConfigValue('AI_REMARK_GENERATION_ENABLED', 'true'))?.toLowerCase() === 'true'; + + if (!aiEnabled || !remarkGenerationEnabled) { + logger.warn(`[Conclusion Service] AI generation disabled in admin config for request ${requestId}`); + return null; + } + + // Check if AI service is available + if (!aiService.isAvailable()) { + logger.warn(`[Conclusion Service] AI service unavailable for request ${requestId}`); + return null; + } + + // 2. Gather context for AI generation + const approvalLevels = await ApprovalLevel.find({ requestId }) + .sort({ levelNumber: 1 }); + + const workNotes = await WorkNote.find({ requestId }) + .sort({ createdAt: 1 }) + .limit(20); + + const documents = await Document.find({ requestId }) + .sort({ uploadedAt: -1 }); + + const activities = await Activity.find({ requestId }) + .sort({ createdAt: 1 }) + .limit(50); + + // 3. Build context object + const context = { + requestTitle: (request as any).title, + requestDescription: (request as any).description, + requestNumber: (request as any).requestNumber, + priority: (request as any).priority, + approvalFlow: approvalLevels.map((level: any) => { + const tatPercentage = level.tatPercentageUsed !== undefined && level.tatPercentageUsed !== null + ? Number(level.tatPercentageUsed) + : (level.elapsedHours && level.tatHours ? (Number(level.elapsedHours) / Number(level.tatHours)) * 100 : 0); + return { + levelNumber: level.levelNumber, + approverName: level.approver?.name || level.approverName || 'Unknown', + status: level.status, + comments: level.comments, + actionDate: level.actionDate, + tatHours: Number(level.tatHours || 0), + elapsedHours: Number(level.elapsedHours || 0), + tatPercentageUsed: tatPercentage + }; + }), + workNotes: workNotes.map((note: any) => ({ + userName: note.userName, + message: note.message, + createdAt: note.createdAt + })), + documents: documents.map((doc: any) => ({ + fileName: doc.originalFileName || doc.fileName, + uploadedBy: doc.uploadedBy, + uploadedAt: doc.uploadedAt + })), + activities: activities.map((activity: any) => ({ + type: activity.activityType, + action: activity.activityDescription, + details: activity.activityDescription, + timestamp: activity.createdAt + })) + }; + + // 4. Generate AI conclusion via AI Service + const aiResult = await aiService.generateConclusionRemark(context); + + // 5. Save/Update ConclusionRemark + let conclusionInstance = await ConclusionRemark.findOne({ requestId }); + + const conclusionData = { + aiGeneratedRemark: aiResult.remark, + aiModelUsed: aiResult.provider, + aiConfidenceScore: aiResult.confidence, + approvalSummary: { + totalLevels: approvalLevels.length, + approvedLevels: approvalLevels.filter((l: any) => l.status === 'APPROVED').length, + averageTatUsage: approvalLevels.reduce((sum: number, l: any) => + sum + Number(l.tatPercentageUsed || 0), 0) / (approvalLevels.length || 1) + }, + documentSummary: { + totalDocuments: documents.length, + documentNames: documents.map((d: any) => d.originalFileName || d.fileName) + }, + keyDiscussionPoints: aiResult.keyPoints, + generatedAt: new Date() + }; + + if (conclusionInstance) { + Object.assign(conclusionInstance, conclusionData); + await conclusionInstance.save(); + } else { + conclusionInstance = await ConclusionRemark.create({ + requestId, + conclusionId: uuidv4(), + ...conclusionData, + isEdited: false, + editCount: 0 + }); + } + + logger.info(`[Conclusion Service] ✅ Automatic AI conclusion generated for request ${requestId}`); + return conclusionInstance; + } catch (error) { + logger.error(`[Conclusion Service] ❌ Failed to generate automatic AI conclusion for ${requestId}:`, error); + // We don't re-throw as this is a background/automatic process that shouldn't break the main flow + return null; + } + } +} + +export const conclusionMongoService = new ConclusionMongoService(); diff --git a/src/services/dealerClaim.service.ts b/src/services/dealerClaim.service.ts index 42c663d..dbd12d7 100644 --- a/src/services/dealerClaim.service.ts +++ b/src/services/dealerClaim.service.ts @@ -8,8 +8,9 @@ import { DocumentModel } from '../models/mongoose/Document.schema'; import { DealerClaimApprovalMongoService } from './dealerClaimApproval.service'; import { WorkflowServiceMongo } from './workflow.service'; import { UserService } from './user.service'; -import { notificationMongoService } from './notification.service'; import { activityMongoService } from './activity.service'; +import { conclusionMongoService } from './conclusion.service'; +import { notificationMongoService } from './notification.service'; import { Priority, WorkflowStatus, ApprovalStatus, ParticipantType } from '../types/common.types'; import logger from '../utils/logger'; import { generateRequestNumber } from '../utils/helpers'; @@ -87,6 +88,7 @@ export class DealerClaimMongoService { description: claimData.requestDescription, priority: Priority.STANDARD, status: WorkflowStatus.PENDING, + workflowState: 'OPEN', totalLevels: 5, currentLevel: 1, totalTatHours: 0, // Will be calculated @@ -325,7 +327,7 @@ export class DealerClaimMongoService { percentageUsed: 0, isBreached: false }, - status: isStep1 ? 'PENDING' : 'PENDING', // Archived code sets Step 1 to PENDING too, but effectively it's the active one + status: isStep1 ? 'IN_PROGRESS' : 'PENDING', // Active step 1 starts as IN_PROGRESS // Wait. Usually Step 1 should be IN_PROGRESS if it's active. // In the archived code: `status: isStep1 ? ApprovalStatus.PENDING : ApprovalStatus.PENDING` - both pending? // Ah, `dealerLevel` is later used. @@ -611,10 +613,14 @@ export class DealerClaimMongoService { // Update workflow status workflow.status = 'REJECTED'; workflow.workflowState = 'CLOSED'; - workflow.isDeleted = true; // Soft delete or just mark cancelled? Usually cancelled. // Let's stick to status update. await workflow.save(); + // Trigger AI conclusion generation + conclusionMongoService.generateAndSaveAIConclusion(workflow.requestId).catch(err => { + logger.error(`[DealerClaimService] Deferred AI conclusion error for ${workflow.requestId} (Cancel):`, err); + }); + // Log activity const user = await UserModel.findOne({ userId }); const userName = user?.displayName || user?.email || 'User'; diff --git a/src/services/dealerClaimApproval.service.ts b/src/services/dealerClaimApproval.service.ts index 3ba999d..8c4b67e 100644 --- a/src/services/dealerClaimApproval.service.ts +++ b/src/services/dealerClaimApproval.service.ts @@ -10,6 +10,7 @@ import { calculateElapsedWorkingHours } from '../utils/tatTimeUtils'; import logger from '../utils/logger'; import { notificationMongoService } from './notification.service'; import { activityMongoService } from './activity.service'; +import { conclusionMongoService } from './conclusion.service'; import { tatSchedulerMongoService } from './tatScheduler.service'; import { DealerClaimMongoService } from './dealerClaim.service'; import { emitToRequestRoom } from '../realtime/socket'; @@ -78,8 +79,10 @@ export class DealerClaimApprovalMongoService { // Check for final approver const allLevels = await ApprovalLevelModel.find({ requestId: level.requestId }); - const approvedCount = allLevels.filter(l => l.status === ApprovalStatus.APPROVED).length; - const isFinal = approvedCount === allLevels.length; + const completedCount = allLevels.filter(l => + l.status === ApprovalStatus.APPROVED || l.status === ApprovalStatus.SKIPPED + ).length; + const isFinal = completedCount === allLevels.length; if (isFinal) { wf.status = WorkflowStatus.APPROVED; @@ -99,6 +102,11 @@ export class DealerClaimApprovalMongoService { type: 'approval', priority: 'MEDIUM' }); + + // Trigger automatic AI conclusion generation + conclusionMongoService.generateAndSaveAIConclusion(level.requestId).catch(err => { + logger.error(`[DealerClaimApprovalService] Deferred AI conclusion error for ${level.requestId}:`, err); + }); } else { // Move to next level const currentLevelNum = level.levelNumber; @@ -212,6 +220,11 @@ export class DealerClaimApprovalMongoService { // level.rejectionReason = action.rejectionReason; // Assuming field exists await level.save(); + // Trigger AI conclusion generation + conclusionMongoService.generateAndSaveAIConclusion(wf.requestId).catch(err => { + logger.error(`[DealerClaimApprovalService] Deferred AI conclusion error for ${wf.requestId} (Rejection):`, err); + }); + // Notify await notificationMongoService.sendToUsers([wf.initiator.userId], { title: `Request Rejected: ${wf.requestNumber}`, diff --git a/src/services/emailNotification.service.ts b/src/services/emailNotification.service.ts index bfc7cb9..963f0fe 100644 --- a/src/services/emailNotification.service.ts +++ b/src/services/emailNotification.service.ts @@ -161,12 +161,12 @@ export class EmailNotificationService { if (isMultiLevel && approvalChain) { // Multi-level approval email const chainData: ApprovalChainItem[] = approvalChain.map((level: any) => ({ - name: level.approverName || level.approverEmail, + name: level.approver?.name || level.approverName || level.approver?.email || level.approverEmail || 'Unknown Approver', status: level.status === 'APPROVED' ? 'approved' : level.levelNumber === approverData.levelNumber ? 'current' : level.levelNumber < approverData.levelNumber ? 'pending' : 'awaiting', - date: level.approvedAt ? this.formatDate(level.approvedAt) : undefined, + date: (level.actionDate || level.approvedAt) ? this.formatDate(level.actionDate || level.approvedAt) : undefined, levelNumber: level.levelNumber })); @@ -175,7 +175,7 @@ export class EmailNotificationService { requestId: requestData.requestNumber, requestTitle: requestData.title, approverName: approverData.displayName || approverData.email, - initiatorName: initiatorData.displayName || initiatorData.email, + initiatorName: requestData.initiator?.name || initiatorData.displayName || initiatorData.email || 'Initiator', requestType: getTemplateTypeLabel(requestData.templateType || requestData.requestType), requestDescription: requestData.description || '', priority: requestData.priority || 'MEDIUM', @@ -207,7 +207,7 @@ export class EmailNotificationService { requestId: requestData.requestNumber, requestTitle: requestData.title, approverName: approverData.displayName || approverData.email, - initiatorName: initiatorData.displayName || initiatorData.email, + initiatorName: requestData.initiator?.name || initiatorData.displayName || initiatorData.email || 'Initiator', requestType: getTemplateTypeLabel(requestData.templateType || requestData.requestType), requestDescription: requestData.description || '', priority: requestData.priority || 'MEDIUM', @@ -259,14 +259,14 @@ export class EmailNotificationService { const data: ApprovalConfirmationData = { recipientName: initiatorData.displayName || initiatorData.email, requestId: requestData.requestNumber, - initiatorName: initiatorData.displayName || initiatorData.email, - approverName: approverData.displayName || approverData.email, - approvalDate: this.formatDate(approverData.approvedAt || new Date()), - approvalTime: this.formatTime(approverData.approvedAt || new Date()), + initiatorName: requestData.initiator?.name || initiatorData.displayName || initiatorData.email || 'Initiator', + approverName: approverData.displayName || approverData.email || approverData.name, + approvalDate: this.formatDate(approverData.approvedAt || approverData.actionDate || new Date()), + approvalTime: this.formatTime(approverData.approvedAt || approverData.actionDate || new Date()), requestType: getTemplateTypeLabel(requestData.templateType || requestData.requestType), approverComments: approverData.comments || undefined, isFinalApproval, - nextApproverName: nextApproverData?.displayName || nextApproverData?.email, + nextApproverName: nextApproverData?.displayName || nextApproverData?.email || nextApproverData?.name, viewDetailsLink: getViewDetailsLink(requestData.requestNumber, this.frontendUrl), companyName: CompanyInfo.name }; @@ -312,10 +312,10 @@ export class EmailNotificationService { const data: RejectionNotificationData = { recipientName: initiatorData.displayName || initiatorData.email, requestId: requestData.requestNumber, - initiatorName: initiatorData.displayName || initiatorData.email, - approverName: approverData.displayName || approverData.email, - rejectionDate: this.formatDate(approverData.rejectedAt || new Date()), - rejectionTime: this.formatTime(approverData.rejectedAt || new Date()), + initiatorName: requestData.initiator?.name || initiatorData.displayName || initiatorData.email || 'Initiator', + approverName: approverData.displayName || approverData.email || approverData.name, + rejectionDate: this.formatDate(approverData.rejectedAt || approverData.actionDate || new Date()), + rejectionTime: this.formatTime(approverData.rejectedAt || approverData.actionDate || new Date()), requestType: getTemplateTypeLabel(requestData.templateType || requestData.requestType), rejectionReason, viewDetailsLink: getViewDetailsLink(requestData.requestNumber, this.frontendUrl), @@ -388,7 +388,7 @@ export class EmailNotificationService { requestId: requestData.requestNumber, requestTitle: requestData.title, approverName: approverData.displayName || approverData.email, - initiatorName: initiatorName, + initiatorName: requestData.initiator?.name || initiatorName, assignedDate: this.formatDate(tatInfo.assignedDate), tatDeadline: this.formatDate(tatInfo.tatDeadline) + ' ' + this.formatTime(tatInfo.tatDeadline), timeRemaining: tatInfo.timeRemaining, @@ -459,7 +459,7 @@ export class EmailNotificationService { requestId: requestData.requestNumber, requestTitle: requestData.title, approverName: approverData.displayName || approverData.email, - initiatorName: initiatorName, + initiatorName: requestData.initiator?.name || initiatorName, priority: requestData.priority || 'MEDIUM', assignedDate: this.formatDate(tatInfo.assignedDate), tatDeadline: this.formatDate(tatInfo.tatDeadline) + ' ' + this.formatTime(tatInfo.tatDeadline), @@ -987,8 +987,9 @@ export class EmailNotificationService { // Add current approvers if they are not the uploader if (requestData.approvalLevels && Array.isArray(requestData.approvalLevels)) { const currentLevel = requestData.approvalLevels.find((l: any) => l.status === 'PENDING' || l.status === 'IN_PROGRESS'); - if (currentLevel && currentLevel.approverEmail && currentLevel.approverEmail !== uploaderData.email) { - recipients.add(currentLevel.approverEmail); + const approverEmail = currentLevel?.approver?.email || currentLevel?.approverEmail; + if (approverEmail && approverEmail !== uploaderData.email) { + recipients.add(approverEmail); } } @@ -1050,8 +1051,8 @@ export class EmailNotificationService { requestTitle: requestData.title, participantName: recipientData.displayName || recipientData.email, participantRole: 'Approver', - addedByName: addedByData.displayName || addedByData.email, - initiatorName: initiatorData.displayName || initiatorData.email, + addedByName: addedByData.displayName || addedByData.email || addedByData.name, + initiatorName: requestData.initiator?.name || initiatorData.displayName || initiatorData.email || 'Initiator', requestType: getTemplateTypeLabel(requestData.templateType), currentStatus: requestData.status, addedDate: this.formatDate(new Date()), @@ -1098,8 +1099,8 @@ export class EmailNotificationService { requestId: requestData.requestNumber, requestTitle: requestData.title, spectatorName: recipientData.displayName || recipientData.email, - addedByName: addedByData.displayName || addedByData.email, - initiatorName: initiatorData.displayName || initiatorData.email, + addedByName: addedByData.displayName || addedByData.email || addedByData.name, + initiatorName: requestData.initiator?.name || initiatorData.displayName || initiatorData.email || 'Initiator', requestType: getTemplateTypeLabel(requestData.templateType), currentStatus: requestData.status, addedDate: this.formatDate(new Date()), diff --git a/src/services/notification.service.ts b/src/services/notification.service.ts index bb291d1..f5a6931 100644 --- a/src/services/notification.service.ts +++ b/src/services/notification.service.ts @@ -531,8 +531,8 @@ class NotificationMongoService { await emailNotificationService.sendRejectionNotification( requestData, - initiatorData, approverData, + initiatorData, payload.metadata?.rejectionReason || 'No reason provided' ); } diff --git a/src/services/pause.service.ts b/src/services/pause.service.ts index 9ed8d5e..e849e6f 100644 --- a/src/services/pause.service.ts +++ b/src/services/pause.service.ts @@ -31,13 +31,8 @@ export class PauseMongoService { throw new Error('Resume date must be in the future'); } - // Get workflow by requestNumber or requestId (both are UUID strings) - let workflow: any = await WorkflowRequestModel.findOne({ - $or: [ - { requestNumber: requestId }, - { requestId: requestId } - ] - }); + // Get workflow by requestId (UUID) + let workflow: any = await WorkflowRequestModel.findOne({ requestId }); if (!workflow) throw new Error('Workflow not found'); @@ -122,7 +117,7 @@ export class PauseMongoService { await workflow.save(); // Cancel jobs - await tatScheduler.cancelTatJobs(workflow.requestNumber, level._id.toString()); + await tatScheduler.cancelTatJobs(workflow.requestId, level._id.toString()); // Notifications const user = await UserModel.findOne({ userId }); @@ -132,7 +127,7 @@ export class PauseMongoService { await notificationMongoService.sendToUsers([initiatorId], { title: 'Workflow Paused', body: `Your request "${workflow.title}" has been paused by ${userName}. Reason: ${reason}.`, - requestId: workflow.requestNumber, + requestId: workflow.requestId, requestNumber: workflow.requestNumber, type: 'workflow_paused', priority: 'HIGH', @@ -144,7 +139,7 @@ export class PauseMongoService { await notificationMongoService.sendToUsers([userId], { title: 'Workflow Paused Successfully', body: `You have paused request "${workflow.title}".`, - requestId: workflow.requestNumber, + requestId: workflow.requestId, requestNumber: workflow.requestNumber, type: 'status_change', priority: 'MEDIUM' @@ -154,16 +149,16 @@ export class PauseMongoService { await notificationMongoService.sendToUsers([approverId], { title: 'Workflow Paused by Initiator', body: `Request "${workflow.title}" has been paused by the initiator.`, - requestId: workflow.requestNumber, + requestId: workflow.requestId, requestNumber: workflow.requestNumber, type: 'workflow_paused', priority: 'HIGH' }); } - // Log Activity + // Log Activity - Standardized to UUID await activityMongoService.log({ - requestId: workflow.requestNumber, + requestId: workflow.requestId, type: 'paused', user: { userId, name: userName }, timestamp: now.toISOString(), @@ -179,12 +174,12 @@ export class PauseMongoService { if (pauseResumeQueue && resumeDate) { const delay = resumeDate.getTime() - now.getTime(); if (delay > 0) { - const jobId = `resume-${workflow.requestNumber}-${level._id.toString()}`; + const jobId = `resume-${workflow.requestId}-${level._id.toString()}`; await pauseResumeQueue.add( 'auto-resume-workflow', { type: 'auto-resume-workflow', - requestId: workflow.requestNumber, // Use semantic ID + requestId: workflow.requestId, // Standardized to UUID levelId: level._id.toString(), scheduledResumeDate: resumeDate.toISOString() }, @@ -199,8 +194,9 @@ export class PauseMongoService { try { const { emitToRequestRoom } = require('../realtime/socket'); if (emitToRequestRoom) { - emitToRequestRoom(workflow.requestNumber, 'request:updated', { - requestId: workflow.requestNumber, + emitToRequestRoom(workflow.requestId, 'request:updated', { + requestId: workflow.requestId, + requestNumber: workflow.requestNumber, action: 'PAUSE', timestamp: now.toISOString() }); @@ -265,7 +261,7 @@ export class PauseMongoService { workflow.pausedBy = undefined; workflow.pauseReason = undefined; workflow.pauseResumeDate = undefined; - workflow.status = 'IN_PROGRESS'; // Assuming previous status was IN_PROGRESS or PENDING + workflow.status = 'PENDING'; // Resumed requests return to PENDING workflow.workflowState = 'OPEN'; await workflow.save(); @@ -273,7 +269,7 @@ export class PauseMongoService { try { const { pauseResumeQueue } = require('../queues/pauseResumeQueue'); if (pauseResumeQueue) { - const jobId = `resume-${workflow.requestNumber}-${level._id.toString()}`; + const jobId = `resume-${workflow.requestId}-${level._id.toString()}`; const specificJob = await pauseResumeQueue.getJob(jobId); if (specificJob) await specificJob.remove(); } @@ -289,7 +285,7 @@ export class PauseMongoService { // We need alert status from level const alerts = level.alerts || {}; await tatScheduler.scheduleTatJobsOnResume( - workflow.requestNumber, + workflow.requestId, level._id.toString(), level.approver?.userId || '', remainingHours, @@ -316,7 +312,7 @@ export class PauseMongoService { await notificationMongoService.sendToUsers([initiatorId], { title: 'Workflow Resumed', body: `Your request "${workflow.title}" has been resumed ${userId ? `by ${userName}` : 'automatically'}.`, - requestId: workflow.requestNumber, + requestId: workflow.requestId, requestNumber: workflow.requestNumber, type: 'workflow_resumed', priority: 'HIGH', @@ -328,16 +324,16 @@ export class PauseMongoService { await notificationMongoService.sendToUsers([userId], { title: 'Workflow Resumed Successfully', body: `You have resumed request "${workflow.title}".`, - requestId: workflow.requestNumber, + requestId: workflow.requestId, requestNumber: workflow.requestNumber, type: 'status_change', priority: 'MEDIUM' }); } - // Log Activity + // Log Activity - Standardized to UUID await activityMongoService.log({ - requestId: workflow.requestNumber, + requestId: workflow.requestId, type: 'resumed', user: userId ? { userId, name: userName } : undefined, timestamp: now.toISOString(), @@ -350,8 +346,9 @@ export class PauseMongoService { try { const { emitToRequestRoom } = require('../realtime/socket'); if (emitToRequestRoom) { - emitToRequestRoom(workflow.requestNumber, 'request:updated', { - requestId: workflow.requestNumber, + emitToRequestRoom(workflow.requestId, 'request:updated', { + requestId: workflow.requestId, + requestNumber: workflow.requestNumber, action: 'RESUME', timestamp: now.toISOString() }); @@ -389,14 +386,15 @@ export class PauseMongoService { await notificationMongoService.sendToUsers([pausedBy], { title: 'Pause Retrigger Request', body: `${initiator?.displayName || 'The initiator'} is requesting you to resume work on request "${workflow.title}".`, - requestId, + requestId: workflow.requestId, requestNumber: workflow.requestNumber, url: `/request/${workflow.requestNumber}`, type: 'pause_retrigger_request' }); + // Log Activity - Standardized to UUID await activityMongoService.log({ - requestId: workflow.requestNumber, + requestId: workflow.requestId, type: 'pause_retriggered', user: { userId, name: initiator?.displayName || 'Initiator' }, timestamp: new Date().toISOString(), diff --git a/src/services/summary.service.ts b/src/services/summary.service.ts index 1939873..42511fd 100644 --- a/src/services/summary.service.ts +++ b/src/services/summary.service.ts @@ -339,6 +339,10 @@ export class SummaryService { async getSummaryDetailsBySharedId(sharedSummaryId: string, userId: string): Promise { // Search for request summary containing this shared item // sharedSummaryId might be the subdoc _id + if (!mongoose.Types.ObjectId.isValid(sharedSummaryId)) { + throw new Error('Shared link not valid (format)'); + } + const summary = await RequestSummary.findOne({ "sharedWith._id": sharedSummaryId }); if (!summary) throw new Error('Shared link not valid'); diff --git a/src/services/workflow.service.ts b/src/services/workflow.service.ts index 179b462..e3dfdbe 100644 --- a/src/services/workflow.service.ts +++ b/src/services/workflow.service.ts @@ -8,6 +8,7 @@ import logger from '../utils/logger'; import { notificationMongoService } from './notification.service'; import { activityMongoService } from './activity.service'; import { tatSchedulerMongoService } from './tatScheduler.service'; +import { conclusionMongoService } from './conclusion.service'; import { addWorkingHours, addWorkingHoursExpress, calculateSLAStatus, formatTime } from '../utils/tatTimeUtils'; const tatScheduler = tatSchedulerMongoService; @@ -48,6 +49,14 @@ export class WorkflowServiceMongo { return WorkflowServiceMongo._supportsTransactions; } + /** + * Public helper to resolve requestId (UUID) from either UUID or requestNumber + */ + async resolveRequestId(identifier: string): Promise { + const request = await this.findRequest(identifier); + return request ? request.requestId : null; + } + /** * Internal helper to find a workflow request by either UUID or request number */ @@ -307,7 +316,7 @@ export class WorkflowServiceMongo { // Update Parent Request request.currentLevel = nextLevelNum; - request.status = 'IN_PROGRESS'; + request.status = 'PENDING'; await request.save(); // SCHEDULE TAT for Next Level @@ -378,6 +387,11 @@ export class WorkflowServiceMongo { actionRequired: false }); + // Trigger automatic AI conclusion generation + conclusionMongoService.generateAndSaveAIConclusion(request.requestId).catch(err => { + logger.error(`[WorkflowService] Deferred AI conclusion error for ${request.requestId}:`, err); + }); + return `Approved Level ${currentLevelNum}. Workflow COMPLETED.`; } @@ -421,6 +435,11 @@ export class WorkflowServiceMongo { request.conclusionRemark = comments; await request.save(); + // Trigger AI conclusion generation + conclusionMongoService.generateAndSaveAIConclusion(request.requestId).catch(err => { + logger.error(`[WorkflowService] Deferred AI conclusion error for ${request.requestId} (Rejection):`, err); + }); + // Fetch rejecter const rejecter = await UserModel.findOne({ userId }); @@ -700,7 +719,7 @@ export class WorkflowServiceMongo { await nextLevel.save(sessionOpt); request.currentLevel = nextLevelNum; - request.status = 'IN_PROGRESS'; + request.status = 'PENDING'; await request.save(sessionOpt); // Schedule TAT for next level (if outside transaction) @@ -712,6 +731,14 @@ export class WorkflowServiceMongo { request.closureDate = new Date(); request.conclusionRemark = 'Workflow Completed (skipped final level)'; await request.save(sessionOpt); + + // Trigger AI conclusion generation (after session commit if outside transaction, or here if inside) + // Note: If using transaction, might be better to trigger after commit + if (!useTransaction) { + conclusionMongoService.generateAndSaveAIConclusion(request.requestId).catch(err => { + logger.error(`[WorkflowService] Deferred AI conclusion error for ${request.requestId} (Complete):`, err); + }); + } } if (useTransaction) await session.commitTransaction(); @@ -760,6 +787,11 @@ export class WorkflowServiceMongo { requestNumber: request.requestNumber, actionRequired: false }); + + // Trigger automatic AI conclusion generation + conclusionMongoService.generateAndSaveAIConclusion(request.requestId).catch(err => { + logger.error(`[WorkflowService] Deferred AI conclusion error for ${request.requestId} (After skipped final level):`, err); + }); } return level; @@ -1277,7 +1309,7 @@ export class WorkflowServiceMongo { } // 4. Sort & Pagination - const sortField = sortBy || 'createdAt'; + const sortField = sortBy === 'created' ? 'createdAt' : (sortBy || 'createdAt'); const sortDir = sortOrder?.toLowerCase() === 'asc' ? 1 : -1; pipeline.push( @@ -1906,7 +1938,7 @@ export class WorkflowServiceMongo { const activatedLevel1 = await ApprovalLevelModel.findOneAndUpdate( { requestId: workflow.requestId, levelNumber: 1 }, { - status: 'PENDING', + status: 'IN_PROGRESS', 'tat.startTime': now, 'tat.endTime': endTime }, diff --git a/src/services/worknote.service.ts b/src/services/worknote.service.ts index d01eaaf..60c4f70 100644 --- a/src/services/worknote.service.ts +++ b/src/services/worknote.service.ts @@ -103,21 +103,24 @@ export class WorkNoteMongoService { } } - // 3. Log Activity - await activityMongoService.log({ - requestId, - type: 'comment', - user: { userId: user.userId, name: user.name || 'User' }, - timestamp: new Date().toISOString(), - action: 'Activity', - details: `${user.name || 'User'} added a work note: ${payload.message.substring(0, 100)}`, - category: 'COMMENT', - severity: 'INFO' - }); + // 3. (REMOVED) Log Activity - Redundant as worknotes are managed separately // 4. Send Notifications await this.handleNotifications(requestId, user, payload, note); + // 5. Emit Real-time Socket Update for "Smooth Chat" + try { + const { emitToRequestRoom } = require('../realtime/socket'); + const socketPayload = { + ...note.toJSON(), + attachments: await this.getAttachments(note.noteId) + }; + emitToRequestRoom(requestId, 'worknote:new', socketPayload); + logger.info(`[WorkNote] Emitted real-time update to room request:${requestId}`); + } catch (socketError) { + logger.warn('[WorkNote] Failed to emit socket update:', socketError); + } + return note; } catch (error) { logger.error('[WorkNote Mongo Service] Error creating note:', error); @@ -170,10 +173,12 @@ export class WorkNoteMongoService { */ private async handleNotifications(requestId: string, user: any, payload: any, note: any) { try { - const workflow = await WorkflowRequestModel.findOne({ requestNumber: requestId }); + // Identifier is guaranteed to be UUID here + const workflow = await WorkflowRequestModel.findOne({ requestId }); if (!workflow) return; const recipients: string[] = []; + const mentionedUsersList = payload.mentionedUsers || []; // Basic logic: notify initiator if someone else posts, notify current approver if initiator posts if (user.userId !== workflow.initiator.userId) { @@ -185,7 +190,7 @@ export class WorkNoteMongoService { levelNumber: workflow.currentLevel }); - if (currentLevel && currentLevel.approver.userId !== user.userId) { + if (currentLevel && currentLevel.approver?.userId !== user.userId) { recipients.push(currentLevel.approver.userId); } @@ -202,22 +207,41 @@ export class WorkNoteMongoService { } } - // Add mentioned users - if (payload.mentionedUsers?.length) { - payload.mentionedUsers.forEach((uid: string) => { - if (!recipients.includes(uid) && uid !== user.userId) { - recipients.push(uid); + // Handle Mentions Specifically + if (mentionedUsersList.length > 0) { + const messageSnippet = payload.message.length > 50 + ? payload.message.substring(0, 47) + '...' + : payload.message; + + const mentionPayload = { + title: '💬 Mentioned in Work Note', + body: `${user.name || 'A user'} mentioned you in ${workflow.requestNumber}: "${messageSnippet}"`, + requestId, + requestNumber: workflow.requestNumber, + url: `/request/${workflow.requestNumber}`, + type: 'mention', + metadata: { noteId: note.noteId } + }; + + // Notify only mentioned users with the mention context + for (const uid of mentionedUsersList) { + if (uid !== user.userId) { + await notificationMongoService.sendToUsers([uid], mentionPayload); + // Also remove from general recipients to avoid double notification + const idx = recipients.indexOf(uid); + if (idx > -1) recipients.splice(idx, 1); } - }); + } } + // Notify others with general context if (recipients.length > 0) { await notificationMongoService.sendToUsers(recipients, { title: 'New Work Note', - body: `${user.name || 'A user'} added a note to ${requestId}`, + body: `${user.name || 'A user'} added a note to ${workflow.requestNumber}`, requestId, - requestNumber: requestId, - url: `/request/${requestId}`, + requestNumber: workflow.requestNumber, + url: `/request/${workflow.requestNumber}`, type: 'comment', metadata: { noteId: note.noteId } }); diff --git a/src/validators/workflow.validator.ts b/src/validators/workflow.validator.ts index 5541a34..00ed2b2 100644 --- a/src/validators/workflow.validator.ts +++ b/src/validators/workflow.validator.ts @@ -44,6 +44,7 @@ export const createWorkflowSchema = z.object({ priorityUi: z.string().optional(), templateId: z.string().optional(), ccList: z.array(z.any()).optional(), + isDraft: z.boolean().optional(), }); export const updateWorkflowSchema = z.object({ diff --git a/verify_request.ts b/verify_request.ts new file mode 100644 index 0000000..9f02951 --- /dev/null +++ b/verify_request.ts @@ -0,0 +1,39 @@ +import mongoose from 'mongoose'; +import { WorkflowRequestModel } from './src/models/mongoose/WorkflowRequest.schema'; +import { ApprovalLevelModel } from './src/models/mongoose/ApprovalLevel.schema'; +import dotenv from 'dotenv'; + +dotenv.config(); + +async function verify() { + try { + await mongoose.connect(process.env.MONGO_URI || ''); + console.log('Connected to MongoDB'); + + const requestNumber = 'REQ-2026-02-0010'; + const workflow = await WorkflowRequestModel.findOne({ requestNumber }); + + if (!workflow) { + console.log(`Request ${requestNumber} not found`); + } else { + console.log('Workflow Request found:', { + requestNumber: workflow.requestNumber, + status: workflow.status, + workflowState: workflow.workflowState, + isDraft: workflow.isDraft + }); + + const levels = await ApprovalLevelModel.find({ requestId: workflow.requestId }).sort({ levelNumber: 1 }); + console.log('Approval Levels:'); + levels.forEach(l => { + console.log(`Level ${l.levelNumber}: ${l.status}`); + }); + } + + await mongoose.disconnect(); + } catch (err) { + console.error(err); + } +} + +verify();