From 7d74bc43bc6115579475f880201871dafb33d710 Mon Sep 17 00:00:00 2001 From: laxmanhalaki Date: Fri, 20 Mar 2026 19:21:57 +0530 Subject: [PATCH] i have fixed additional approver issue in the calim request with more enhancement --- src/services/approval.service.ts | 16 +- src/services/dealerClaim.service.ts | 423 +++++++++----------- src/services/dealerClaimApproval.service.ts | 61 ++- src/services/emailNotification.service.ts | 90 +++++ src/services/notification.service.ts | 26 ++ src/services/workflow.service.ts | 96 +++-- 6 files changed, 431 insertions(+), 281 deletions(-) diff --git a/src/services/approval.service.ts b/src/services/approval.service.ts index 86a908c..da0452a 100644 --- a/src/services/approval.service.ts +++ b/src/services/approval.service.ts @@ -567,9 +567,21 @@ export class ApprovalService { logger.info(`[Approval] Sending assignment notification to next approver: ${nextApproverName} (${nextApproverId}) at level ${nextLevelNumber} for request ${(wf as any).requestNumber}`); + let assignmentTitle = `Action required: ${(wf as any).requestNumber}`; + let assignmentBody = `${(wf as any).title}`; + const nextLevelNameStr = (nextLevel as any).levelName || ''; + + if (nextLevelNameStr.toLowerCase().includes('proposal')) { + assignmentTitle = 'Proposal Required'; + assignmentBody = `A proposal is required for request ${(wf as any).requestNumber}: ${(wf as any).title}`; + } else if (nextLevelNameStr.toLowerCase().includes('completion') || nextLevelNameStr.toLowerCase().includes('documents')) { + assignmentTitle = 'Completion Documents Required'; + assignmentBody = `Completion documents are required for request ${(wf as any).requestNumber}: ${(wf as any).title}`; + } + await notificationService.sendToUsers([nextApproverId], { - title: `Action required: ${(wf as any).requestNumber}`, - body: `${(wf as any).title}`, + title: assignmentTitle, + body: assignmentBody, requestNumber: (wf as any).requestNumber, requestId: (wf as any).requestId, url: `/request/${(wf as any).requestNumber}`, diff --git a/src/services/dealerClaim.service.ts b/src/services/dealerClaim.service.ts index 0c16198..c613d23 100644 --- a/src/services/dealerClaim.service.ts +++ b/src/services/dealerClaim.service.ts @@ -98,6 +98,9 @@ export class DealerClaimService { level: number; tat?: number | string; tatType?: 'hours' | 'days'; + stepName?: string; + isAdditional?: boolean; + originalLevel?: number; }>; } ): Promise { @@ -121,11 +124,11 @@ export class DealerClaimService { // 2. Map and validate dealer user const dealerCode = claimData.dealerCode; - // 0. Validate Dealer User (jobTitle='Dealer' and employeeId=dealerCode) + logger.info(`[DealerClaimService] Validating dealer for code: ${claimData.dealerCode}`); const dealerUser = await validateDealerUser(claimData.dealerCode); - // 0a. Validate Dealer Item Group against Activity Credit Posting + // Validate Dealer Item Group against Activity Credit Posting const activityType = await ActivityType.findOne({ where: { title: claimData.activityType } }); if (activityType && activityType.creditPostingOn) { // Fetch full dealer info (including external API data like itemGroup) @@ -172,8 +175,6 @@ export class DealerClaimService { }; if (!isValidUUID(userId)) { - // If userId is not a UUID (might be Okta ID), try to find by email or other means - // This shouldn't happen in normal flow, but handle gracefully throw new Error(`Invalid initiator ID format. Expected UUID, got: ${userId}`); } @@ -183,7 +184,6 @@ export class DealerClaimService { } // Fallback: Enrichment from local dealer table if data is missing or incomplete - // We still keep this as a secondary fallback, but validation above is primary const localDealer = await findDealerLocally(claimData.dealerCode, claimData.dealerEmail); if (localDealer) { logger.info(`[DealerClaimService] Enriched claim request with local dealer data: ${localDealer.dealerCode}`); @@ -202,7 +202,7 @@ export class DealerClaimService { const transformedLevels = []; // Define step names mapping - const stepNames: Record = { + const standardStepNames: Record = { 1: 'Dealer Proposal Submission', 2: 'Requestor Evaluation', 3: 'Department Lead Approval', @@ -210,53 +210,94 @@ export class DealerClaimService { 5: 'Requestor Claim Approval' }; - for (const a of claimData.approvers) { - let approverUserId = a.userId; + // Determine the maximum level to correctly identify the final approver + const maxLevel = claimData.approvers && claimData.approvers.length > 0 + ? Math.max(...claimData.approvers.map(approver => approver.level)) + : 5; - // Determine level name - use mapped name or fallback to "Step X" - let levelName = stepNames[a.level] || `Step ${a.level}`; - - // If this is a Dealer-specific step (Step 1 or Step 4), ensure we use the validated dealerUser - if (a.level === 1 || a.level === 4) { - logger.info(`[DealerClaimService] Assigning validated dealer user to ${levelName} (Step ${a.level})`); - approverUserId = dealerUser.userId; - a.email = dealerUser.email; - a.name = dealerUser.displayName || dealerUser.email; - } - - // If userId missing, ensure user exists by email - if (!approverUserId && a.email) { - try { - const user = await userService.ensureUserExists({ email: a.email }); - approverUserId = user.userId; - } catch (e) { - logger.warn(`[DealerClaimService] Could not resolve user for email ${a.email}:`, e); - // If it fails, keep it empty and let the workflow service handle it (or fail early) + if (claimData.approvers) { + let nextStandardStep = 1; + for (const approver of claimData.approvers) { + let approverUserId = approver.userId; + const currentLevel = approver.level; + + // Determine if this is an additional approver or a standard step + // Rule: If isAdditional flag is set OR it has a custom stepName (not in standard names), + // it's an insertion and doesn't consume a standard step slot. + const isAdditional = (approver as any).isAdditional === true; + const customName = (approver as any).stepName; + const isStandardName = customName && Object.values(standardStepNames).includes(customName); + + // Determine originalLevel hint + let originalLevel = (approver as any).originalLevel; + if (!originalLevel) { + if (isAdditional || (customName && !isStandardName)) { + // This is an additional/custom step, it doesn't have a standard original level + originalLevel = undefined; + } else { + // This is a standard step, take the next available standard slot + originalLevel = nextStandardStep++; + } + } else if (!isAdditional) { + // If originalLevel is explicitly provided, sync our counter + nextStandardStep = Math.max(nextStandardStep, originalLevel + 1); } + + // Determine level name + let levelName = customName; + + if (!levelName) { + // If not provided, use originalLevel mapping or fallback to current level mapping + levelName = (originalLevel && standardStepNames[originalLevel]) || `Additional Approver`; + } + + // If this is a Dealer-specific step (originalLevel 1 or 4), ensure we use the validated dealerUser + if (originalLevel === 1 || originalLevel === 4) { + logger.info(`[DealerClaimService] Assigning validated dealer user to ${levelName} (Original Level ${originalLevel}, Current Level ${currentLevel})`); + approverUserId = dealerUser.userId; + approver.email = dealerUser.email; + approver.name = dealerUser.displayName || dealerUser.email; + } + + // Determine if current approverUserId is a valid UUID + const isNotUuid = approverUserId && !isValidUUID(approverUserId); + + // If userId missing or is not a valid UUID (e.g. an Okta ID), ensure user exists in local DB + if ((!approverUserId || isNotUuid) && approver.email) { + try { + const user = await userService.ensureUserExists({ + email: approver.email, + userId: isNotUuid ? approverUserId : undefined + }); + approverUserId = user.userId; + + if (isNotUuid) { + logger.info(`[DealerClaimService] Resolved Okta ID ${approver.userId} to UUID ${approverUserId} for ${approver.email}`); + } + } catch (e) { + logger.warn(`[DealerClaimService] Could not resolve user for email ${approver.email}:`, e); + if (isNotUuid) { + approverUserId = ''; + } + } + } + + let tatHours = 24; // Default + if (approver.tat) { + const val = typeof approver.tat === 'number' ? approver.tat : parseInt(approver.tat as string); + tatHours = (approver as any).tatType === 'days' ? val * 24 : val; + } + + transformedLevels.push({ + levelNumber: currentLevel, + levelName: levelName, + approverId: approverUserId || '', + approverEmail: approver.email, + approverName: approver.name || approver.email, + tatHours: tatHours, + isFinalApprover: currentLevel === maxLevel + }); } - - let tatHours = 24; // Default - if (a.tat) { - const val = typeof a.tat === 'number' ? a.tat : parseInt(a.tat as string); - tatHours = a.tatType === 'days' ? val * 24 : val; - } - - // Already determined levelName above - - // If it's an additional approver (not one of the standard steps), label it clearly - // Note: The frontend might send extra steps if approvers are added dynamically - // But for initial creation, we usually stick to the standard flow - - transformedLevels.push({ - levelNumber: a.level, - levelName: levelName, - approverId: approverUserId || '', // Fallback to empty string if still not resolved - approverEmail: a.email, - approverName: a.name || a.email, - tatHours: tatHours, - // New 5-step flow: Level 5 is the final approver (Requestor Claim Approval) - isFinalApprover: a.level === 5 - }); } // 2. Transform participants @@ -281,7 +322,10 @@ export class DealerClaimService { } } - const workflow = await this.getWorkflowService().createWorkflow(userId, { + // 2. Create the workflow as a DRAFT first + // This prevents immediate notification triggering within the transaction + const workflowService = this.getWorkflowService(); + const workflow = await workflowService.createWorkflow(userId, { templateType: 'DEALER CLAIM', workflowType: 'CLAIM_MANAGEMENT', title: `Dealer Claim: ${sanitizedName} (${dealerCode})`, @@ -289,11 +333,11 @@ export class DealerClaimService { priority: (claimData as any).priority || Priority.STANDARD, approvalLevels: transformedLevels, participants: transformedParticipants, - isDraft: false + isDraft: true // Create as draft initially } as any, { transaction, ipAddress: null, userAgent: 'System/DealerClaimService' }); // Create claim details - const claimDetails = await DealerClaimDetails.create({ + await DealerClaimDetails.create({ requestId: workflow.requestId, activityName: sanitizedName, activityType: claimData.activityType, @@ -308,7 +352,7 @@ export class DealerClaimService { periodEndDate: claimData.periodEndDate, } as any, { transaction }); - // Initialize budget tracking with initial estimated budget (if provided) + // Initialize budget tracking await ClaimBudgetTracking.upsert({ requestId: workflow.requestId, initialEstimatedBudget: claimData.estimatedBudget, @@ -318,19 +362,20 @@ export class DealerClaimService { // 3. Commit transaction await transaction.commit(); + logger.info(`[DealerClaimService] Transaction committed for workflow: ${workflow.requestNumber}`); - logger.info(`[DealerClaimService] Created claim request: ${workflow.requestNumber}`); - return workflow; + // 4. Submit the workflow - this triggers notifications after transaction commit + // This ensures that NotificationService (and DealerClaimEmailService) can find the claim details + const submittedWorkflow = await workflowService.submitWorkflow(workflow.requestId); + + logger.info(`[DealerClaimService] Submitted claim request: ${workflow.requestNumber}`); + return submittedWorkflow || workflow; } catch (error: any) { - // Rollback transaction on error if (transaction) await transaction.rollback(); - // Log detailed error information for debugging const errorDetails: any = { message: error.message, name: error.name, }; - - // Sequelize validation errors if (error.errors && Array.isArray(error.errors)) { errorDetails.validationErrors = error.errors.map((e: any) => ({ field: e.path, @@ -338,8 +383,6 @@ export class DealerClaimService { value: e.value, })); } - - // Sequelize database errors if (error.parent) { errorDetails.databaseError = { message: error.parent.message, @@ -347,7 +390,6 @@ export class DealerClaimService { detail: error.parent.detail, }; } - logger.error('[DealerClaimService] Error creating claim request:', errorDetails); throw error; } @@ -380,8 +422,6 @@ export class DealerClaimService { } // Step definitions with default TAT (only manual approval steps) - // Note: Activity Creation (was level 4), E-Invoice Generation (was level 7), and Credit Note Confirmation (was level 8) - // are now handled as activity logs only, not approval steps const stepDefinitions = [ { level: 1, name: 'Dealer Proposal Submission', defaultTat: 72, isAuto: false }, { level: 2, name: 'Requestor Evaluation', defaultTat: 48, isAuto: false }, @@ -396,6 +436,11 @@ export class DealerClaimService { // Track which original steps have been processed const processedOriginalSteps = new Set(); + // Determine the maximum level to correctly identify the final approver + const maxLevel = approvers.length > 0 + ? Math.max(...approvers.map(a => a.level)) + : 5; + // Process approvers in order by their level for (const approver of sortedApprovers) { let approverId: string | null = null; @@ -403,22 +448,17 @@ export class DealerClaimService { let approverName = 'System'; let tatHours = 48; // Default TAT let levelName = ''; - let isSystemStep = false; - let isFinalApprover = false; - - // Find the step definition this approver belongs to - let stepDef = null; // Check if this is a system step by email (for backwards compatibility) const systemEmails = [`system@${appDomain}`]; const financeEmails = [`finance@${appDomain}`]; const isSystemEmail = systemEmails.includes(approver.email) || financeEmails.includes(approver.email); + let stepDef = null; + if (approver.isAdditional) { // Additional approver - use stepName from frontend levelName = approver.stepName || 'Additional Approver'; - isSystemStep = false; - isFinalApprover = false; } else { // Fixed step - find by originalStepLevel first, then by matching level const originalLevel = approver.originalStepLevel || approver.level; @@ -430,210 +470,112 @@ export class DealerClaimService { } // System steps (Activity Creation, E-Invoice Generation, Credit Note Confirmation) are no longer approval steps - // They are handled as activity logs only - // If approver has system email but no step definition found, skip creating approval level if (!stepDef && isSystemEmail) { - logger.info(`[DealerClaimService] Skipping system step approver at level ${approver.level} - system steps are now activity logs only`); + logger.info(`[DealerClaimService] Skipping system step approver at level ${approver.level}`); continue; // Skip creating approval level for system steps } if (stepDef) { levelName = stepDef.name; - isSystemStep = false; // No system steps in approval levels anymore - isFinalApprover = stepDef.level === 5; // Last step is now Requestor Claim Approval (level 5) processedOriginalSteps.add(stepDef.level); } else { - // Fallback - shouldn't happen but handle gracefully + // Fallback levelName = `Step ${approver.level}`; - isSystemStep = false; - logger.warn(`[DealerClaimService] Could not find step definition for approver at level ${approver.level}, using fallback name`); + logger.warn(`[DealerClaimService] Could not find step definition for approver at level ${approver.level}`); } - // Ensure levelName is never empty and truncate if too long (max 100 chars) - if (!levelName || levelName.trim() === '') { - levelName = approver.isAdditional - ? `Additional Approver - Level ${approver.level}` - : `Step ${approver.level}`; - logger.warn(`[DealerClaimService] levelName was empty for approver at level ${approver.level}, using fallback: ${levelName}`); - } - - // Truncate levelName to max 100 characters (database constraint) + // Truncate levelName if too long (max 100 chars) if (levelName.length > 100) { - logger.warn(`[DealerClaimService] levelName too long (${levelName.length} chars) for level ${approver.level}, truncating to 100 chars`); levelName = levelName.substring(0, 97) + '...'; } } - // System steps are no longer created as approval levels - they are activity logs only - // This code path should not be reached anymore, but kept for safety - if (isSystemStep) { - logger.warn(`[DealerClaimService] System step detected but should not create approval level. Skipping.`); - continue; // Skip creating approval level for system steps + // User-provided approver (fixed or additional) + if (!approver.email) { + throw new Error(`Approver email is required for level ${approver.level}: ${levelName}`); } - { - // User-provided approver (fixed or additional) - if (!approver.email) { - throw new Error(`Approver email is required for level ${approver.level}: ${levelName}`); - } - - // Calculate TAT in hours - if (approver.tat) { - const tat = Number(approver.tat); - if (isNaN(tat) || tat <= 0) { - throw new Error(`Invalid TAT for level ${approver.level}. TAT must be a positive number.`); - } + // Calculate TAT in hours + if (approver.tat) { + const tat = Number(approver.tat); + if (!isNaN(tat) && tat > 0) { tatHours = approver.tatType === 'days' ? tat * 24 : tat; - } else if (stepDef) { - tatHours = stepDef.defaultTat; } - - // Ensure user exists in database (create from Okta if needed) - let user: User | null = null; - - // Helper function to check if a string is a valid UUID - const isValidUUID = (str: string): boolean => { - const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; - return uuidRegex.test(str); - }; - - // Try to find user by userId if it's a valid UUID - if (approver.userId && isValidUUID(approver.userId)) { - try { - user = await User.findByPk(approver.userId); - } catch (error: any) { - // If findByPk fails (e.g., invalid UUID format), log and continue to email lookup - logger.debug(`[DealerClaimService] Could not find user by userId ${approver.userId}, will try email lookup`); - } - } - - // If user not found by ID (or userId was not a valid UUID), try email - if (!user && approver.email) { - user = await User.findOne({ where: { email: approver.email.toLowerCase() } }); - - if (!user) { - // User doesn't exist - create from Okta - logger.info(`[DealerClaimService] User ${approver.email} not found in DB, syncing from Okta`); - try { - const userService = this.getUserService(); - user = await userService.ensureUserExists({ - email: approver.email.toLowerCase(), - userId: approver.userId, // Pass Okta ID if provided (ensureUserExists will handle it) - }) as any; - logger.info(`[DealerClaimService] Successfully synced user ${approver.email} from Okta`); - } catch (oktaError: any) { - logger.error(`[DealerClaimService] Failed to sync user from Okta: ${approver.email}`, oktaError); - throw new Error(`User email '${approver.email}' not found in organization directory. Please verify the email address.`); - } - } - } - - if (!user) { - throw new Error(`Could not resolve user for level ${approver.level}: ${approver.email}`); - } - - approverId = user.userId; - approverEmail = user.email; - approverName = approver.name || user.displayName || user.email || 'Approver'; + } else if (stepDef) { + tatHours = stepDef.defaultTat; } - // Ensure we have a valid approverId - if (!approverId) { - logger.error(`[DealerClaimService] No approverId resolved for level ${approver.level}, using initiator as fallback`); - approverId = initiatorId; - approverEmail = approverEmail || initiator.email; - approverName = approverName || 'Unknown Approver'; - } - - // Ensure approverId is a valid UUID before creating - const isValidUUID = (str: string): boolean => { + // Ensure user exists in database (create from Okta if needed) + let user: User | null = null; + const isValidUUIDLocal = (str: string): boolean => { const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; return uuidRegex.test(str); }; - if (!approverId || !isValidUUID(approverId)) { - logger.error(`[DealerClaimService] Invalid approverId for level ${approver.level}: ${approverId}`); - throw new Error(`Invalid approver ID format for level ${approver.level}. Expected UUID.`); + // Try to find user by userId if it's a valid UUID + if (approver.userId && isValidUUIDLocal(approver.userId)) { + try { + user = await User.findByPk(approver.userId); + } catch (error: any) { + logger.debug(`[DealerClaimService] Could not find user by userId ${approver.userId}`); + } } - // Create approval level using the approver's level (which may be shifted) + // If user not found by ID (or userId was not a valid UUID), try email + if (!user && approver.email) { + user = await User.findOne({ where: { email: approver.email.toLowerCase() } }); + if (!user) { + try { + const userService = this.getUserService(); + user = await userService.ensureUserExists({ + email: approver.email.toLowerCase(), + userId: approver.userId, + }) as any; + } catch (oktaError: any) { + logger.error(`[DealerClaimService] Failed to sync user from Okta: ${approver.email}`, oktaError); + throw new Error(`User email '${approver.email}' not found in organization directory.`); + } + } + } + + if (!user) { + throw new Error(`Could not resolve user for level ${approver.level}: ${approver.email}`); + } + + approverId = user.userId; + approverEmail = user.email; + approverName = approver.name || user.displayName || user.email || 'Approver'; + const now = new Date(); const isStep1 = approver.level === 1; try { - // Check for duplicate level_number for this request_id (unique constraint) - const existingLevel = await ApprovalLevel.findOne({ - where: { - requestId, - levelNumber: approver.level - } - }); - - if (existingLevel) { - logger.error(`[DealerClaimService] Duplicate level number ${approver.level} already exists for request ${requestId}`); - throw new Error(`Level ${approver.level} already exists for this request. This may indicate a duplicate approver.`); - } - await ApprovalLevel.create({ requestId, - levelNumber: approver.level, // Use the approver's level (may be shifted) - levelName: levelName, // Already validated and truncated above + levelNumber: approver.level, + levelName: levelName, approverId: approverId, approverEmail: approverEmail || '', approverName: approverName || 'Unknown', tatHours: tatHours || 0, - status: isStep1 ? ApprovalStatus.PENDING : ApprovalStatus.PENDING, - isFinalApprover: isFinalApprover || false, + status: ApprovalStatus.PENDING, + isFinalApprover: approver.level === maxLevel, elapsedHours: 0, remainingHours: tatHours || 0, tatPercentageUsed: 0, levelStartTime: isStep1 ? now : undefined, tatStartTime: isStep1 ? now : undefined, - // Note: tatDays is NOT included - it's auto-calculated by the database } as any); } catch (createError: any) { - // Log detailed validation errors - const errorDetails: any = { - message: createError.message, - name: createError.name, - level: approver.level, - levelName: levelName?.substring(0, 50), // Truncate for logging - approverId, - approverEmail, - approverName: approverName?.substring(0, 50), - tatHours, - }; - - // Sequelize validation errors - if (createError.errors && Array.isArray(createError.errors)) { - errorDetails.validationErrors = createError.errors.map((e: any) => ({ - field: e.path, - message: e.message, - value: e.value, - type: e.type, - })); - } - - // Database constraint errors - if (createError.parent) { - errorDetails.databaseError = { - message: createError.parent.message, - code: createError.parent.code, - detail: createError.parent.detail, - constraint: createError.parent.constraint, - }; - } - - logger.error(`[DealerClaimService] Failed to create approval level for level ${approver.level}:`, errorDetails); + logger.error(`[DealerClaimService] Failed to create approval level for level ${approver.level}:`, createError); throw new Error(`Failed to create approval level ${approver.level} (${levelName}): ${createError.message}`); } } // Validate that required fixed steps were processed - const requiredSteps = stepDefinitions.filter(s => !s.isAuto); - for (const requiredStep of requiredSteps) { - if (!processedOriginalSteps.has(requiredStep.level)) { - logger.warn(`[DealerClaimService] Required step ${requiredStep.level} (${requiredStep.name}) was not found in approvers array`); + for (const step of stepDefinitions) { + if (!processedOriginalSteps.has(step.level)) { + logger.warn(`[DealerClaimService] Required step ${step.level} (${step.name}) was not found`); } } } @@ -735,8 +677,33 @@ export class DealerClaimService { }; // Only try to find user if approverId is a valid UUID - if (!isValidUUID(approverId)) { - logger.warn(`[DealerClaimService] Invalid UUID format for approverId: ${approverId}, skipping participant creation`); + if (!approverId || !isValidUUID(approverId)) { + logger.warn(`[DealerClaimService] Missing or invalid UUID format for approverId: ${approverId}, attempting to resolve by email: ${approverEmail}`); + + if (approverEmail) { + try { + const userService = this.getUserService(); + const user = await userService.ensureUserExists({ + email: approverEmail.toLowerCase(), + userId: approverId && !isValidUUID(approverId) ? approverId : undefined + }); + // Update with resolved UUID + const resolvedUserId = user.userId; + + if (resolvedUserId && isValidUUID(resolvedUserId)) { + participantsToAdd.push({ + userId: resolvedUserId, + userEmail: user.email, + userName: user.displayName || user.email || 'Approver', + participantType: ParticipantType.APPROVER, + }); + addedUserIds.add(resolvedUserId); + continue; + } + } catch (err) { + logger.error(`[DealerClaimService] Failed to resolve participant by email ${approverEmail}:`, err); + } + } continue; } diff --git a/src/services/dealerClaimApproval.service.ts b/src/services/dealerClaimApproval.service.ts index c61d9f8..727c83f 100644 --- a/src/services/dealerClaimApproval.service.ts +++ b/src/services/dealerClaimApproval.service.ts @@ -247,9 +247,10 @@ export class DealerClaimApprovalService { if (nextLevel) { logger.info(`[DealerClaimApproval] Found next level: ${nextLevelNumber} (${(nextLevel as any).levelName || 'unnamed'}), approver: ${(nextLevel as any).approverName || (nextLevel as any).approverEmail || 'unknown'}, status: ${nextLevel.status}`); - // SPECIAL LOGIC: If we are moving to Step 4 (Dealer Completion Documents), - // check if this is a re-quotation flow. If so, skip Step 4 and move to Step 5. - if (nextLevelNumber === 4) { + // SPECIAL LOGIC: If we are moving to Dealer Completion Documents, + // check if this is a re-quotation flow. If so, skip this step and move to the next one. + const currentNextLevelName = (nextLevel as any).levelName || ''; + if (currentNextLevelName === 'Dealer Completion Documents') { try { const { DealerClaimHistory, SnapshotType } = await import('@models/DealerClaimHistory'); const reQuotationHistory = await DealerClaimHistory.findOne({ @@ -259,29 +260,36 @@ export class DealerClaimApprovalService { changeReason: { [Op.iLike]: '%Revised Quotation Requested%' } } }); - + if (reQuotationHistory) { - logger.info(`[DealerClaimApproval] Re-quotation detected in history for request ${level.requestId}. Skipping Step 4 (Completion Documents).`); - - // Skip level 4 + logger.info(`[DealerClaimApproval] Re-quotation detected in history for request ${level.requestId}. Skipping Dealer Completion Documents.`); + + // Skip this level await nextLevel.update({ status: ApprovalStatus.SKIPPED, comments: 'Skipped - Re-quotation flow' }); - - // Find level 5 - const level5 = await ApprovalLevel.findOne({ - where: { requestId: level.requestId, levelNumber: 5 } + + // Find the NEXT available level (regardless of its number) + const nextTarget = await ApprovalLevel.findOne({ + where: { + requestId: level.requestId, + levelNumber: { [Op.gt]: nextLevel.levelNumber } + }, + order: [['levelNumber', 'ASC']] }); - - if (level5) { - logger.info(`[DealerClaimApproval] Redirecting to Step 5 for request ${level.requestId}`); - nextLevel = level5; // Switch to level 5 as the "next level" to activate + + if (nextTarget) { + logger.info(`[DealerClaimApproval] Redirecting to next target level ${nextTarget.levelNumber} (${(nextTarget as any).levelName || 'unnamed'}) for request ${level.requestId}`); + nextLevel = nextTarget; + } else { + logger.info(`[DealerClaimApproval] No further steps after skipping Dealer Completion Documents for request ${level.requestId}`); + nextLevel = null; } } } catch (historyError) { logger.error(`[DealerClaimApproval] Error checking re-quotation history:`, historyError); - // Fallback: proceed to Step 4 normally if history check fails + // Fallback: proceed normally if history check fails } } @@ -613,9 +621,21 @@ export class DealerClaimApprovalService { try { logger.info(`[DealerClaimApproval] Sending assignment notification to next approver: ${nextApproverName} (${nextApproverId}) at level ${nextLevelNumber} for request ${(wf as any).requestNumber}`); + let assignmentTitle = `Action required: ${(wf as any).requestNumber}`; + let assignmentBody = `${(wf as any).title}`; + const nextLevelNameStr = (nextLevel as any).levelName || ''; + + if (nextLevelNameStr.toLowerCase().includes('proposal')) { + assignmentTitle = 'Proposal Required'; + assignmentBody = `A proposal is required for request ${(wf as any).requestNumber}: ${(wf as any).title}`; + } else if (nextLevelNameStr.toLowerCase().includes('completion') || nextLevelNameStr.toLowerCase().includes('documents')) { + assignmentTitle = 'Completion Documents Required'; + assignmentBody = `Completion documents are required for request ${(wf as any).requestNumber}: ${(wf as any).title}`; + } + await notificationService.sendToUsers([nextApproverId], { - title: `Action required: ${(wf as any).requestNumber}`, - body: `${(wf as any).title}`, + title: assignmentTitle, + body: assignmentBody, requestNumber: (wf as any).requestNumber, requestId: (wf as any).requestId, url: `/request/${(wf as any).requestNumber}`, @@ -832,9 +852,10 @@ export class DealerClaimApprovalService { const isReQuotation = (action.rejectionReason || action.comments || '').includes('Revised Quotation Requested'); if (isReQuotation) { - const step1 = allLevels.find(l => l.levelNumber === 1); + // Find Step 1 (Dealer Proposal Submission) by name (more robust than hardcoded level 1) + const step1 = allLevels.find(l => (l as any).levelName === 'Dealer Proposal Submission'); if (step1) { - logger.info(`[DealerClaimApproval] Re-quotation requested. Jumping to Step 1 for request ${level.requestId}`); + logger.info(`[DealerClaimApproval] Re-quotation requested. Jumping to Dealer Proposal Submission (${step1.levelNumber}) for request ${level.requestId}`); targetLevel = step1; // Reset all intermediate levels (between Step 1 and current level) diff --git a/src/services/emailNotification.service.ts b/src/services/emailNotification.service.ts index a8733d6..a03679e 100644 --- a/src/services/emailNotification.service.ts +++ b/src/services/emailNotification.service.ts @@ -934,6 +934,96 @@ export class EmailNotificationService { } } + /** + * 11a. Send Participant Added Email (for Additional Approvers) + */ + async sendParticipantAdded( + requestData: any, + participantData: any, + addedByData?: any, + initiatorData?: any + ): Promise { + try { + const canSend = await shouldSendEmail( + participantData.userId, + EmailNotificationType.PARTICIPANT_ADDED + ); + + if (!canSend) { + logger.info(`Email skipped (preferences): Participant Added for ${participantData.email}`); + return; + } + + // Get initiator name + let initiatorName = 'Initiator'; + if (initiatorData) { + initiatorName = initiatorData.displayName || initiatorData.email || 'Initiator'; + } else if (requestData.initiatorId) { + try { + const { User } = await import('@models/index'); + const initiator = await User.findByPk(requestData.initiatorId); + if (initiator) { + const initiatorJson = initiator.toJSON(); + initiatorName = initiatorJson.displayName || initiatorJson.email || 'Initiator'; + } + } catch (error) { + logger.warn(`Failed to fetch initiator for participant added email: ${error}`); + } + } + + // Get added by name + let addedByName: string | undefined; + if (addedByData) { + addedByName = addedByData.displayName || addedByData.email; + } + + // Get participant to check when they were added + const { Participant } = await import('@models/index'); + const participant = await Participant.findOne({ + where: { + requestId: requestData.requestId || requestData.workflowRequestId, + userId: participantData.userId + } + }); + + const addedDate = participant ? this.formatDate((participant as any).addedAt || new Date()) : this.formatDate(new Date()); + const addedTime = participant ? this.formatTime((participant as any).addedAt || new Date()) : this.formatTime(new Date()); + + const data: ParticipantAddedData = { + recipientName: participantData.displayName || participantData.email, + participantName: participantData.displayName || participantData.email, + participantRole: 'Approver', + addedByName: addedByName || 'Admin', + initiatorName: initiatorName, + requestId: requestData.requestNumber, + requestTitle: requestData.title, + requestType: getTemplateTypeLabel(requestData.templateType || requestData.workflowType), + currentStatus: requestData.status || 'PENDING', + addedDate: addedDate, + addedTime: addedTime, + requestDescription: requestData.description || '', + viewDetailsLink: getViewDetailsLink(requestData.requestNumber, this.frontendUrl), + companyName: CompanyInfo.name + }; + + const html = getParticipantAddedEmail(data); + const subject = `[${requestData.requestNumber}] Added as Additional Approver`; + + const result = await emailService.sendEmail({ + to: participantData.email, + subject, + html + }); + + if (result.previewUrl) { + logger.info(`📧 Participant Added Email Preview: ${result.previewUrl}`); + } + logger.info(`✅ Participant Added email sent to ${participantData.email} for request ${requestData.requestNumber}`); + } catch (error) { + logger.error(`Failed to send Participant Added email:`, error); + } + } + /** * 12. Send Dealer Proposal Required Email */ diff --git a/src/services/notification.service.ts b/src/services/notification.service.ts index 69db994..5a90e53 100644 --- a/src/services/notification.service.ts +++ b/src/services/notification.service.ts @@ -300,6 +300,7 @@ class NotificationService { 'workflow_paused': EmailNotificationType.WORKFLOW_PAUSED, 'approver_skipped': EmailNotificationType.APPROVER_SKIPPED, 'spectator_added': EmailNotificationType.SPECTATOR_ADDED, + 'participant_added': EmailNotificationType.PARTICIPANT_ADDED, // Dealer Claim Specific 'proposal_submitted': EmailNotificationType.DEALER_PROPOSAL_SUBMITTED, 'activity_created': EmailNotificationType.ACTIVITY_CREATED, @@ -1009,6 +1010,8 @@ class NotificationService { } break; + + case 'proposal_submitted': { // Get dealer and proposal data from metadata @@ -1164,6 +1167,29 @@ class NotificationService { } break; + case 'participant_added': + { + // Get the participant user + const participantUser = await User.findByPk(userId); + + if (!participantUser) { + logger.warn(`[Email] Participant user ${userId} not found`); + return; + } + + // Get the user who added the participant + const addedByUserId = payload.metadata?.addedBy; + const addedByUser = addedByUserId ? await User.findByPk(addedByUserId) : null; + + await emailNotificationService.sendParticipantAdded( + requestData, + participantUser.toJSON(), + addedByUser ? addedByUser.toJSON() : undefined, + initiatorData + ); + } + break; + default: logger.info(`[Email] No email configured for notification type: ${notificationType}`); } diff --git a/src/services/workflow.service.ts b/src/services/workflow.service.ts index 2fb22c5..c84dff6 100644 --- a/src/services/workflow.service.ts +++ b/src/services/workflow.service.ts @@ -411,9 +411,7 @@ export class WorkflowService { // If we're adding at the current level OR request was approved, the new approver becomes the active approver const workflowCurrentLevel = (workflow as any).currentLevel; const isAddingAtCurrentLevel = targetLevel === workflowCurrentLevel; - const shouldBeActive = isAddingAtCurrentLevel || isRequestApproved; - - // Create new approval level at target position + const shouldBeActive = isAddingAtCurrentLevel || isRequestApproved; // Create new approval level at target position const newLevel = await ApprovalLevel.create({ requestId, levelNumber: targetLevel, @@ -429,21 +427,23 @@ export class WorkflowService { tatStartTime: shouldBeActive ? new Date() : null } as any); - // If request was APPROVED and we're adding a new approver, reactivate the request - if (isRequestApproved) { - // Change request status back to PENDING - await workflow.update({ - status: WorkflowStatus.PENDING, - currentLevel: targetLevel // Set new approver as current level - } as any); - logger.info(`[Workflow] Request ${requestId} status changed from APPROVED to PENDING - new approver added at level ${targetLevel}`); - } else if (isAddingAtCurrentLevel) { - // If we're adding at the current level, the workflow's currentLevel stays the same - // (it's still the same level number, just with a new approver) - // No need to update workflow.currentLevel - it's already correct + // Only update workflow currentLevel if the new level is becoming the active one + if (shouldBeActive) { + // If request was APPROVED and we're adding a new approver, reactivate the request + if (isRequestApproved) { + // Change request status back to PENDING + await workflow.update({ + status: WorkflowStatus.PENDING, + currentLevel: targetLevel // Set new approver as current level + } as any); + logger.info(`[Workflow] Request ${requestId} status changed from APPROVED to PENDING - new approver added at level ${targetLevel}`); + } else { + // If we're adding at the current level, ensure currentLevel matches targetLevel + await workflow.update({ currentLevel: targetLevel } as any); + logger.info(`[Workflow] Updated workflow currentLevel to ${targetLevel} for active new approver`); + } } else { - // If adding after current level, update currentLevel to the new approver - await workflow.update({ currentLevel: targetLevel } as any); + logger.info(`[Workflow] New approver added as PENDING at level ${targetLevel}. Current level remains at ${workflowCurrentLevel}`); } // Update isFinalApprover for previous final approver (now it's not final anymore) @@ -498,18 +498,39 @@ export class WorkflowService { }); // Send notification to new additional approver (in-app, email, and web push) - // ADDITIONAL APPROVER NOTIFICATION: Uses 'assignment' type to trigger approval request email - // This works the same as regular approvers - they need to review and approve - await notificationService.sendToUsers([userId], { - title: 'New Request Assignment', - body: `You have been added as Level ${targetLevel} approver to request ${(workflow as any).requestNumber}: ${(workflow as any).title}`, - requestId, - requestNumber: (workflow as any).requestNumber, - url: `/request/${(workflow as any).requestNumber}`, - type: 'assignment', //: This triggers the approval request email notification - priority: 'HIGH', - actionRequired: true // Additional approvers need to take action - }); + if (shouldBeActive) { + // If immediately active, send 'assignment' (Action Required) email + // This will trigger the standard approval request email + await notificationService.sendToUsers([userId], { + title: `Action required: ${(workflow as any).requestNumber}`, + body: `${(workflow as any).title}`, + requestNumber: (workflow as any).requestNumber, + requestId: (workflow as any).requestId, + url: `/request/${(workflow as any).requestNumber}`, + type: 'assignment', + priority: 'HIGH', + actionRequired: true + }); + logger.info(`[Workflow] Sent assignment (Action Required) notification to active new approver ${email}`); + } else { + // If future approver, send 'participant_added' (Informational) email + // This informs the user they were added without asking for action yet + await notificationService.sendToUsers([userId], { + title: `Added as additional approver: ${(workflow as any).requestNumber}`, + body: `You have been added as a future approver for ${(workflow as any).title}`, + requestNumber: (workflow as any).requestNumber, + requestId: (workflow as any).requestId, + url: `/request/${(workflow as any).requestNumber}`, + type: 'participant_added', + priority: 'MEDIUM', + actionRequired: false, + metadata: { + addedBy: addedBy, + role: 'Approver' + } + }); + logger.info(`[Workflow] Sent participant_added (Info) notification to future approver ${email}`); + } logger.info(`[Workflow] Added approver ${email} at level ${targetLevel} to request ${requestId}`); return newLevel; @@ -3881,9 +3902,21 @@ export class WorkflowService { }); // Send notification to FIRST APPROVER for assignment + const firstLevelName = (current as any).levelName || ''; + let assignmentTitle = 'New Request Assigned'; + let assignmentBody = `${workflowTitle}`; + + if (firstLevelName.toLowerCase().includes('proposal')) { + assignmentTitle = 'Proposal Required'; + assignmentBody = `A proposal is required for request ${requestNumber}: ${workflowTitle}`; + } else if (firstLevelName.toLowerCase().includes('completion') || firstLevelName.toLowerCase().includes('documents')) { + assignmentTitle = 'Completion Documents Required'; + assignmentBody = `Completion documents are required for request ${requestNumber}: ${workflowTitle}`; + } + await notificationService.sendToUsers([(current as any).approverId], { - title: 'New Request Assigned', - body: `${workflowTitle}`, + title: assignmentTitle, + body: assignmentBody, requestNumber: requestNumber, requestId: actualRequestId, url: `/request/${requestNumber}`, @@ -3891,6 +3924,7 @@ export class WorkflowService { priority: 'HIGH', actionRequired: true }); + logger.info(`[Workflow] Sent assignment (Action Required) notification to active first approver ${(current as any).approverEmail}`); } // Send notifications to SPECTATORS (in-app, email, and web push)