Compare commits

..

No commits in common. "7d74bc43bc6115579475f880201871dafb33d710" and "d699a5f31c61c7a671a828b2c59bb9e0df38962f" have entirely different histories.

9 changed files with 288 additions and 468 deletions

View File

@ -60,7 +60,6 @@ function extractCsvFields(r: Record<string, string | undefined>) {
async function findCreditNoteId( async function findCreditNoteId(
trnsUniqNo: string | null, trnsUniqNo: string | null,
tdsTransId: string | null, tdsTransId: string | null,
claimNumber: string | null,
fileName: string, fileName: string,
): Promise<{ creditNoteId: number | null; requestId: string | null }> { ): Promise<{ creditNoteId: number | null; requestId: string | null }> {
const CN = Form16CreditNote as any; const CN = Form16CreditNote as any;
@ -94,12 +93,6 @@ async function findCreditNoteId(
} }
} }
// 4. CLAIM_NUMBER = credit note number (seen in some SAP/WFM exports)
if (!cn && claimNumber) {
cn = await CN.findOne({ where: { creditNoteNumber: claimNumber }, attributes: ['id', 'submissionId'] });
if (cn) logger.info(`[Form16 SAP Job] Credit match via CLAIM_NUMBER=${claimNumber} → credit_note id=${cn.id}`);
}
if (!cn) return { creditNoteId: null, requestId: null }; if (!cn) return { creditNoteId: null, requestId: null };
const submission = await (Form16aSubmission as any).findByPk(cn.submissionId, { attributes: ['requestId'] }); const submission = await (Form16aSubmission as any).findByPk(cn.submissionId, { attributes: ['requestId'] });
@ -232,7 +225,7 @@ async function processOutgoingFile(
let requestNumber: string | null = null; let requestNumber: string | null = null;
if (type === 'credit') { if (type === 'credit') {
const res = await findCreditNoteId(trnsUniqNo, tdsTransId, claimNumber, fileName); const res = await findCreditNoteId(trnsUniqNo, tdsTransId, fileName);
creditNoteId = res.creditNoteId; creditNoteId = res.creditNoteId;
requestId = res.requestId; requestId = res.requestId;
if (creditNoteId && sapDocNo) { if (creditNoteId && sapDocNo) {

View File

@ -567,21 +567,9 @@ 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: assignmentTitle, title: `Action required: ${(wf as any).requestNumber}`,
body: assignmentBody, body: `${(wf as any).title}`,
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}`,

View File

@ -98,9 +98,6 @@ 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> {
@ -124,11 +121,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);
// Validate Dealer Item Group against Activity Credit Posting // 0a. 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)
@ -175,6 +172,8 @@ 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}`);
} }
@ -184,6 +183,7 @@ 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 standardStepNames: Record<number, string> = { const stepNames: 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,94 +210,53 @@ export class DealerClaimService {
5: 'Requestor Claim Approval' 5: 'Requestor Claim Approval'
}; };
// Determine the maximum level to correctly identify the final approver for (const a of claimData.approvers) {
const maxLevel = claimData.approvers && claimData.approvers.length > 0 let approverUserId = a.userId;
? Math.max(...claimData.approvers.map(approver => approver.level))
: 5;
if (claimData.approvers) { // Determine level name - use mapped name or fallback to "Step X"
let nextStandardStep = 1; let levelName = stepNames[a.level] || `Step ${a.level}`;
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 // If this is a Dealer-specific step (Step 1 or Step 4), ensure we use the validated dealerUser
// Rule: If isAdditional flag is set OR it has a custom stepName (not in standard names), if (a.level === 1 || a.level === 4) {
// it's an insertion and doesn't consume a standard step slot. logger.info(`[DealerClaimService] Assigning validated dealer user to ${levelName} (Step ${a.level})`);
const isAdditional = (approver as any).isAdditional === true; approverUserId = dealerUser.userId;
const customName = (approver as any).stepName; a.email = dealerUser.email;
const isStandardName = customName && Object.values(standardStepNames).includes(customName); a.name = dealerUser.displayName || dealerUser.email;
// 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
});
} }
// 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)
}
}
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 // 2. Transform participants
@ -322,10 +281,7 @@ export class DealerClaimService {
} }
} }
// 2. Create the workflow as a DRAFT first const workflow = await this.getWorkflowService().createWorkflow(userId, {
// 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})`,
@ -333,11 +289,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: true // Create as draft initially isDraft: false
} as any, { transaction, ipAddress: null, userAgent: 'System/DealerClaimService' }); } as any, { transaction, ipAddress: null, userAgent: 'System/DealerClaimService' });
// Create claim details // Create claim details
await DealerClaimDetails.create({ const claimDetails = await DealerClaimDetails.create({
requestId: workflow.requestId, requestId: workflow.requestId,
activityName: sanitizedName, activityName: sanitizedName,
activityType: claimData.activityType, activityType: claimData.activityType,
@ -352,7 +308,7 @@ export class DealerClaimService {
periodEndDate: claimData.periodEndDate, periodEndDate: claimData.periodEndDate,
} as any, { transaction }); } as any, { transaction });
// Initialize budget tracking // Initialize budget tracking with initial estimated budget (if provided)
await ClaimBudgetTracking.upsert({ await ClaimBudgetTracking.upsert({
requestId: workflow.requestId, requestId: workflow.requestId,
initialEstimatedBudget: claimData.estimatedBudget, initialEstimatedBudget: claimData.estimatedBudget,
@ -362,20 +318,19 @@ export class DealerClaimService {
// 3. Commit transaction // 3. Commit transaction
await transaction.commit(); await transaction.commit();
logger.info(`[DealerClaimService] Transaction committed for workflow: ${workflow.requestNumber}`);
// 4. Submit the workflow - this triggers notifications after transaction commit logger.info(`[DealerClaimService] Created claim request: ${workflow.requestNumber}`);
// This ensures that NotificationService (and DealerClaimEmailService) can find the claim details return workflow;
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,
@ -383,6 +338,8 @@ 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,
@ -390,6 +347,7 @@ 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;
} }
@ -422,6 +380,8 @@ 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 },
@ -436,11 +396,6 @@ 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;
@ -448,17 +403,22 @@ 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;
@ -470,112 +430,210 @@ 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}`); logger.info(`[DealerClaimService] Skipping system step approver at level ${approver.level} - system steps are now activity logs only`);
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 // Fallback - shouldn't happen but handle gracefully
levelName = `Step ${approver.level}`; levelName = `Step ${approver.level}`;
logger.warn(`[DealerClaimService] Could not find step definition for approver at level ${approver.level}`); isSystemStep = false;
logger.warn(`[DealerClaimService] Could not find step definition for approver at level ${approver.level}, using fallback name`);
} }
// Truncate levelName if too long (max 100 chars) // 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)
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) + '...';
} }
} }
// User-provided approver (fixed or additional) // System steps are no longer created as approval levels - they are activity logs only
if (!approver.email) { // This code path should not be reached anymore, but kept for safety
throw new Error(`Approver email is required for level ${approver.level}: ${levelName}`); if (isSystemStep) {
logger.warn(`[DealerClaimService] System step detected but should not create approval level. Skipping.`);
continue; // Skip creating approval level for system steps
} }
// Calculate TAT in hours {
if (approver.tat) { // User-provided approver (fixed or additional)
const tat = Number(approver.tat); if (!approver.email) {
if (!isNaN(tat) && tat > 0) { throw new Error(`Approver email is required for level ${approver.level}: ${levelName}`);
tatHours = approver.tatType === 'days' ? tat * 24 : tat;
} }
} else if (stepDef) {
tatHours = stepDef.defaultTat; // 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.`);
}
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';
} }
// Ensure user exists in database (create from Okta if needed) // Ensure we have a valid approverId
let user: User | null = null; if (!approverId) {
const isValidUUIDLocal = (str: string): boolean => { 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; 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 if (!approverId || !isValidUUID(approverId)) {
if (approver.userId && isValidUUIDLocal(approver.userId)) { logger.error(`[DealerClaimService] Invalid approverId for level ${approver.level}: ${approverId}`);
try { throw new Error(`Invalid approver ID format for level ${approver.level}. Expected UUID.`);
user = await User.findByPk(approver.userId);
} catch (error: any) {
logger.debug(`[DealerClaimService] Could not find user by userId ${approver.userId}`);
}
} }
// If user not found by ID (or userId was not a valid UUID), try email // Create approval level using the approver's level (which may be shifted)
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 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, levelNumber: approver.level, // Use the approver's level (may be shifted)
levelName: levelName, levelName: levelName, // Already validated and truncated above
approverId: approverId, approverId: approverId,
approverEmail: approverEmail || '', approverEmail: approverEmail || '',
approverName: approverName || 'Unknown', approverName: approverName || 'Unknown',
tatHours: tatHours || 0, tatHours: tatHours || 0,
status: ApprovalStatus.PENDING, status: isStep1 ? ApprovalStatus.PENDING : ApprovalStatus.PENDING,
isFinalApprover: approver.level === maxLevel, isFinalApprover: isFinalApprover || false,
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) {
logger.error(`[DealerClaimService] Failed to create approval level for level ${approver.level}:`, createError); // 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);
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
for (const step of stepDefinitions) { const requiredSteps = stepDefinitions.filter(s => !s.isAuto);
if (!processedOriginalSteps.has(step.level)) { for (const requiredStep of requiredSteps) {
logger.warn(`[DealerClaimService] Required step ${step.level} (${step.name}) was not found`); if (!processedOriginalSteps.has(requiredStep.level)) {
logger.warn(`[DealerClaimService] Required step ${requiredStep.level} (${requiredStep.name}) was not found in approvers array`);
} }
} }
} }
@ -677,33 +735,8 @@ 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 (!approverId || !isValidUUID(approverId)) { if (!isValidUUID(approverId)) {
logger.warn(`[DealerClaimService] Missing or invalid UUID format for approverId: ${approverId}, attempting to resolve by email: ${approverEmail}`); logger.warn(`[DealerClaimService] Invalid UUID format for approverId: ${approverId}, skipping participant creation`);
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;
} }

View File

@ -247,10 +247,9 @@ 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 Dealer Completion Documents, // SPECIAL LOGIC: If we are moving to Step 4 (Dealer Completion Documents),
// check if this is a re-quotation flow. If so, skip this step and move to the next one. // check if this is a re-quotation flow. If so, skip Step 4 and move to Step 5.
const currentNextLevelName = (nextLevel as any).levelName || ''; if (nextLevelNumber === 4) {
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({
@ -262,34 +261,27 @@ export class DealerClaimApprovalService {
}); });
if (reQuotationHistory) { if (reQuotationHistory) {
logger.info(`[DealerClaimApproval] Re-quotation detected in history for request ${level.requestId}. Skipping Dealer Completion Documents.`); logger.info(`[DealerClaimApproval] Re-quotation detected in history for request ${level.requestId}. Skipping Step 4 (Completion Documents).`);
// Skip this level // Skip level 4
await nextLevel.update({ await nextLevel.update({
status: ApprovalStatus.SKIPPED, status: ApprovalStatus.SKIPPED,
comments: 'Skipped - Re-quotation flow' comments: 'Skipped - Re-quotation flow'
}); });
// Find the NEXT available level (regardless of its number) // Find level 5
const nextTarget = await ApprovalLevel.findOne({ const level5 = await ApprovalLevel.findOne({
where: { where: { requestId: level.requestId, levelNumber: 5 }
requestId: level.requestId,
levelNumber: { [Op.gt]: nextLevel.levelNumber }
},
order: [['levelNumber', 'ASC']]
}); });
if (nextTarget) { if (level5) {
logger.info(`[DealerClaimApproval] Redirecting to next target level ${nextTarget.levelNumber} (${(nextTarget as any).levelName || 'unnamed'}) for request ${level.requestId}`); logger.info(`[DealerClaimApproval] Redirecting to Step 5 for request ${level.requestId}`);
nextLevel = nextTarget; nextLevel = level5; // Switch to level 5 as the "next level" to activate
} 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 normally if history check fails // Fallback: proceed to Step 4 normally if history check fails
} }
} }
@ -621,21 +613,9 @@ 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: assignmentTitle, title: `Action required: ${(wf as any).requestNumber}`,
body: assignmentBody, body: `${(wf as any).title}`,
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}`,
@ -852,10 +832,9 @@ 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) {
// Find Step 1 (Dealer Proposal Submission) by name (more robust than hardcoded level 1) const step1 = allLevels.find(l => l.levelNumber === 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 Dealer Proposal Submission (${step1.levelNumber}) for request ${level.requestId}`); logger.info(`[DealerClaimApproval] Re-quotation requested. Jumping to Step 1 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)

View File

@ -934,96 +934,6 @@ 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
*/ */

View File

@ -278,11 +278,7 @@ export async function listCreditNotesForDealer(userId: string, filters?: { finan
if (hasTrnsUniqNoColumn && noteIds.length) { if (hasTrnsUniqNoColumn && noteIds.length) {
try { try {
const sapRows = await (Form16SapResponse as any).findAll({ const sapRows = await (Form16SapResponse as any).findAll({
where: { where: { type: 'credit', creditNoteId: { [Op.in]: noteIds }, storageUrl: { [Op.ne]: null } },
type: 'credit',
creditNoteId: { [Op.in]: noteIds },
[Op.or]: [{ storageUrl: { [Op.ne]: null } }, { sapDocumentNumber: { [Op.ne]: null } }],
},
attributes: ['creditNoteId'], attributes: ['creditNoteId'],
raw: true, raw: true,
}); });
@ -887,11 +883,7 @@ export async function listAllCreditNotesForRe(filters?: { financialYear?: string
if (hasTrnsUniqNoColumn && noteIds.length) { if (hasTrnsUniqNoColumn && noteIds.length) {
try { try {
const sapRows = await (Form16SapResponse as any).findAll({ const sapRows = await (Form16SapResponse as any).findAll({
where: { where: { type: 'credit', creditNoteId: { [Op.in]: noteIds }, storageUrl: { [Op.ne]: null } },
type: 'credit',
creditNoteId: { [Op.in]: noteIds },
[Op.or]: [{ storageUrl: { [Op.ne]: null } }, { sapDocumentNumber: { [Op.ne]: null } }],
},
attributes: ['creditNoteId'], attributes: ['creditNoteId'],
raw: true, raw: true,
}); });
@ -1003,10 +995,7 @@ export async function listAllDebitNotesForRe(filters?: { financialYear?: string;
if (noteIds.length) { if (noteIds.length) {
try { try {
const sapRows = await (Form16DebitNoteSapResponse as any).findAll({ const sapRows = await (Form16DebitNoteSapResponse as any).findAll({
where: { where: { debitNoteId: { [Op.in]: noteIds }, storageUrl: { [Op.ne]: null } },
debitNoteId: { [Op.in]: noteIds },
[Op.or]: [{ storageUrl: { [Op.ne]: null } }, { sapDocumentNumber: { [Op.ne]: null } }],
},
attributes: ['debitNoteId'], attributes: ['debitNoteId'],
raw: true, raw: true,
}); });

View File

@ -300,7 +300,6 @@ 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,
@ -1010,8 +1009,6 @@ class NotificationService {
} }
break; break;
case 'proposal_submitted': case 'proposal_submitted':
{ {
// Get dealer and proposal data from metadata // Get dealer and proposal data from metadata
@ -1167,29 +1164,6 @@ 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}`);
} }

View File

@ -287,21 +287,9 @@ export class WFMFileService {
const fileContent = fs.readFileSync(filePath, 'utf-8'); const fileContent = fs.readFileSync(filePath, 'utf-8');
const lines = fileContent.split(/\r?\n/).filter(line => line.trim() !== ''); const lines = fileContent.split(/\r?\n/).filter(line => line.trim() !== '');
if (lines.length <= 1) return []; if (lines.length <= 1) return [];
const headers = lines[0].split('|').map(h => h.trim());
// SAP/WFM responses are expected to use pipe ('|'), but some environments may export
// with comma/semicolon or with spaces in header names. Normalize both delimiter + headers.
const headerLine = lines[0] || '';
const delimiter = headerLine.includes('|') ? '|' : headerLine.includes(';') ? ';' : ',';
const normalizeHeaderKey = (h: string) =>
h
.trim()
.toUpperCase()
.replace(/\s+/g, '_') // 'DOC NO' -> 'DOC_NO'
.replace(/[^A-Z0-9_]/g, '_'); // keep only safe chars for matching
const headers = headerLine.split(delimiter).map(normalizeHeaderKey);
const data = lines.slice(1).map(line => { const data = lines.slice(1).map(line => {
const values = line.split(delimiter); const values = line.split('|');
const row: any = {}; const row: any = {};
headers.forEach((header, index) => { headers.forEach((header, index) => {
row[header] = values[index]?.trim() || ''; row[header] = values[index]?.trim() || '';

View File

@ -411,7 +411,9 @@ 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; // Create new approval level at target position const shouldBeActive = isAddingAtCurrentLevel || isRequestApproved;
// Create new approval level at target position
const newLevel = await ApprovalLevel.create({ const newLevel = await ApprovalLevel.create({
requestId, requestId,
levelNumber: targetLevel, levelNumber: targetLevel,
@ -427,23 +429,21 @@ 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 request was APPROVED and we're adding a new approver, reactivate the request
if (shouldBeActive) { if (isRequestApproved) {
// If request was APPROVED and we're adding a new approver, reactivate the request // Change request status back to PENDING
if (isRequestApproved) { await workflow.update({
// Change request status back to PENDING status: WorkflowStatus.PENDING,
await workflow.update({ currentLevel: targetLevel // Set new approver as current level
status: WorkflowStatus.PENDING, } as any);
currentLevel: targetLevel // Set new approver as current level logger.info(`[Workflow] Request ${requestId} status changed from APPROVED to PENDING - new approver added at level ${targetLevel}`);
} as any); } else if (isAddingAtCurrentLevel) {
logger.info(`[Workflow] Request ${requestId} status changed from APPROVED to PENDING - new approver added at level ${targetLevel}`); // If we're adding at the current level, the workflow's currentLevel stays the same
} else { // (it's still the same level number, just with a new approver)
// If we're adding at the current level, ensure currentLevel matches targetLevel // No need to update workflow.currentLevel - it's already correct
await workflow.update({ currentLevel: targetLevel } as any);
logger.info(`[Workflow] Updated workflow currentLevel to ${targetLevel} for active new approver`);
}
} else { } else {
logger.info(`[Workflow] New approver added as PENDING at level ${targetLevel}. Current level remains at ${workflowCurrentLevel}`); // If adding after current level, update currentLevel to the new approver
await workflow.update({ currentLevel: targetLevel } as any);
} }
// 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,39 +498,18 @@ 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)
if (shouldBeActive) { // ADDITIONAL APPROVER NOTIFICATION: Uses 'assignment' type to trigger approval request email
// If immediately active, send 'assignment' (Action Required) email // This works the same as regular approvers - they need to review and approve
// 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;
@ -3902,21 +3881,9 @@ 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: assignmentTitle, title: 'New Request Assigned',
body: assignmentBody, body: `${workflowTitle}`,
requestNumber: requestNumber, requestNumber: requestNumber,
requestId: actualRequestId, requestId: actualRequestId,
url: `/request/${requestNumber}`, url: `/request/${requestNumber}`,
@ -3924,7 +3891,6 @@ 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)