i have fixed additional approver issue in the calim request with more enhancement
This commit is contained in:
parent
d3ff1791ac
commit
7d74bc43bc
@ -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}`);
|
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], {
|
await notificationService.sendToUsers([nextApproverId], {
|
||||||
title: `Action required: ${(wf as any).requestNumber}`,
|
title: assignmentTitle,
|
||||||
body: `${(wf as any).title}`,
|
body: assignmentBody,
|
||||||
requestNumber: (wf as any).requestNumber,
|
requestNumber: (wf as any).requestNumber,
|
||||||
requestId: (wf as any).requestId,
|
requestId: (wf as any).requestId,
|
||||||
url: `/request/${(wf as any).requestNumber}`,
|
url: `/request/${(wf as any).requestNumber}`,
|
||||||
|
|||||||
@ -98,6 +98,9 @@ export class DealerClaimService {
|
|||||||
level: number;
|
level: number;
|
||||||
tat?: number | string;
|
tat?: number | string;
|
||||||
tatType?: 'hours' | 'days';
|
tatType?: 'hours' | 'days';
|
||||||
|
stepName?: string;
|
||||||
|
isAdditional?: boolean;
|
||||||
|
originalLevel?: number;
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
): Promise<WorkflowRequest> {
|
): Promise<WorkflowRequest> {
|
||||||
@ -121,11 +124,11 @@ export class DealerClaimService {
|
|||||||
|
|
||||||
// 2. Map and validate dealer user
|
// 2. Map and validate dealer user
|
||||||
const dealerCode = claimData.dealerCode;
|
const dealerCode = claimData.dealerCode;
|
||||||
// 0. Validate Dealer User (jobTitle='Dealer' and employeeId=dealerCode)
|
|
||||||
logger.info(`[DealerClaimService] Validating dealer for code: ${claimData.dealerCode}`);
|
logger.info(`[DealerClaimService] Validating dealer for code: ${claimData.dealerCode}`);
|
||||||
const dealerUser = await validateDealerUser(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 } });
|
const activityType = await ActivityType.findOne({ where: { title: claimData.activityType } });
|
||||||
if (activityType && activityType.creditPostingOn) {
|
if (activityType && activityType.creditPostingOn) {
|
||||||
// Fetch full dealer info (including external API data like itemGroup)
|
// Fetch full dealer info (including external API data like itemGroup)
|
||||||
@ -172,8 +175,6 @@ export class DealerClaimService {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (!isValidUUID(userId)) {
|
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}`);
|
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
|
// 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);
|
const localDealer = await findDealerLocally(claimData.dealerCode, claimData.dealerEmail);
|
||||||
if (localDealer) {
|
if (localDealer) {
|
||||||
logger.info(`[DealerClaimService] Enriched claim request with local dealer data: ${localDealer.dealerCode}`);
|
logger.info(`[DealerClaimService] Enriched claim request with local dealer data: ${localDealer.dealerCode}`);
|
||||||
@ -202,7 +202,7 @@ export class DealerClaimService {
|
|||||||
const transformedLevels = [];
|
const transformedLevels = [];
|
||||||
|
|
||||||
// Define step names mapping
|
// Define step names mapping
|
||||||
const stepNames: Record<number, string> = {
|
const standardStepNames: Record<number, string> = {
|
||||||
1: 'Dealer Proposal Submission',
|
1: 'Dealer Proposal Submission',
|
||||||
2: 'Requestor Evaluation',
|
2: 'Requestor Evaluation',
|
||||||
3: 'Department Lead Approval',
|
3: 'Department Lead Approval',
|
||||||
@ -210,54 +210,95 @@ export class DealerClaimService {
|
|||||||
5: 'Requestor Claim Approval'
|
5: 'Requestor Claim Approval'
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const a of claimData.approvers) {
|
// Determine the maximum level to correctly identify the final approver
|
||||||
let approverUserId = a.userId;
|
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"
|
if (claimData.approvers) {
|
||||||
let levelName = stepNames[a.level] || `Step ${a.level}`;
|
let nextStandardStep = 1;
|
||||||
|
for (const approver of claimData.approvers) {
|
||||||
|
let approverUserId = approver.userId;
|
||||||
|
const currentLevel = approver.level;
|
||||||
|
|
||||||
// If this is a Dealer-specific step (Step 1 or Step 4), ensure we use the validated dealerUser
|
// Determine if this is an additional approver or a standard step
|
||||||
if (a.level === 1 || a.level === 4) {
|
// Rule: If isAdditional flag is set OR it has a custom stepName (not in standard names),
|
||||||
logger.info(`[DealerClaimService] Assigning validated dealer user to ${levelName} (Step ${a.level})`);
|
// it's an insertion and doesn't consume a standard step slot.
|
||||||
approverUserId = dealerUser.userId;
|
const isAdditional = (approver as any).isAdditional === true;
|
||||||
a.email = dealerUser.email;
|
const customName = (approver as any).stepName;
|
||||||
a.name = dealerUser.displayName || dealerUser.email;
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If userId missing, ensure user exists by email
|
// Determine level name
|
||||||
if (!approverUserId && a.email) {
|
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 {
|
try {
|
||||||
const user = await userService.ensureUserExists({ email: a.email });
|
const user = await userService.ensureUserExists({
|
||||||
|
email: approver.email,
|
||||||
|
userId: isNotUuid ? approverUserId : undefined
|
||||||
|
});
|
||||||
approverUserId = user.userId;
|
approverUserId = user.userId;
|
||||||
|
|
||||||
|
if (isNotUuid) {
|
||||||
|
logger.info(`[DealerClaimService] Resolved Okta ID ${approver.userId} to UUID ${approverUserId} for ${approver.email}`);
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.warn(`[DealerClaimService] Could not resolve user for email ${a.email}:`, e);
|
logger.warn(`[DealerClaimService] Could not resolve user for email ${approver.email}:`, e);
|
||||||
// If it fails, keep it empty and let the workflow service handle it (or fail early)
|
if (isNotUuid) {
|
||||||
|
approverUserId = '';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let tatHours = 24; // Default
|
let tatHours = 24; // Default
|
||||||
if (a.tat) {
|
if (approver.tat) {
|
||||||
const val = typeof a.tat === 'number' ? a.tat : parseInt(a.tat as string);
|
const val = typeof approver.tat === 'number' ? approver.tat : parseInt(approver.tat as string);
|
||||||
tatHours = a.tatType === 'days' ? val * 24 : val;
|
tatHours = (approver as any).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({
|
transformedLevels.push({
|
||||||
levelNumber: a.level,
|
levelNumber: currentLevel,
|
||||||
levelName: levelName,
|
levelName: levelName,
|
||||||
approverId: approverUserId || '', // Fallback to empty string if still not resolved
|
approverId: approverUserId || '',
|
||||||
approverEmail: a.email,
|
approverEmail: approver.email,
|
||||||
approverName: a.name || a.email,
|
approverName: approver.name || approver.email,
|
||||||
tatHours: tatHours,
|
tatHours: tatHours,
|
||||||
// New 5-step flow: Level 5 is the final approver (Requestor Claim Approval)
|
isFinalApprover: currentLevel === maxLevel
|
||||||
isFinalApprover: a.level === 5
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 2. Transform participants
|
// 2. Transform participants
|
||||||
const transformedParticipants = [
|
const transformedParticipants = [
|
||||||
@ -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',
|
templateType: 'DEALER CLAIM',
|
||||||
workflowType: 'CLAIM_MANAGEMENT',
|
workflowType: 'CLAIM_MANAGEMENT',
|
||||||
title: `Dealer Claim: ${sanitizedName} (${dealerCode})`,
|
title: `Dealer Claim: ${sanitizedName} (${dealerCode})`,
|
||||||
@ -289,11 +333,11 @@ export class DealerClaimService {
|
|||||||
priority: (claimData as any).priority || Priority.STANDARD,
|
priority: (claimData as any).priority || Priority.STANDARD,
|
||||||
approvalLevels: transformedLevels,
|
approvalLevels: transformedLevels,
|
||||||
participants: transformedParticipants,
|
participants: transformedParticipants,
|
||||||
isDraft: false
|
isDraft: true // Create as draft initially
|
||||||
} as any, { transaction, ipAddress: null, userAgent: 'System/DealerClaimService' });
|
} as any, { transaction, ipAddress: null, userAgent: 'System/DealerClaimService' });
|
||||||
|
|
||||||
// Create claim details
|
// Create claim details
|
||||||
const claimDetails = await DealerClaimDetails.create({
|
await DealerClaimDetails.create({
|
||||||
requestId: workflow.requestId,
|
requestId: workflow.requestId,
|
||||||
activityName: sanitizedName,
|
activityName: sanitizedName,
|
||||||
activityType: claimData.activityType,
|
activityType: claimData.activityType,
|
||||||
@ -308,7 +352,7 @@ export class DealerClaimService {
|
|||||||
periodEndDate: claimData.periodEndDate,
|
periodEndDate: claimData.periodEndDate,
|
||||||
} as any, { transaction });
|
} as any, { transaction });
|
||||||
|
|
||||||
// Initialize budget tracking with initial estimated budget (if provided)
|
// Initialize budget tracking
|
||||||
await ClaimBudgetTracking.upsert({
|
await ClaimBudgetTracking.upsert({
|
||||||
requestId: workflow.requestId,
|
requestId: workflow.requestId,
|
||||||
initialEstimatedBudget: claimData.estimatedBudget,
|
initialEstimatedBudget: claimData.estimatedBudget,
|
||||||
@ -318,19 +362,20 @@ export class DealerClaimService {
|
|||||||
|
|
||||||
// 3. Commit transaction
|
// 3. Commit transaction
|
||||||
await transaction.commit();
|
await transaction.commit();
|
||||||
|
logger.info(`[DealerClaimService] Transaction committed for workflow: ${workflow.requestNumber}`);
|
||||||
|
|
||||||
logger.info(`[DealerClaimService] Created claim request: ${workflow.requestNumber}`);
|
// 4. Submit the workflow - this triggers notifications after transaction commit
|
||||||
return workflow;
|
// 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) {
|
} catch (error: any) {
|
||||||
// Rollback transaction on error
|
|
||||||
if (transaction) await transaction.rollback();
|
if (transaction) await transaction.rollback();
|
||||||
// Log detailed error information for debugging
|
|
||||||
const errorDetails: any = {
|
const errorDetails: any = {
|
||||||
message: error.message,
|
message: error.message,
|
||||||
name: error.name,
|
name: error.name,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Sequelize validation errors
|
|
||||||
if (error.errors && Array.isArray(error.errors)) {
|
if (error.errors && Array.isArray(error.errors)) {
|
||||||
errorDetails.validationErrors = error.errors.map((e: any) => ({
|
errorDetails.validationErrors = error.errors.map((e: any) => ({
|
||||||
field: e.path,
|
field: e.path,
|
||||||
@ -338,8 +383,6 @@ export class DealerClaimService {
|
|||||||
value: e.value,
|
value: e.value,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sequelize database errors
|
|
||||||
if (error.parent) {
|
if (error.parent) {
|
||||||
errorDetails.databaseError = {
|
errorDetails.databaseError = {
|
||||||
message: error.parent.message,
|
message: error.parent.message,
|
||||||
@ -347,7 +390,6 @@ export class DealerClaimService {
|
|||||||
detail: error.parent.detail,
|
detail: error.parent.detail,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.error('[DealerClaimService] Error creating claim request:', errorDetails);
|
logger.error('[DealerClaimService] Error creating claim request:', errorDetails);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@ -380,8 +422,6 @@ export class DealerClaimService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Step definitions with default TAT (only manual approval steps)
|
// 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 = [
|
const stepDefinitions = [
|
||||||
{ level: 1, name: 'Dealer Proposal Submission', defaultTat: 72, isAuto: false },
|
{ level: 1, name: 'Dealer Proposal Submission', defaultTat: 72, isAuto: false },
|
||||||
{ level: 2, name: 'Requestor Evaluation', defaultTat: 48, 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
|
// Track which original steps have been processed
|
||||||
const processedOriginalSteps = new Set<number>();
|
const processedOriginalSteps = new Set<number>();
|
||||||
|
|
||||||
|
// 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
|
// Process approvers in order by their level
|
||||||
for (const approver of sortedApprovers) {
|
for (const approver of sortedApprovers) {
|
||||||
let approverId: string | null = null;
|
let approverId: string | null = null;
|
||||||
@ -403,22 +448,17 @@ export class DealerClaimService {
|
|||||||
let approverName = 'System';
|
let approverName = 'System';
|
||||||
let tatHours = 48; // Default TAT
|
let tatHours = 48; // Default TAT
|
||||||
let levelName = '';
|
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)
|
// Check if this is a system step by email (for backwards compatibility)
|
||||||
const systemEmails = [`system@${appDomain}`];
|
const systemEmails = [`system@${appDomain}`];
|
||||||
const financeEmails = [`finance@${appDomain}`];
|
const financeEmails = [`finance@${appDomain}`];
|
||||||
const isSystemEmail = systemEmails.includes(approver.email) || financeEmails.includes(approver.email);
|
const isSystemEmail = systemEmails.includes(approver.email) || financeEmails.includes(approver.email);
|
||||||
|
|
||||||
|
let stepDef = null;
|
||||||
|
|
||||||
if (approver.isAdditional) {
|
if (approver.isAdditional) {
|
||||||
// Additional approver - use stepName from frontend
|
// Additional approver - use stepName from frontend
|
||||||
levelName = approver.stepName || 'Additional Approver';
|
levelName = approver.stepName || 'Additional Approver';
|
||||||
isSystemStep = false;
|
|
||||||
isFinalApprover = false;
|
|
||||||
} else {
|
} else {
|
||||||
// Fixed step - find by originalStepLevel first, then by matching level
|
// Fixed step - find by originalStepLevel first, then by matching level
|
||||||
const originalLevel = approver.originalStepLevel || approver.level;
|
const originalLevel = approver.originalStepLevel || approver.level;
|
||||||
@ -430,48 +470,26 @@ export class DealerClaimService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// System steps (Activity Creation, E-Invoice Generation, Credit Note Confirmation) are no longer approval steps
|
// 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) {
|
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
|
continue; // Skip creating approval level for system steps
|
||||||
}
|
}
|
||||||
|
|
||||||
if (stepDef) {
|
if (stepDef) {
|
||||||
levelName = stepDef.name;
|
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);
|
processedOriginalSteps.add(stepDef.level);
|
||||||
} else {
|
} else {
|
||||||
// Fallback - shouldn't happen but handle gracefully
|
// Fallback
|
||||||
levelName = `Step ${approver.level}`;
|
levelName = `Step ${approver.level}`;
|
||||||
isSystemStep = false;
|
logger.warn(`[DealerClaimService] Could not find step definition for approver at level ${approver.level}`);
|
||||||
logger.warn(`[DealerClaimService] Could not find step definition for approver at level ${approver.level}, using fallback name`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure levelName is never empty and truncate if too long (max 100 chars)
|
// Truncate levelName 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)
|
|
||||||
if (levelName.length > 100) {
|
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) + '...';
|
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)
|
// User-provided approver (fixed or additional)
|
||||||
if (!approver.email) {
|
if (!approver.email) {
|
||||||
throw new Error(`Approver email is required for level ${approver.level}: ${levelName}`);
|
throw new Error(`Approver email is required for level ${approver.level}: ${levelName}`);
|
||||||
@ -480,50 +498,42 @@ export class DealerClaimService {
|
|||||||
// Calculate TAT in hours
|
// Calculate TAT in hours
|
||||||
if (approver.tat) {
|
if (approver.tat) {
|
||||||
const tat = Number(approver.tat);
|
const tat = Number(approver.tat);
|
||||||
if (isNaN(tat) || tat <= 0) {
|
if (!isNaN(tat) && tat > 0) {
|
||||||
throw new Error(`Invalid TAT for level ${approver.level}. TAT must be a positive number.`);
|
|
||||||
}
|
|
||||||
tatHours = approver.tatType === 'days' ? tat * 24 : tat;
|
tatHours = approver.tatType === 'days' ? tat * 24 : tat;
|
||||||
|
}
|
||||||
} else if (stepDef) {
|
} else if (stepDef) {
|
||||||
tatHours = stepDef.defaultTat;
|
tatHours = stepDef.defaultTat;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure user exists in database (create from Okta if needed)
|
// Ensure user exists in database (create from Okta if needed)
|
||||||
let user: User | null = null;
|
let user: User | null = null;
|
||||||
|
const isValidUUIDLocal = (str: string): boolean => {
|
||||||
// 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;
|
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);
|
return uuidRegex.test(str);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Try to find user by userId if it's a valid UUID
|
// Try to find user by userId if it's a valid UUID
|
||||||
if (approver.userId && isValidUUID(approver.userId)) {
|
if (approver.userId && isValidUUIDLocal(approver.userId)) {
|
||||||
try {
|
try {
|
||||||
user = await User.findByPk(approver.userId);
|
user = await User.findByPk(approver.userId);
|
||||||
} catch (error: any) {
|
} 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}`);
|
||||||
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 not found by ID (or userId was not a valid UUID), try email
|
||||||
if (!user && approver.email) {
|
if (!user && approver.email) {
|
||||||
user = await User.findOne({ where: { email: approver.email.toLowerCase() } });
|
user = await User.findOne({ where: { email: approver.email.toLowerCase() } });
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
// User doesn't exist - create from Okta
|
|
||||||
logger.info(`[DealerClaimService] User ${approver.email} not found in DB, syncing from Okta`);
|
|
||||||
try {
|
try {
|
||||||
const userService = this.getUserService();
|
const userService = this.getUserService();
|
||||||
user = await userService.ensureUserExists({
|
user = await userService.ensureUserExists({
|
||||||
email: approver.email.toLowerCase(),
|
email: approver.email.toLowerCase(),
|
||||||
userId: approver.userId, // Pass Okta ID if provided (ensureUserExists will handle it)
|
userId: approver.userId,
|
||||||
}) as any;
|
}) as any;
|
||||||
logger.info(`[DealerClaimService] Successfully synced user ${approver.email} from Okta`);
|
|
||||||
} catch (oktaError: any) {
|
} catch (oktaError: any) {
|
||||||
logger.error(`[DealerClaimService] Failed to sync user from Okta: ${approver.email}`, oktaError);
|
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.`);
|
throw new Error(`User email '${approver.email}' not found in organization directory.`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -535,105 +545,37 @@ export class DealerClaimService {
|
|||||||
approverId = user.userId;
|
approverId = user.userId;
|
||||||
approverEmail = user.email;
|
approverEmail = user.email;
|
||||||
approverName = approver.name || user.displayName || user.email || 'Approver';
|
approverName = approver.name || user.displayName || user.email || 'Approver';
|
||||||
}
|
|
||||||
|
|
||||||
// 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 => {
|
|
||||||
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.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create approval level using the approver's level (which may be shifted)
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const isStep1 = approver.level === 1;
|
const isStep1 = approver.level === 1;
|
||||||
|
|
||||||
try {
|
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({
|
await ApprovalLevel.create({
|
||||||
requestId,
|
requestId,
|
||||||
levelNumber: approver.level, // Use the approver's level (may be shifted)
|
levelNumber: approver.level,
|
||||||
levelName: levelName, // Already validated and truncated above
|
levelName: levelName,
|
||||||
approverId: approverId,
|
approverId: approverId,
|
||||||
approverEmail: approverEmail || '',
|
approverEmail: approverEmail || '',
|
||||||
approverName: approverName || 'Unknown',
|
approverName: approverName || 'Unknown',
|
||||||
tatHours: tatHours || 0,
|
tatHours: tatHours || 0,
|
||||||
status: isStep1 ? ApprovalStatus.PENDING : ApprovalStatus.PENDING,
|
status: ApprovalStatus.PENDING,
|
||||||
isFinalApprover: isFinalApprover || false,
|
isFinalApprover: approver.level === maxLevel,
|
||||||
elapsedHours: 0,
|
elapsedHours: 0,
|
||||||
remainingHours: tatHours || 0,
|
remainingHours: tatHours || 0,
|
||||||
tatPercentageUsed: 0,
|
tatPercentageUsed: 0,
|
||||||
levelStartTime: isStep1 ? now : undefined,
|
levelStartTime: isStep1 ? now : undefined,
|
||||||
tatStartTime: isStep1 ? now : undefined,
|
tatStartTime: isStep1 ? now : undefined,
|
||||||
// Note: tatDays is NOT included - it's auto-calculated by the database
|
|
||||||
} as any);
|
} as any);
|
||||||
} catch (createError: any) {
|
} catch (createError: any) {
|
||||||
// Log detailed validation errors
|
logger.error(`[DealerClaimService] Failed to create approval level for level ${approver.level}:`, createError);
|
||||||
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);
|
|
||||||
throw new Error(`Failed to create approval level ${approver.level} (${levelName}): ${createError.message}`);
|
throw new Error(`Failed to create approval level ${approver.level} (${levelName}): ${createError.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate that required fixed steps were processed
|
// Validate that required fixed steps were processed
|
||||||
const requiredSteps = stepDefinitions.filter(s => !s.isAuto);
|
for (const step of stepDefinitions) {
|
||||||
for (const requiredStep of requiredSteps) {
|
if (!processedOriginalSteps.has(step.level)) {
|
||||||
if (!processedOriginalSteps.has(requiredStep.level)) {
|
logger.warn(`[DealerClaimService] Required step ${step.level} (${step.name}) was not found`);
|
||||||
logger.warn(`[DealerClaimService] Required step ${requiredStep.level} (${requiredStep.name}) was not found in approvers array`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -735,8 +677,33 @@ export class DealerClaimService {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Only try to find user if approverId is a valid UUID
|
// Only try to find user if approverId is a valid UUID
|
||||||
if (!isValidUUID(approverId)) {
|
if (!approverId || !isValidUUID(approverId)) {
|
||||||
logger.warn(`[DealerClaimService] Invalid UUID format for approverId: ${approverId}, skipping participant creation`);
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -247,9 +247,10 @@ export class DealerClaimApprovalService {
|
|||||||
if (nextLevel) {
|
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}`);
|
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),
|
// SPECIAL LOGIC: If we are moving to Dealer Completion Documents,
|
||||||
// check if this is a re-quotation flow. If so, skip Step 4 and move to Step 5.
|
// check if this is a re-quotation flow. If so, skip this step and move to the next one.
|
||||||
if (nextLevelNumber === 4) {
|
const currentNextLevelName = (nextLevel as any).levelName || '';
|
||||||
|
if (currentNextLevelName === 'Dealer Completion Documents') {
|
||||||
try {
|
try {
|
||||||
const { DealerClaimHistory, SnapshotType } = await import('@models/DealerClaimHistory');
|
const { DealerClaimHistory, SnapshotType } = await import('@models/DealerClaimHistory');
|
||||||
const reQuotationHistory = await DealerClaimHistory.findOne({
|
const reQuotationHistory = await DealerClaimHistory.findOne({
|
||||||
@ -261,27 +262,34 @@ export class DealerClaimApprovalService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (reQuotationHistory) {
|
if (reQuotationHistory) {
|
||||||
logger.info(`[DealerClaimApproval] Re-quotation detected in history for request ${level.requestId}. Skipping Step 4 (Completion Documents).`);
|
logger.info(`[DealerClaimApproval] Re-quotation detected in history for request ${level.requestId}. Skipping Dealer Completion Documents.`);
|
||||||
|
|
||||||
// Skip level 4
|
// Skip this level
|
||||||
await nextLevel.update({
|
await nextLevel.update({
|
||||||
status: ApprovalStatus.SKIPPED,
|
status: ApprovalStatus.SKIPPED,
|
||||||
comments: 'Skipped - Re-quotation flow'
|
comments: 'Skipped - Re-quotation flow'
|
||||||
});
|
});
|
||||||
|
|
||||||
// Find level 5
|
// Find the NEXT available level (regardless of its number)
|
||||||
const level5 = await ApprovalLevel.findOne({
|
const nextTarget = await ApprovalLevel.findOne({
|
||||||
where: { requestId: level.requestId, levelNumber: 5 }
|
where: {
|
||||||
|
requestId: level.requestId,
|
||||||
|
levelNumber: { [Op.gt]: nextLevel.levelNumber }
|
||||||
|
},
|
||||||
|
order: [['levelNumber', 'ASC']]
|
||||||
});
|
});
|
||||||
|
|
||||||
if (level5) {
|
if (nextTarget) {
|
||||||
logger.info(`[DealerClaimApproval] Redirecting to Step 5 for request ${level.requestId}`);
|
logger.info(`[DealerClaimApproval] Redirecting to next target level ${nextTarget.levelNumber} (${(nextTarget as any).levelName || 'unnamed'}) for request ${level.requestId}`);
|
||||||
nextLevel = level5; // Switch to level 5 as the "next level" to activate
|
nextLevel = nextTarget;
|
||||||
|
} else {
|
||||||
|
logger.info(`[DealerClaimApproval] No further steps after skipping Dealer Completion Documents for request ${level.requestId}`);
|
||||||
|
nextLevel = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (historyError) {
|
} catch (historyError) {
|
||||||
logger.error(`[DealerClaimApproval] Error checking re-quotation history:`, 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 {
|
try {
|
||||||
logger.info(`[DealerClaimApproval] Sending assignment notification to next approver: ${nextApproverName} (${nextApproverId}) at level ${nextLevelNumber} for request ${(wf as any).requestNumber}`);
|
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], {
|
await notificationService.sendToUsers([nextApproverId], {
|
||||||
title: `Action required: ${(wf as any).requestNumber}`,
|
title: assignmentTitle,
|
||||||
body: `${(wf as any).title}`,
|
body: assignmentBody,
|
||||||
requestNumber: (wf as any).requestNumber,
|
requestNumber: (wf as any).requestNumber,
|
||||||
requestId: (wf as any).requestId,
|
requestId: (wf as any).requestId,
|
||||||
url: `/request/${(wf as any).requestNumber}`,
|
url: `/request/${(wf as any).requestNumber}`,
|
||||||
@ -832,9 +852,10 @@ export class DealerClaimApprovalService {
|
|||||||
const isReQuotation = (action.rejectionReason || action.comments || '').includes('Revised Quotation Requested');
|
const isReQuotation = (action.rejectionReason || action.comments || '').includes('Revised Quotation Requested');
|
||||||
|
|
||||||
if (isReQuotation) {
|
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) {
|
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;
|
targetLevel = step1;
|
||||||
|
|
||||||
// Reset all intermediate levels (between Step 1 and current level)
|
// Reset all intermediate levels (between Step 1 and current level)
|
||||||
|
|||||||
@ -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<void> {
|
||||||
|
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
|
* 12. Send Dealer Proposal Required Email
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -300,6 +300,7 @@ class NotificationService {
|
|||||||
'workflow_paused': EmailNotificationType.WORKFLOW_PAUSED,
|
'workflow_paused': EmailNotificationType.WORKFLOW_PAUSED,
|
||||||
'approver_skipped': EmailNotificationType.APPROVER_SKIPPED,
|
'approver_skipped': EmailNotificationType.APPROVER_SKIPPED,
|
||||||
'spectator_added': EmailNotificationType.SPECTATOR_ADDED,
|
'spectator_added': EmailNotificationType.SPECTATOR_ADDED,
|
||||||
|
'participant_added': EmailNotificationType.PARTICIPANT_ADDED,
|
||||||
// Dealer Claim Specific
|
// Dealer Claim Specific
|
||||||
'proposal_submitted': EmailNotificationType.DEALER_PROPOSAL_SUBMITTED,
|
'proposal_submitted': EmailNotificationType.DEALER_PROPOSAL_SUBMITTED,
|
||||||
'activity_created': EmailNotificationType.ACTIVITY_CREATED,
|
'activity_created': EmailNotificationType.ACTIVITY_CREATED,
|
||||||
@ -1009,6 +1010,8 @@ class NotificationService {
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
case 'proposal_submitted':
|
case 'proposal_submitted':
|
||||||
{
|
{
|
||||||
// Get dealer and proposal data from metadata
|
// Get dealer and proposal data from metadata
|
||||||
@ -1164,6 +1167,29 @@ class NotificationService {
|
|||||||
}
|
}
|
||||||
break;
|
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:
|
default:
|
||||||
logger.info(`[Email] No email configured for notification type: ${notificationType}`);
|
logger.info(`[Email] No email configured for notification type: ${notificationType}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
// 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 workflowCurrentLevel = (workflow as any).currentLevel;
|
||||||
const isAddingAtCurrentLevel = targetLevel === workflowCurrentLevel;
|
const isAddingAtCurrentLevel = targetLevel === workflowCurrentLevel;
|
||||||
const shouldBeActive = isAddingAtCurrentLevel || isRequestApproved;
|
const shouldBeActive = isAddingAtCurrentLevel || isRequestApproved; // Create new approval level at target position
|
||||||
|
|
||||||
// Create new approval level at target position
|
|
||||||
const newLevel = await ApprovalLevel.create({
|
const newLevel = await ApprovalLevel.create({
|
||||||
requestId,
|
requestId,
|
||||||
levelNumber: targetLevel,
|
levelNumber: targetLevel,
|
||||||
@ -429,6 +427,8 @@ export class WorkflowService {
|
|||||||
tatStartTime: shouldBeActive ? new Date() : null
|
tatStartTime: shouldBeActive ? new Date() : null
|
||||||
} as any);
|
} as any);
|
||||||
|
|
||||||
|
// 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 request was APPROVED and we're adding a new approver, reactivate the request
|
||||||
if (isRequestApproved) {
|
if (isRequestApproved) {
|
||||||
// Change request status back to PENDING
|
// Change request status back to PENDING
|
||||||
@ -437,13 +437,13 @@ export class WorkflowService {
|
|||||||
currentLevel: targetLevel // Set new approver as current level
|
currentLevel: targetLevel // Set new approver as current level
|
||||||
} as any);
|
} as any);
|
||||||
logger.info(`[Workflow] Request ${requestId} status changed from APPROVED to PENDING - new approver added at level ${targetLevel}`);
|
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
|
|
||||||
} else {
|
} else {
|
||||||
// If adding after current level, update currentLevel to the new approver
|
// If we're adding at the current level, ensure currentLevel matches targetLevel
|
||||||
await workflow.update({ currentLevel: targetLevel } as any);
|
await workflow.update({ currentLevel: targetLevel } as any);
|
||||||
|
logger.info(`[Workflow] Updated workflow currentLevel to ${targetLevel} for active new approver`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
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)
|
// 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)
|
// Send notification to new additional approver (in-app, email, and web push)
|
||||||
// ADDITIONAL APPROVER NOTIFICATION: Uses 'assignment' type to trigger approval request email
|
if (shouldBeActive) {
|
||||||
// This works the same as regular approvers - they need to review and approve
|
// If immediately active, send 'assignment' (Action Required) email
|
||||||
|
// This will trigger the standard approval request email
|
||||||
await notificationService.sendToUsers([userId], {
|
await notificationService.sendToUsers([userId], {
|
||||||
title: 'New Request Assignment',
|
title: `Action required: ${(workflow as any).requestNumber}`,
|
||||||
body: `You have been added as Level ${targetLevel} approver to request ${(workflow as any).requestNumber}: ${(workflow as any).title}`,
|
body: `${(workflow as any).title}`,
|
||||||
requestId,
|
|
||||||
requestNumber: (workflow as any).requestNumber,
|
requestNumber: (workflow as any).requestNumber,
|
||||||
|
requestId: (workflow as any).requestId,
|
||||||
url: `/request/${(workflow as any).requestNumber}`,
|
url: `/request/${(workflow as any).requestNumber}`,
|
||||||
type: 'assignment', //: This triggers the approval request email notification
|
type: 'assignment',
|
||||||
priority: 'HIGH',
|
priority: 'HIGH',
|
||||||
actionRequired: true // Additional approvers need to take action
|
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}`);
|
logger.info(`[Workflow] Added approver ${email} at level ${targetLevel} to request ${requestId}`);
|
||||||
return newLevel;
|
return newLevel;
|
||||||
@ -3881,9 +3902,21 @@ export class WorkflowService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Send notification to FIRST APPROVER for assignment
|
// 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], {
|
await notificationService.sendToUsers([(current as any).approverId], {
|
||||||
title: 'New Request Assigned',
|
title: assignmentTitle,
|
||||||
body: `${workflowTitle}`,
|
body: assignmentBody,
|
||||||
requestNumber: requestNumber,
|
requestNumber: requestNumber,
|
||||||
requestId: actualRequestId,
|
requestId: actualRequestId,
|
||||||
url: `/request/${requestNumber}`,
|
url: `/request/${requestNumber}`,
|
||||||
@ -3891,6 +3924,7 @@ export class WorkflowService {
|
|||||||
priority: 'HIGH',
|
priority: 'HIGH',
|
||||||
actionRequired: true
|
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)
|
// Send notifications to SPECTATORS (in-app, email, and web push)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user