From e3bda6df157f3135aab0a3c30467bdf2669dc2ea Mon Sep 17 00:00:00 2001 From: laxmanhalaki Date: Tue, 13 Jan 2026 19:18:39 +0530 Subject: [PATCH] dealer claim multi iteration implementaion started --- src/controllers/dealerClaim.controller.ts | 2 + src/controllers/workflow.controller.ts | 204 ++- .../20260113-redesign-dealer-claim-history.ts | 136 ++ src/models/DealerClaimHistory.ts | 190 +++ src/models/WorkflowRequest.ts | 9 +- src/models/index.ts | 11 +- src/routes/workflow.routes.ts | 219 +-- src/scripts/auto-setup.ts | 2 + src/scripts/migrate.ts | 24 +- src/services/dealerClaim.service.ts | 1336 +++++++++++++++-- src/services/dealerClaimApproval.service.ts | 298 +++- src/services/gcsStorage.service.ts | 24 +- 12 files changed, 2068 insertions(+), 387 deletions(-) create mode 100644 src/migrations/20260113-redesign-dealer-claim-history.ts create mode 100644 src/models/DealerClaimHistory.ts diff --git a/src/controllers/dealerClaim.controller.ts b/src/controllers/dealerClaim.controller.ts index e8a1055..8f284d1 100644 --- a/src/controllers/dealerClaim.controller.ts +++ b/src/controllers/dealerClaim.controller.ts @@ -250,6 +250,7 @@ export class DealerClaimController { numberOfParticipants, closedExpenses, totalClosedExpenses, + completionDescription, } = req.body; // Parse closedExpenses if it's a JSON string @@ -540,6 +541,7 @@ export class DealerClaimController { totalClosedExpenses: totalClosedExpenses ? parseFloat(totalClosedExpenses) : 0, invoicesReceipts: invoicesReceipts.length > 0 ? invoicesReceipts : undefined, attendanceSheet: attendanceSheet || undefined, + completionDescription: completionDescription || undefined, }); return ResponseHandler.success(res, { message: 'Completion documents submitted successfully' }, 'Completion submitted'); diff --git a/src/controllers/workflow.controller.ts b/src/controllers/workflow.controller.ts index 7ae9d60..3a482fa 100644 --- a/src/controllers/workflow.controller.ts +++ b/src/controllers/workflow.controller.ts @@ -13,18 +13,20 @@ import path from 'path'; import crypto from 'crypto'; import { getRequestMetadata } from '@utils/requestUtils'; import { enrichApprovalLevels, enrichSpectators, validateInitiator } from '@services/userEnrichment.service'; +import { DealerClaimService } from '@services/dealerClaim.service'; import logger from '@utils/logger'; const workflowService = new WorkflowService(); +const dealerClaimService = new DealerClaimService(); export class WorkflowController { async createWorkflow(req: AuthenticatedRequest, res: Response): Promise { try { const validatedData = validateCreateWorkflow(req.body); - + // Validate initiator exists await validateInitiator(req.user.userId); - + // Handle frontend format: map 'approvers' -> 'approvalLevels' for backward compatibility let approvalLevels = validatedData.approvalLevels || []; if (!approvalLevels.length && (req.body as any).approvers) { @@ -36,38 +38,38 @@ export class WorkflowController { isFinalApprover: index === approvers.length - 1, })); } - + // Normalize approval levels: map approverEmail -> email for backward compatibility const normalizedApprovalLevels = approvalLevels.map((level: any) => ({ ...level, email: level.email || level.approverEmail, // Support both formats })); - + // Enrich approval levels with user data (auto-lookup from AD if not in DB) logger.info(`[WorkflowController] Enriching ${normalizedApprovalLevels.length} approval levels`); const enrichedApprovalLevels = await enrichApprovalLevels(normalizedApprovalLevels as any); - + // Enrich spectators if provided // Normalize spectators: map userEmail -> email for backward compatibility // Filter participants to only include SPECTATOR type (exclude INITIATOR and APPROVER) const allParticipants = validatedData.spectators || validatedData.participants || []; - const spectators = allParticipants.filter((p: any) => + const spectators = allParticipants.filter((p: any) => !p.participantType || p.participantType === 'SPECTATOR' ); const normalizedSpectators = spectators.map((spec: any) => ({ ...spec, email: spec.email || spec.userEmail, // Support both formats })).filter((spec: any) => spec.email); // Only include entries with email - const enrichedSpectators = normalizedSpectators.length > 0 - ? await enrichSpectators(normalizedSpectators as any) + const enrichedSpectators = normalizedSpectators.length > 0 + ? await enrichSpectators(normalizedSpectators as any) : []; - + // Build complete participants array automatically // This includes: INITIATOR + all APPROVERs + all SPECTATORs const initiator = await User.findByPk(req.user.userId); const initiatorEmail = (initiator as any).email; const initiatorName = (initiator as any).displayName || (initiator as any).email; - + const autoGeneratedParticipants = [ // Add initiator { @@ -94,7 +96,7 @@ export class WorkflowController { // Add all spectators ...enrichedSpectators, ]; - + // Convert string literal priority to enum const workflowData = { ...validatedData, @@ -102,13 +104,13 @@ export class WorkflowController { approvalLevels: enrichedApprovalLevels, participants: autoGeneratedParticipants, }; - + const requestMeta = getRequestMetadata(req); const workflow = await workflowService.createWorkflow(req.user.userId, workflowData, { ipAddress: requestMeta.ipAddress, userAgent: requestMeta.userAgent }); - + ResponseHandler.success(res, workflow, 'Workflow created successfully', 201); } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; @@ -131,7 +133,7 @@ export class WorkflowController { ResponseHandler.error(res, 'payload is required', 400); return; } - + let parsed; try { parsed = JSON.parse(raw); @@ -139,7 +141,7 @@ export class WorkflowController { ResponseHandler.error(res, 'Invalid JSON in payload', 400, parseError instanceof Error ? parseError.message : 'JSON parse error'); return; } - + // Transform frontend format to backend format BEFORE validation // Map 'approvers' -> 'approvalLevels' for backward compatibility if (!parsed.approvalLevels && parsed.approvers) { @@ -151,57 +153,57 @@ export class WorkflowController { isFinalApprover: index === approvers.length - 1, })); } - + let validated; try { validated = validateCreateWorkflow(parsed); } catch (validationError: any) { // Zod validation errors provide detailed information - const errorMessage = validationError?.errors + const errorMessage = validationError?.errors ? validationError.errors.map((e: any) => `${e.path.join('.')}: ${e.message}`).join('; ') : (validationError instanceof Error ? validationError.message : 'Validation failed'); logger.error(`[WorkflowController] Validation failed:`, errorMessage); ResponseHandler.error(res, 'Validation failed', 400, errorMessage); return; } - + // Validate initiator exists await validateInitiator(userId); - + // Use the approval levels from validation (already transformed above) let approvalLevels = validated.approvalLevels || []; - + // Normalize approval levels: map approverEmail -> email for backward compatibility const normalizedApprovalLevels = approvalLevels.map((level: any) => ({ ...level, email: level.email || level.approverEmail, // Support both formats })); - + // Enrich approval levels with user data (auto-lookup from AD if not in DB) logger.info(`[WorkflowController] Enriching ${normalizedApprovalLevels.length} approval levels`); const enrichedApprovalLevels = await enrichApprovalLevels(normalizedApprovalLevels as any); - + // Enrich spectators if provided // Normalize spectators: map userEmail -> email for backward compatibility // Filter participants to only include SPECTATOR type (exclude INITIATOR and APPROVER) const allParticipants = validated.spectators || validated.participants || []; - const spectators = allParticipants.filter((p: any) => + const spectators = allParticipants.filter((p: any) => !p.participantType || p.participantType === 'SPECTATOR' ); const normalizedSpectators = spectators.map((spec: any) => ({ ...spec, email: spec.email || spec.userEmail, // Support both formats })).filter((spec: any) => spec.email); // Only include entries with email - const enrichedSpectators = normalizedSpectators.length > 0 - ? await enrichSpectators(normalizedSpectators as any) + const enrichedSpectators = normalizedSpectators.length > 0 + ? await enrichSpectators(normalizedSpectators as any) : []; - + // Build complete participants array automatically // This includes: INITIATOR + all APPROVERs + all SPECTATORs const initiator = await User.findByPk(userId); const initiatorEmail = (initiator as any).email; const initiatorName = (initiator as any).displayName || (initiator as any).email; - + const autoGeneratedParticipants = [ // Add initiator { @@ -228,9 +230,9 @@ export class WorkflowController { // Add all spectators ...enrichedSpectators, ]; - - const workflowData = { - ...validated, + + const workflowData = { + ...validated, priority: validated.priority as Priority, approvalLevels: enrichedApprovalLevels, participants: autoGeneratedParticipants, @@ -250,13 +252,13 @@ export class WorkflowController { const { activityService } = require('../services/activity.service'); const user = await User.findByPk(userId); const uploaderName = (user as any)?.displayName || (user as any)?.email || 'User'; - + for (const file of files) { // Get file buffer - multer.memoryStorage provides buffer, not path const fileBuffer = (file as any).buffer || (file.path ? fs.readFileSync(file.path) : Buffer.from('')); const checksum = crypto.createHash('sha256').update(fileBuffer).digest('hex'); const extension = path.extname(file.originalname).replace('.', '').toLowerCase(); - + // Upload with automatic fallback to local storage const requestNumber = (workflow as any).requestNumber || (workflow as any).request_number; const uploadResult = await gcsStorageService.uploadFileWithFallback({ @@ -266,10 +268,10 @@ export class WorkflowController { requestNumber: requestNumber, fileType: 'documents' }); - + const storageUrl = uploadResult.storageUrl; const gcsFilePath = uploadResult.filePath; - + // Clean up local temporary file if it exists (from multer disk storage) if (file.path && fs.existsSync(file.path)) { try { @@ -283,20 +285,20 @@ export class WorkflowController { const MAX_FILE_NAME_LENGTH = 255; const originalFileName = file.originalname; let truncatedOriginalFileName = originalFileName; - + if (originalFileName.length > MAX_FILE_NAME_LENGTH) { // Preserve file extension when truncating const ext = path.extname(originalFileName); const nameWithoutExt = path.basename(originalFileName, ext); const maxNameLength = MAX_FILE_NAME_LENGTH - ext.length; - + if (maxNameLength > 0) { truncatedOriginalFileName = nameWithoutExt.substring(0, maxNameLength) + ext; } else { // If extension itself is too long, just use the extension truncatedOriginalFileName = ext.substring(0, MAX_FILE_NAME_LENGTH); } - + logger.warn('[Workflow] File name truncated to fit database column', { originalLength: originalFileName.length, truncatedLength: truncatedOriginalFileName.length, @@ -308,18 +310,18 @@ export class WorkflowController { // Generate fileName (basename of the generated file name in GCS) const generatedFileName = path.basename(gcsFilePath); let truncatedFileName = generatedFileName; - + if (generatedFileName.length > MAX_FILE_NAME_LENGTH) { const ext = path.extname(generatedFileName); const nameWithoutExt = path.basename(generatedFileName, ext); const maxNameLength = MAX_FILE_NAME_LENGTH - ext.length; - + if (maxNameLength > 0) { truncatedFileName = nameWithoutExt.substring(0, maxNameLength) + ext; } else { truncatedFileName = ext.substring(0, MAX_FILE_NAME_LENGTH); } - + logger.warn('[Workflow] Generated file name truncated', { originalLength: generatedFileName.length, truncatedLength: truncatedFileName.length, @@ -346,7 +348,7 @@ export class WorkflowController { storageUrl: finalStorageUrl ? 'present' : 'null (too long)', requestId: workflow.requestId }); - + try { const doc = await Document.create({ requestId: workflow.requestId, @@ -387,7 +389,7 @@ export class WorkflowController { // Re-throw to be caught by outer catch block throw docError; } - + // Log document upload activity const requestMeta = getRequestMetadata(req); activityService.log({ @@ -422,7 +424,7 @@ export class WorkflowController { try { const { id } = req.params; const workflow = await workflowService.getWorkflowById(id); - + if (!workflow) { ResponseHandler.notFound(res, 'Workflow not found'); return; @@ -439,19 +441,19 @@ export class WorkflowController { try { const { id } = req.params as any; const userId = req.user?.userId; - + if (!userId) { ResponseHandler.error(res, 'Authentication required', 401); return; } - + // Check if user has access to this request const accessCheck = await workflowService.checkUserRequestAccess(userId, id); if (!accessCheck.hasAccess) { ResponseHandler.error(res, accessCheck.reason || 'Access denied', 403); return; } - + const result = await workflowService.getWorkflowDetails(id); if (!result) { ResponseHandler.notFound(res, 'Workflow not found'); @@ -468,7 +470,7 @@ export class WorkflowController { try { const page = Math.max(parseInt(String(req.query.page || '1'), 10), 1); const limit = Math.min(Math.max(parseInt(String(req.query.limit || '20'), 10), 1), 100); - + // Extract filter parameters const filters = { search: req.query.search as string | undefined, @@ -484,7 +486,7 @@ export class WorkflowController { startDate: req.query.startDate as string | undefined, endDate: req.query.endDate as string | undefined, }; - + const result = await workflowService.listWorkflows(page, limit, filters); ResponseHandler.success(res, result, 'Workflows fetched'); } catch (error) { @@ -498,7 +500,7 @@ export class WorkflowController { const userId = (req as any).user?.userId || (req as any).user?.id || (req as any).auth?.userId; const page = Math.max(parseInt(String(req.query.page || '1'), 10), 1); const limit = Math.min(Math.max(parseInt(String(req.query.limit || '20'), 10), 1), 100); - + // Extract filter parameters (same as listWorkflows) const search = req.query.search as string | undefined; const status = req.query.status as string | undefined; @@ -511,9 +513,9 @@ export class WorkflowController { const dateRange = req.query.dateRange as string | undefined; const startDate = req.query.startDate as string | undefined; const endDate = req.query.endDate as string | undefined; - + const filters = { search, status, priority, department, initiator, approver, approverType, slaCompliance, dateRange, startDate, endDate }; - + const result = await workflowService.listMyRequests(userId, page, limit, filters); ResponseHandler.success(res, result, 'My requests fetched'); } catch (error) { @@ -531,7 +533,7 @@ export class WorkflowController { const userId = (req as any).user?.userId || (req as any).user?.id || (req as any).auth?.userId; const page = Math.max(parseInt(String(req.query.page || '1'), 10), 1); const limit = Math.min(Math.max(parseInt(String(req.query.limit || '20'), 10), 1), 100); - + // Extract filter parameters (same as listWorkflows) const search = req.query.search as string | undefined; const status = req.query.status as string | undefined; @@ -545,9 +547,9 @@ export class WorkflowController { const dateRange = req.query.dateRange as string | undefined; const startDate = req.query.startDate as string | undefined; const endDate = req.query.endDate as string | undefined; - + const filters = { search, status, priority, templateType, department, initiator, approver, approverType, slaCompliance, dateRange, startDate, endDate }; - + const result = await workflowService.listParticipantRequests(userId, page, limit, filters); ResponseHandler.success(res, result, 'Participant requests fetched'); } catch (error) { @@ -564,7 +566,7 @@ export class WorkflowController { const userId = (req as any).user?.userId || (req as any).user?.id || (req as any).auth?.userId; const page = Math.max(parseInt(String(req.query.page || '1'), 10), 1); const limit = Math.min(Math.max(parseInt(String(req.query.limit || '20'), 10), 1), 100); - + // Extract filter parameters const search = req.query.search as string | undefined; const status = req.query.status as string | undefined; @@ -575,9 +577,9 @@ export class WorkflowController { const dateRange = req.query.dateRange as string | undefined; const startDate = req.query.startDate as string | undefined; const endDate = req.query.endDate as string | undefined; - + const filters = { search, status, priority, templateType, department, slaCompliance, dateRange, startDate, endDate }; - + const result = await workflowService.listMyInitiatedRequests(userId, page, limit, filters); ResponseHandler.success(res, result, 'My initiated requests fetched'); } catch (error) { @@ -591,7 +593,7 @@ export class WorkflowController { const userId = (req as any).user?.userId || (req as any).user?.id || (req as any).auth?.userId; const page = Math.max(parseInt(String(req.query.page || '1'), 10), 1); const limit = Math.min(Math.max(parseInt(String(req.query.limit || '20'), 10), 1), 100); - + // Extract filter parameters const filters = { search: req.query.search as string | undefined, @@ -599,11 +601,11 @@ export class WorkflowController { priority: req.query.priority as string | undefined, templateType: req.query.templateType as string | undefined }; - + // Extract sorting parameters const sortBy = req.query.sortBy as string | undefined; const sortOrder = (req.query.sortOrder as string | undefined) || 'desc'; - + const result = await workflowService.listOpenForMe(userId, page, limit, filters, sortBy, sortOrder); ResponseHandler.success(res, result, 'Open requests for user fetched'); } catch (error) { @@ -617,7 +619,7 @@ export class WorkflowController { const userId = (req as any).user?.userId || (req as any).user?.id || (req as any).auth?.userId; const page = Math.max(parseInt(String(req.query.page || '1'), 10), 1); const limit = Math.min(Math.max(parseInt(String(req.query.limit || '20'), 10), 1), 100); - + // Extract filter parameters const filters = { search: req.query.search as string | undefined, @@ -625,11 +627,11 @@ export class WorkflowController { priority: req.query.priority as string | undefined, templateType: req.query.templateType as string | undefined }; - + // Extract sorting parameters const sortBy = req.query.sortBy as string | undefined; const sortOrder = (req.query.sortOrder as string | undefined) || 'desc'; - + const result = await workflowService.listClosedByMe(userId, page, limit, filters, sortBy, sortOrder); ResponseHandler.success(res, result, 'Closed requests by user fetched'); } catch (error) { @@ -648,9 +650,9 @@ export class WorkflowController { // Map string literal to enum value explicitly updateData.priority = validatedData.priority === 'EXPRESS' ? Priority.EXPRESS : Priority.STANDARD; } - + const workflow = await workflowService.updateWorkflow(id, updateData); - + if (!workflow) { ResponseHandler.notFound(res, 'Workflow not found'); return; @@ -720,7 +722,7 @@ export class WorkflowController { const fileBuffer = (file as any).buffer || (file.path ? fs.readFileSync(file.path) : Buffer.from('')); const checksum = crypto.createHash('sha256').update(fileBuffer).digest('hex'); const extension = path.extname(file.originalname).replace('.', '').toLowerCase(); - + // Upload with automatic fallback to local storage const requestNumber = (workflow as any).requestNumber || (workflow as any).request_number; const uploadResult = await gcsStorageService.uploadFileWithFallback({ @@ -730,10 +732,10 @@ export class WorkflowController { requestNumber: requestNumber, fileType: 'documents' }); - + const storageUrl = uploadResult.storageUrl; const gcsFilePath = uploadResult.filePath; - + // Clean up local temporary file if it exists (from multer disk storage) if (file.path && fs.existsSync(file.path)) { try { @@ -747,20 +749,20 @@ export class WorkflowController { const MAX_FILE_NAME_LENGTH = 255; const originalFileName = file.originalname; let truncatedOriginalFileName = originalFileName; - + if (originalFileName.length > MAX_FILE_NAME_LENGTH) { // Preserve file extension when truncating const ext = path.extname(originalFileName); const nameWithoutExt = path.basename(originalFileName, ext); const maxNameLength = MAX_FILE_NAME_LENGTH - ext.length; - + if (maxNameLength > 0) { truncatedOriginalFileName = nameWithoutExt.substring(0, maxNameLength) + ext; } else { // If extension itself is too long, just use the extension truncatedOriginalFileName = ext.substring(0, MAX_FILE_NAME_LENGTH); } - + logger.warn('[Workflow] File name truncated to fit database column', { originalLength: originalFileName.length, truncatedLength: truncatedOriginalFileName.length, @@ -772,18 +774,18 @@ export class WorkflowController { // Generate fileName (basename of the generated file name in GCS) const generatedFileName = path.basename(gcsFilePath); let truncatedFileName = generatedFileName; - + if (generatedFileName.length > MAX_FILE_NAME_LENGTH) { const ext = path.extname(generatedFileName); const nameWithoutExt = path.basename(generatedFileName, ext); const maxNameLength = MAX_FILE_NAME_LENGTH - ext.length; - + if (maxNameLength > 0) { truncatedFileName = nameWithoutExt.substring(0, maxNameLength) + ext; } else { truncatedFileName = ext.substring(0, MAX_FILE_NAME_LENGTH); } - + logger.warn('[Workflow] Generated file name truncated', { originalLength: generatedFileName.length, truncatedLength: truncatedFileName.length, @@ -810,7 +812,7 @@ export class WorkflowController { storageUrl: finalStorageUrl ? 'present' : 'null (too long)', requestId: actualRequestId }); - + try { const doc = await Document.create({ requestId: actualRequestId, @@ -874,7 +876,7 @@ export class WorkflowController { try { const { id } = req.params; const workflow = await workflowService.submitWorkflow(id); - + if (!workflow) { ResponseHandler.notFound(res, 'Workflow not found'); return; @@ -886,4 +888,54 @@ export class WorkflowController { ResponseHandler.error(res, 'Failed to submit workflow', 400, errorMessage); } } + + async handleInitiatorAction(req: Request, res: Response): Promise { + try { + const { id } = req.params; + const { action, ...data } = req.body; + const userId = req.user?.userId; + + if (!userId) { + ResponseHandler.unauthorized(res, 'User ID missing from request'); + return; + } + + await dealerClaimService.handleInitiatorAction(id, userId, action as any, data); + ResponseHandler.success(res, null, `Action ${action} performed successfully`); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + logger.error('[WorkflowController] handleInitiatorAction failed', { + error: errorMessage, + requestId: req.params.id, + userId: req.user?.userId, + action: req.body.action + }); + ResponseHandler.error(res, 'Failed to perform initiator action', 400, errorMessage); + } + } + + async getHistory(req: Request, res: Response): Promise { + try { + const { id } = req.params; + + // Resolve requestId UUID from identifier (could be requestNumber or UUID) + const workflowService = new WorkflowService(); + const wf = await (workflowService as any).findWorkflowByIdentifier(id); + if (!wf) { + ResponseHandler.notFound(res, 'Workflow not found'); + return; + } + const requestId = wf.getDataValue('requestId'); + + const history = await dealerClaimService.getHistory(requestId); + ResponseHandler.success(res, history, 'Revision history fetched successfully'); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + logger.error('[WorkflowController] getHistory failed', { + error: errorMessage, + requestId: req.params.id + }); + ResponseHandler.error(res, 'Failed to fetch revision history', 400, errorMessage); + } + } } diff --git a/src/migrations/20260113-redesign-dealer-claim-history.ts b/src/migrations/20260113-redesign-dealer-claim-history.ts new file mode 100644 index 0000000..f9476e7 --- /dev/null +++ b/src/migrations/20260113-redesign-dealer-claim-history.ts @@ -0,0 +1,136 @@ +import { QueryInterface, DataTypes } from 'sequelize'; + +export const up = async (queryInterface: QueryInterface) => { + // 1. Drop the old dealer_claim_history table if it exists + const tables = await queryInterface.showAllTables(); + if (tables.includes('dealer_claim_history')) { + await queryInterface.dropTable('dealer_claim_history'); + } + + // 2. Create or update the enum type for snapshot_type + // Check if enum exists, if not create it, if yes update it + try { + await queryInterface.sequelize.query(` + DO $$ + BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'enum_dealer_claim_history_snapshot_type') THEN + CREATE TYPE enum_dealer_claim_history_snapshot_type AS ENUM ('PROPOSAL', 'COMPLETION', 'INTERNAL_ORDER', 'WORKFLOW', 'APPROVE'); + ELSE + -- Check if APPROVE exists in the enum + IF NOT EXISTS ( + SELECT 1 FROM pg_enum + WHERE enumlabel = 'APPROVE' + AND enumtypid = (SELECT oid FROM pg_type WHERE typname = 'enum_dealer_claim_history_snapshot_type') + ) THEN + ALTER TYPE enum_dealer_claim_history_snapshot_type ADD VALUE 'APPROVE'; + END IF; + END IF; + END $$; + `); + } catch (error) { + // If enum creation fails, try to continue (might already exist) + console.warn('Enum creation/update warning:', error); + } + + // 3. Create new simplified level-based dealer_claim_history table + await queryInterface.createTable('dealer_claim_history', { + history_id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true + }, + request_id: { + type: DataTypes.UUID, + allowNull: false, + references: { + model: 'workflow_requests', + key: 'request_id' + }, + onUpdate: 'CASCADE', + onDelete: 'CASCADE' + }, + approval_level_id: { + type: DataTypes.UUID, + allowNull: true, // Nullable for workflow-level snapshots + references: { + model: 'approval_levels', + key: 'level_id' + }, + onUpdate: 'CASCADE', + onDelete: 'SET NULL' + }, + level_number: { + type: DataTypes.INTEGER, + allowNull: true, // Nullable for workflow-level snapshots + comment: 'Level number for easier querying (e.g., 1=Dealer, 3=Dept Lead, 4/5=Completion)' + }, + level_name: { + type: DataTypes.STRING(255), + allowNull: true, // Nullable for workflow-level snapshots + comment: 'Level name for consistent matching (e.g., "Dealer Proposal Submission", "Department Lead Approval")' + }, + version: { + type: DataTypes.INTEGER, + allowNull: false, + comment: 'Version number for this specific level (starts at 1 per level)' + }, + snapshot_type: { + type: DataTypes.ENUM('PROPOSAL', 'COMPLETION', 'INTERNAL_ORDER', 'WORKFLOW', 'APPROVE'), + allowNull: false, + comment: 'Type of snapshot: PROPOSAL (Step 1), COMPLETION (Step 4/5), INTERNAL_ORDER (Step 3), WORKFLOW (general), APPROVE (approver actions with comments)' + }, + snapshot_data: { + type: DataTypes.JSONB, + allowNull: false, + comment: 'JSON object containing all snapshot data specific to this level and type. Structure varies by snapshot_type.' + }, + change_reason: { + type: DataTypes.TEXT, + allowNull: true, + comment: 'Reason for this version change (e.g., "Revision Requested: ...")' + }, + changed_by: { + type: DataTypes.UUID, + allowNull: false, + references: { + model: 'users', + key: 'user_id' + } + }, + created_at: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW + } + }); + + // Add indexes for efficient querying + await queryInterface.addIndex('dealer_claim_history', ['request_id', 'level_number', 'version'], { + name: 'idx_history_request_level_version' + }); + await queryInterface.addIndex('dealer_claim_history', ['approval_level_id', 'version'], { + name: 'idx_history_level_version' + }); + await queryInterface.addIndex('dealer_claim_history', ['request_id', 'snapshot_type'], { + name: 'idx_history_request_type' + }); + await queryInterface.addIndex('dealer_claim_history', ['snapshot_type', 'level_number'], { + name: 'idx_history_type_level' + }); + await queryInterface.addIndex('dealer_claim_history', ['request_id', 'level_name'], { + name: 'idx_history_request_level_name' + }); + await queryInterface.addIndex('dealer_claim_history', ['level_name', 'snapshot_type'], { + name: 'idx_history_level_name_type' + }); + // Index for JSONB queries on snapshot_data + await queryInterface.addIndex('dealer_claim_history', ['snapshot_type'], { + name: 'idx_history_snapshot_type', + using: 'BTREE' + }); +}; + +export const down = async (queryInterface: QueryInterface) => { + // Drop the new table + await queryInterface.dropTable('dealer_claim_history'); +}; diff --git a/src/models/DealerClaimHistory.ts b/src/models/DealerClaimHistory.ts new file mode 100644 index 0000000..1285daa --- /dev/null +++ b/src/models/DealerClaimHistory.ts @@ -0,0 +1,190 @@ +import { DataTypes, Model, Optional } from 'sequelize'; +import { sequelize } from '@config/database'; +import { WorkflowRequest } from './WorkflowRequest'; +import { ApprovalLevel } from './ApprovalLevel'; +import { User } from './User'; + +export enum SnapshotType { + PROPOSAL = 'PROPOSAL', + COMPLETION = 'COMPLETION', + INTERNAL_ORDER = 'INTERNAL_ORDER', + WORKFLOW = 'WORKFLOW', + APPROVE = 'APPROVE' +} + +// Type definitions for snapshot data structures +export interface ProposalSnapshotData { + documentUrl?: string; + totalBudget?: number; + comments?: string; + expectedCompletionDate?: string; + costItems?: Array<{ + description: string; + amount: number; + order: number; + }>; +} + +export interface CompletionSnapshotData { + documentUrl?: string; + totalExpenses?: number; + comments?: string; + expenses?: Array<{ + description: string; + amount: number; + }>; +} + +export interface IOSnapshotData { + ioNumber?: string; + blockedAmount?: number; + availableBalance?: number; + remainingBalance?: number; + sapDocumentNumber?: string; +} + +export interface WorkflowSnapshotData { + status?: string; + currentLevel?: number; +} + +export interface ApprovalSnapshotData { + action: 'APPROVE' | 'REJECT'; + comments?: string; + rejectionReason?: string; + approverName?: string; + approverEmail?: string; + levelName?: string; +} + +interface DealerClaimHistoryAttributes { + historyId: string; + requestId: string; + approvalLevelId?: string; + levelNumber?: number; + levelName?: string; + version: number; + snapshotType: SnapshotType; + snapshotData: ProposalSnapshotData | CompletionSnapshotData | IOSnapshotData | WorkflowSnapshotData | ApprovalSnapshotData | any; + changeReason?: string; + changedBy: string; + createdAt: Date; +} + +interface DealerClaimHistoryCreationAttributes extends Optional { } + +class DealerClaimHistory extends Model implements DealerClaimHistoryAttributes { + public historyId!: string; + public requestId!: string; + public approvalLevelId?: string; + public levelNumber?: number; + public version!: number; + public snapshotType!: SnapshotType; + public snapshotData!: ProposalSnapshotData | CompletionSnapshotData | IOSnapshotData | WorkflowSnapshotData | any; + public changeReason?: string; + public changedBy!: string; + public createdAt!: Date; +} + +DealerClaimHistory.init( + { + historyId: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + field: 'history_id' + }, + requestId: { + type: DataTypes.UUID, + allowNull: false, + field: 'request_id', + references: { + model: 'workflow_requests', + key: 'request_id' + } + }, + approvalLevelId: { + type: DataTypes.UUID, + allowNull: true, + field: 'approval_level_id', + references: { + model: 'approval_levels', + key: 'level_id' + } + }, + levelNumber: { + type: DataTypes.INTEGER, + allowNull: true, + field: 'level_number' + }, + levelName: { + type: DataTypes.STRING(255), + allowNull: true, + field: 'level_name' + }, + version: { + type: DataTypes.INTEGER, + allowNull: false + }, + snapshotType: { + type: DataTypes.ENUM('PROPOSAL', 'COMPLETION', 'INTERNAL_ORDER', 'WORKFLOW', 'APPROVE'), + allowNull: false, + field: 'snapshot_type' + }, + snapshotData: { + type: DataTypes.JSONB, + allowNull: false, + field: 'snapshot_data' + }, + changeReason: { + type: DataTypes.TEXT, + allowNull: true, + field: 'change_reason' + }, + changedBy: { + type: DataTypes.UUID, + allowNull: false, + field: 'changed_by', + references: { + model: 'users', + key: 'user_id' + } + }, + createdAt: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW, + field: 'created_at' + } + }, + { + sequelize, + modelName: 'DealerClaimHistory', + tableName: 'dealer_claim_history', + timestamps: false, + indexes: [ + { + fields: ['request_id', 'level_number', 'version'], + name: 'idx_history_request_level_version' + }, + { + fields: ['approval_level_id', 'version'], + name: 'idx_history_level_version' + }, + { + fields: ['request_id', 'snapshot_type'], + name: 'idx_history_request_type' + }, + { + fields: ['snapshot_type', 'level_number'], + name: 'idx_history_type_level' + } + ] + } +); + +DealerClaimHistory.belongsTo(WorkflowRequest, { foreignKey: 'requestId' }); +DealerClaimHistory.belongsTo(ApprovalLevel, { foreignKey: 'approvalLevelId' }); +DealerClaimHistory.belongsTo(User, { as: 'changer', foreignKey: 'changedBy' }); + +export { DealerClaimHistory }; diff --git a/src/models/WorkflowRequest.ts b/src/models/WorkflowRequest.ts index bdaffd6..ea69054 100644 --- a/src/models/WorkflowRequest.ts +++ b/src/models/WorkflowRequest.ts @@ -29,11 +29,12 @@ interface WorkflowRequestAttributes { pauseReason?: string; pauseResumeDate?: Date; pauseTatSnapshot?: any; + version: number; createdAt: Date; updatedAt: Date; } -interface WorkflowRequestCreationAttributes extends Optional {} +interface WorkflowRequestCreationAttributes extends Optional { } class WorkflowRequest extends Model implements WorkflowRequestAttributes { public requestId!: string; @@ -61,6 +62,7 @@ class WorkflowRequest extends Model { @@ -137,6 +138,13 @@ const defineAssociations = () => { sourceKey: 'requestId' }); + // DealerClaimHistory associations + WorkflowRequest.hasMany(DealerClaimHistory, { + as: 'history', + foreignKey: 'requestId', + sourceKey: 'requestId' + }); + // Note: belongsTo associations are defined in individual model files to avoid duplicate alias conflicts // Only hasMany associations from WorkflowRequest are defined here since they're one-way }; @@ -170,7 +178,8 @@ export { InternalOrder, ClaimBudgetTracking, Dealer, - ActivityType + ActivityType, + DealerClaimHistory }; // Export default sequelize instance diff --git a/src/routes/workflow.routes.ts b/src/routes/workflow.routes.ts index ef57521..a850d81 100644 --- a/src/routes/workflow.routes.ts +++ b/src/routes/workflow.routes.ts @@ -33,11 +33,11 @@ function createContentDisposition(disposition: 'inline' | 'attachment', filename .replace(/[<>:"|?*\x00-\x1F\x7F]/g, '_') // Only replace truly problematic chars .replace(/\\/g, '_') // Replace backslashes .trim(); - + // For ASCII-only filenames, use simple format (browsers prefer this) // Only use filename* for non-ASCII characters const hasNonASCII = /[^\x00-\x7F]/.test(filename); - + if (hasNonASCII) { // Use RFC 5987 encoding for non-ASCII characters const encodedFilename = encodeURIComponent(filename); @@ -229,27 +229,27 @@ router.get('/documents/:documentId/preview', const { Document } = require('@models/Document'); const { gcsStorageService } = require('../services/gcsStorage.service'); const fs = require('fs'); - + const document = await Document.findOne({ where: { documentId } }); if (!document) { res.status(404).json({ success: false, error: 'Document not found' }); return; } - + const storageUrl = (document as any).storageUrl || (document as any).storage_url; const filePath = (document as any).filePath || (document as any).file_path; const fileName = (document as any).originalFileName || (document as any).original_file_name || (document as any).fileName; const fileType = (document as any).mimeType || (document as any).mime_type; - + // Check if it's a GCS URL const isGcsUrl = storageUrl && (storageUrl.startsWith('https://storage.googleapis.com') || storageUrl.startsWith('gs://')); - + if (isGcsUrl) { // Redirect to GCS public URL or use signed URL for private files res.redirect(storageUrl); return; } - + // If storageUrl is null but filePath indicates GCS storage, stream file directly from GCS if (!storageUrl && filePath && filePath.startsWith('requests/')) { try { @@ -257,7 +257,7 @@ router.get('/documents/:documentId/preview', if (!gcsStorageService.isConfigured()) { throw new Error('GCS not configured'); } - + // Access the storage instance from the service const { Storage } = require('@google-cloud/storage'); const keyFilePath = process.env.GCP_KEY_FILE || ''; @@ -266,26 +266,26 @@ router.get('/documents/:documentId/preview', const resolvedKeyPath = path.isAbsolute(keyFilePath) ? keyFilePath : path.resolve(process.cwd(), keyFilePath); - + const storage = new Storage({ projectId: process.env.GCP_PROJECT_ID || '', keyFilename: resolvedKeyPath, }); - + const bucket = storage.bucket(bucketName); const file = bucket.file(filePath); - + // Check if file exists const [exists] = await file.exists(); if (!exists) { res.status(404).json({ success: false, error: 'File not found in GCS' }); return; } - + // Get file metadata for content type const [metadata] = await file.getMetadata(); const contentType = metadata.contentType || fileType || 'application/octet-stream'; - + // Set CORS headers const origin = req.headers.origin; if (origin) { @@ -294,12 +294,12 @@ router.get('/documents/:documentId/preview', } res.setHeader('Access-Control-Expose-Headers', 'Content-Type, Content-Disposition'); res.setHeader('Content-Type', contentType); - + // For images and PDFs, allow inline viewing const isPreviewable = fileType && (fileType.includes('image') || fileType.includes('pdf')); const disposition = isPreviewable ? 'inline' : 'attachment'; res.setHeader('Content-Disposition', createContentDisposition(disposition, fileName)); - + // Stream file from GCS to response file.createReadStream() .on('error', (streamError: Error) => { @@ -310,9 +310,9 @@ router.get('/documents/:documentId/preview', error: streamError.message, }); if (!res.headersSent) { - res.status(500).json({ - success: false, - error: 'Failed to stream file from storage' + res.status(500).json({ + success: false, + error: 'Failed to stream file from storage' }); } }) @@ -325,26 +325,26 @@ router.get('/documents/:documentId/preview', filePath, error: gcsError instanceof Error ? gcsError.message : 'Unknown error', }); - res.status(500).json({ - success: false, - error: 'Failed to access file. Please try again.' + res.status(500).json({ + success: false, + error: 'Failed to access file. Please try again.' }); return; } } - + // Local file handling - check if storageUrl is a local path (starts with /uploads/) if (storageUrl && storageUrl.startsWith('/uploads/')) { // Extract relative path from storageUrl (remove /uploads/ prefix) const relativePath = storageUrl.replace(/^\/uploads\//, ''); const absolutePath = path.join(UPLOAD_DIR, relativePath); - + // Check if file exists if (!fs.existsSync(absolutePath)) { res.status(404).json({ success: false, error: 'File not found on server' }); return; } - + // Set CORS headers to allow blob URL creation when served from same origin const origin = req.headers.origin; if (origin) { @@ -352,10 +352,10 @@ router.get('/documents/:documentId/preview', res.setHeader('Access-Control-Allow-Credentials', 'true'); } res.setHeader('Access-Control-Expose-Headers', 'Content-Type, Content-Disposition'); - + // Set appropriate content type res.contentType(fileType || 'application/octet-stream'); - + // For images and PDFs, allow inline viewing const isPreviewable = fileType && (fileType.includes('image') || fileType.includes('pdf')); if (isPreviewable) { @@ -363,7 +363,7 @@ router.get('/documents/:documentId/preview', } else { res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`); } - + res.sendFile(absolutePath, (err) => { if (err && !res.headersSent) { res.status(500).json({ success: false, error: 'Failed to serve file' }); @@ -371,18 +371,18 @@ router.get('/documents/:documentId/preview', }); return; } - + // Legacy local file handling (absolute path stored in filePath) // Resolve relative path if needed - const absolutePath = filePath && !path.isAbsolute(filePath) - ? path.join(UPLOAD_DIR, filePath) + const absolutePath = filePath && !path.isAbsolute(filePath) + ? path.join(UPLOAD_DIR, filePath) : filePath; - + if (!absolutePath || !fs.existsSync(absolutePath)) { res.status(404).json({ success: false, error: 'File not found on server' }); return; } - + // Set CORS headers to allow blob URL creation when served from same origin const origin = req.headers.origin; if (origin) { @@ -390,10 +390,10 @@ router.get('/documents/:documentId/preview', res.setHeader('Access-Control-Allow-Credentials', 'true'); } res.setHeader('Access-Control-Expose-Headers', 'Content-Type, Content-Disposition'); - + // Set appropriate content type res.contentType(fileType || 'application/octet-stream'); - + // For images and PDFs, allow inline viewing const isPreviewable = fileType && (fileType.includes('image') || fileType.includes('pdf')); if (isPreviewable) { @@ -401,7 +401,7 @@ router.get('/documents/:documentId/preview', } else { res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`); } - + res.sendFile(absolutePath, (err) => { if (err && !res.headersSent) { res.status(500).json({ success: false, error: 'Failed to serve file' }); @@ -418,26 +418,26 @@ router.get('/documents/:documentId/download', const { Document } = require('@models/Document'); const { gcsStorageService } = require('../services/gcsStorage.service'); const fs = require('fs'); - + const document = await Document.findOne({ where: { documentId } }); if (!document) { res.status(404).json({ success: false, error: 'Document not found' }); return; } - + const storageUrl = (document as any).storageUrl || (document as any).storage_url; const filePath = (document as any).filePath || (document as any).file_path; const fileName = (document as any).originalFileName || (document as any).original_file_name || (document as any).fileName; - + // Check if it's a GCS URL const isGcsUrl = storageUrl && (storageUrl.startsWith('https://storage.googleapis.com') || storageUrl.startsWith('gs://')); - + if (isGcsUrl) { // Redirect to GCS public URL for download res.redirect(storageUrl); return; } - + // If storageUrl is null but filePath indicates GCS storage, stream file directly from GCS if (!storageUrl && filePath && filePath.startsWith('requests/')) { try { @@ -445,7 +445,7 @@ router.get('/documents/:documentId/download', if (!gcsStorageService.isConfigured()) { throw new Error('GCS not configured'); } - + // Access the storage instance from the service const { Storage } = require('@google-cloud/storage'); const keyFilePath = process.env.GCP_KEY_FILE || ''; @@ -454,26 +454,26 @@ router.get('/documents/:documentId/download', const resolvedKeyPath = path.isAbsolute(keyFilePath) ? keyFilePath : path.resolve(process.cwd(), keyFilePath); - + const storage = new Storage({ projectId: process.env.GCP_PROJECT_ID || '', keyFilename: resolvedKeyPath, }); - + const bucket = storage.bucket(bucketName); const file = bucket.file(filePath); - + // Check if file exists const [exists] = await file.exists(); if (!exists) { res.status(404).json({ success: false, error: 'File not found in GCS' }); return; } - + // Get file metadata for content type const [metadata] = await file.getMetadata(); const contentType = metadata.contentType || (document as any).mimeType || (document as any).mime_type || 'application/octet-stream'; - + // Set CORS headers const origin = req.headers.origin; if (origin) { @@ -481,11 +481,11 @@ router.get('/documents/:documentId/download', res.setHeader('Access-Control-Allow-Credentials', 'true'); } res.setHeader('Access-Control-Expose-Headers', 'Content-Type, Content-Disposition'); - + // Set headers for download res.setHeader('Content-Type', contentType); res.setHeader('Content-Disposition', createContentDisposition('attachment', fileName)); - + // Stream file from GCS to response file.createReadStream() .on('error', (streamError: Error) => { @@ -496,9 +496,9 @@ router.get('/documents/:documentId/download', error: streamError.message, }); if (!res.headersSent) { - res.status(500).json({ - success: false, - error: 'Failed to stream file from storage' + res.status(500).json({ + success: false, + error: 'Failed to stream file from storage' }); } }) @@ -511,26 +511,26 @@ router.get('/documents/:documentId/download', filePath, error: gcsError instanceof Error ? gcsError.message : 'Unknown error', }); - res.status(500).json({ - success: false, - error: 'Failed to access file. Please try again.' + res.status(500).json({ + success: false, + error: 'Failed to access file. Please try again.' }); return; } } - + // Local file handling - check if storageUrl is a local path (starts with /uploads/) if (storageUrl && storageUrl.startsWith('/uploads/')) { // Extract relative path from storageUrl (remove /uploads/ prefix) const relativePath = storageUrl.replace(/^\/uploads\//, ''); const absolutePath = path.join(UPLOAD_DIR, relativePath); - + // Check if file exists if (!fs.existsSync(absolutePath)) { res.status(404).json({ success: false, error: 'File not found on server' }); return; } - + // Set CORS headers const origin = req.headers.origin; if (origin) { @@ -538,12 +538,12 @@ router.get('/documents/:documentId/download', res.setHeader('Access-Control-Allow-Credentials', 'true'); } res.setHeader('Access-Control-Expose-Headers', 'Content-Type, Content-Disposition'); - + // Set headers for download const fileTypeForDownload = (document as any).mimeType || (document as any).mime_type || 'application/octet-stream'; res.setHeader('Content-Type', fileTypeForDownload); res.setHeader('Content-Disposition', createContentDisposition('attachment', fileName)); - + res.download(absolutePath, fileName, (err) => { if (err && !res.headersSent) { res.status(500).json({ success: false, error: 'Failed to download file' }); @@ -551,18 +551,18 @@ router.get('/documents/:documentId/download', }); return; } - + // Legacy local file handling (absolute path stored in filePath) // Resolve relative path if needed - const absolutePath = filePath && !path.isAbsolute(filePath) - ? path.join(UPLOAD_DIR, filePath) + const absolutePath = filePath && !path.isAbsolute(filePath) + ? path.join(UPLOAD_DIR, filePath) : filePath; - + if (!absolutePath || !fs.existsSync(absolutePath)) { res.status(404).json({ success: false, error: 'File not found on server' }); return; } - + res.download(absolutePath, fileName, (err) => { if (err && !res.headersSent) { res.status(500).json({ success: false, error: 'Failed to download file' }); @@ -578,26 +578,26 @@ router.get('/work-notes/attachments/:attachmentId/preview', const { attachmentId } = req.params; const fileInfo = await workNoteService.downloadAttachment(attachmentId); const fs = require('fs'); - + // Check if it's a GCS URL if (fileInfo.isGcsUrl && fileInfo.storageUrl) { // Redirect to GCS public URL res.redirect(fileInfo.storageUrl); return; } - + // Local file handling - check if storageUrl is a local path (starts with /uploads/) if (fileInfo.storageUrl && fileInfo.storageUrl.startsWith('/uploads/')) { // Extract relative path from storageUrl (remove /uploads/ prefix) const relativePath = fileInfo.storageUrl.replace(/^\/uploads\//, ''); const absolutePath = path.join(UPLOAD_DIR, relativePath); - + // Check if file exists if (!fs.existsSync(absolutePath)) { res.status(404).json({ success: false, error: 'File not found' }); return; } - + // Set CORS headers to allow blob URL creation when served from same origin const origin = req.headers.origin; if (origin) { @@ -605,10 +605,10 @@ router.get('/work-notes/attachments/:attachmentId/preview', res.setHeader('Access-Control-Allow-Credentials', 'true'); } res.setHeader('Access-Control-Expose-Headers', 'Content-Type, Content-Disposition'); - + // Set appropriate content type res.contentType(fileInfo.fileType || 'application/octet-stream'); - + // For images and PDFs, allow inline viewing const isPreviewable = fileInfo.fileType && (fileInfo.fileType.includes('image') || fileInfo.fileType.includes('pdf')); if (isPreviewable) { @@ -616,7 +616,7 @@ router.get('/work-notes/attachments/:attachmentId/preview', } else { res.setHeader('Content-Disposition', `attachment; filename="${fileInfo.fileName}"`); } - + res.sendFile(absolutePath, (err) => { if (err && !res.headersSent) { res.status(500).json({ success: false, error: 'Failed to serve file' }); @@ -624,18 +624,18 @@ router.get('/work-notes/attachments/:attachmentId/preview', }); return; } - + // Legacy local file handling (absolute path stored in filePath) // Resolve relative path if needed - const absolutePath = fileInfo.filePath && !path.isAbsolute(fileInfo.filePath) - ? path.join(UPLOAD_DIR, fileInfo.filePath) + const absolutePath = fileInfo.filePath && !path.isAbsolute(fileInfo.filePath) + ? path.join(UPLOAD_DIR, fileInfo.filePath) : fileInfo.filePath; - + if (!absolutePath || !fs.existsSync(absolutePath)) { res.status(404).json({ success: false, error: 'File not found' }); return; } - + // Set CORS headers to allow blob URL creation when served from same origin const origin = req.headers.origin; if (origin) { @@ -643,10 +643,10 @@ router.get('/work-notes/attachments/:attachmentId/preview', res.setHeader('Access-Control-Allow-Credentials', 'true'); } res.setHeader('Access-Control-Expose-Headers', 'Content-Type, Content-Disposition'); - + // Set appropriate content type res.contentType(fileInfo.fileType || 'application/octet-stream'); - + // For images and PDFs, allow inline viewing const isPreviewable = fileInfo.fileType && (fileInfo.fileType.includes('image') || fileInfo.fileType.includes('pdf')); if (isPreviewable) { @@ -654,7 +654,7 @@ router.get('/work-notes/attachments/:attachmentId/preview', } else { res.setHeader('Content-Disposition', `attachment; filename="${fileInfo.fileName}"`); } - + res.sendFile(absolutePath, (err) => { if (err && !res.headersSent) { res.status(500).json({ success: false, error: 'Failed to serve file' }); @@ -670,26 +670,26 @@ router.get('/work-notes/attachments/:attachmentId/download', const { attachmentId } = req.params; const fileInfo = await workNoteService.downloadAttachment(attachmentId); const fs = require('fs'); - + // Check if it's a GCS URL if (fileInfo.isGcsUrl && fileInfo.storageUrl) { // Redirect to GCS public URL for download res.redirect(fileInfo.storageUrl); return; } - + // Local file handling - check if storageUrl is a local path (starts with /uploads/) if (fileInfo.storageUrl && fileInfo.storageUrl.startsWith('/uploads/')) { // Extract relative path from storageUrl (remove /uploads/ prefix) const relativePath = fileInfo.storageUrl.replace(/^\/uploads\//, ''); const absolutePath = path.join(UPLOAD_DIR, relativePath); - + // Check if file exists if (!fs.existsSync(absolutePath)) { res.status(404).json({ success: false, error: 'File not found' }); return; } - + // Set CORS headers const origin = req.headers.origin; if (origin) { @@ -697,7 +697,7 @@ router.get('/work-notes/attachments/:attachmentId/download', res.setHeader('Access-Control-Allow-Credentials', 'true'); } res.setHeader('Access-Control-Expose-Headers', 'Content-Type, Content-Disposition'); - + res.download(absolutePath, fileInfo.fileName, (err) => { if (err && !res.headersSent) { res.status(500).json({ success: false, error: 'Failed to download file' }); @@ -705,18 +705,18 @@ router.get('/work-notes/attachments/:attachmentId/download', }); return; } - + // Legacy local file handling (absolute path stored in filePath) // Resolve relative path if needed - const absolutePath = fileInfo.filePath && !path.isAbsolute(fileInfo.filePath) - ? path.join(UPLOAD_DIR, fileInfo.filePath) + const absolutePath = fileInfo.filePath && !path.isAbsolute(fileInfo.filePath) + ? path.join(UPLOAD_DIR, fileInfo.filePath) : fileInfo.filePath; - + if (!absolutePath || !fs.existsSync(absolutePath)) { res.status(404).json({ success: false, error: 'File not found' }); return; } - + res.download(absolutePath, fileInfo.fileName, (err) => { if (err && !res.headersSent) { res.status(500).json({ success: false, error: 'Failed to download file' }); @@ -738,7 +738,7 @@ router.post('/:id/participants/approver', } const requestId: string = wf.getDataValue('requestId'); const { email } = req.body; - + if (!email) { res.status(400).json({ success: false, error: 'Email is required' }); return; @@ -761,7 +761,7 @@ router.post('/:id/participants/spectator', } const requestId: string = wf.getDataValue('requestId'); const { email } = req.body; - + if (!email) { res.status(400).json({ success: false, error: 'Email is required' }); return; @@ -794,11 +794,11 @@ router.post('/:id/approvals/:levelId/skip', reason || '', req.user?.userId ); - - res.status(200).json({ - success: true, + + res.status(200).json({ + success: true, message: 'Approver skipped successfully', - data: result + data: result }); }) ); @@ -819,9 +819,9 @@ router.post('/:id/approvers/at-level', const { email, tatHours, level } = req.body; if (!email || !tatHours || !level) { - res.status(400).json({ - success: false, - error: 'Email, tatHours, and level are required' + res.status(400).json({ + success: false, + error: 'Email, tatHours, and level are required' }); return; } @@ -833,11 +833,11 @@ router.post('/:id/approvers/at-level', Number(level), req.user?.userId ); - - res.status(201).json({ - success: true, + + res.status(201).json({ + success: true, message: 'Approver added successfully', - data: result + data: result }); }) ); @@ -874,4 +874,19 @@ router.get('/:id/pause', asyncHandler(pauseController.getPauseDetails.bind(pauseController)) ); +// Initiator actions for rejected/returned requests +router.post('/:id/initiator-action', + authenticateToken, + requireParticipantTypes(['INITIATOR']), + validateParams(workflowParamsSchema), + asyncHandler(workflowController.handleInitiatorAction.bind(workflowController)) +); + +// Get revision history +router.get('/:id/history', + authenticateToken, + validateParams(workflowParamsSchema), + asyncHandler(workflowController.getHistory.bind(workflowController)) +); + export default router; diff --git a/src/scripts/auto-setup.ts b/src/scripts/auto-setup.ts index 79fe01f..ca4819c 100644 --- a/src/scripts/auto-setup.ts +++ b/src/scripts/auto-setup.ts @@ -135,6 +135,7 @@ async function runMigrations(): Promise { const m40 = require('../migrations/20251218-fix-claim-invoice-credit-note-columns'); const m41 = require('../migrations/20250120-create-dealers-table'); const m42 = require('../migrations/20250125-create-activity-types'); + const m43 = require('../migrations/20260113-redesign-dealer-claim-history'); const migrations = [ { name: '2025103000-create-users', module: m0 }, @@ -182,6 +183,7 @@ async function runMigrations(): Promise { { name: '20251218-fix-claim-invoice-credit-note-columns', module: m40 }, { name: '20250120-create-dealers-table', module: m41 }, { name: '20250125-create-activity-types', module: m42 }, + { name: '20260113-redesign-dealer-claim-history', module: m43 }, ]; const queryInterface = sequelize.getQueryInterface(); diff --git a/src/scripts/migrate.ts b/src/scripts/migrate.ts index 6466c05..48bf745 100644 --- a/src/scripts/migrate.ts +++ b/src/scripts/migrate.ts @@ -45,6 +45,7 @@ import * as m39 from '../migrations/20251214-create-dealer-completion-expenses'; import * as m40 from '../migrations/20251218-fix-claim-invoice-credit-note-columns'; import * as m41 from '../migrations/20250120-create-dealers-table'; import * as m42 from '../migrations/20250125-create-activity-types'; +import * as m43 from '../migrations/20260113-redesign-dealer-claim-history'; interface Migration { name: string; @@ -56,7 +57,7 @@ interface Migration { const migrations: Migration[] = [ // 1. FIRST: Create base tables with no dependencies { name: '2025103000-create-users', module: m0 }, // ← MUST BE FIRST - + // 2. Tables that depend on users { name: '2025103001-create-workflow-requests', module: m1 }, { name: '2025103002-create-approval-levels', module: m2 }, @@ -66,7 +67,7 @@ const migrations: Migration[] = [ { name: '20251031_02_create_activities', module: m6 }, { name: '20251031_03_create_work_notes', module: m7 }, { name: '20251031_04_create_work_note_attachments', module: m8 }, - + // 3. Table modifications and additional features { name: '20251104-add-tat-alert-fields', module: m9 }, { name: '20251104-create-tat-alerts', module: m10 }, @@ -104,6 +105,7 @@ const migrations: Migration[] = [ { name: '20251218-fix-claim-invoice-credit-note-columns', module: m40 }, { name: '20250120-create-dealers-table', module: m41 }, { name: '20250125-create-activity-types', module: m42 }, + { name: '20260113-redesign-dealer-claim-history', module: m43 }, ]; /** @@ -112,7 +114,7 @@ const migrations: Migration[] = [ async function ensureMigrationsTable(queryInterface: QueryInterface): Promise { try { const tables = await queryInterface.showAllTables(); - + if (!tables.includes('migrations')) { await queryInterface.sequelize.query(` CREATE TABLE migrations ( @@ -164,28 +166,28 @@ async function markMigrationExecuted(name: string): Promise { async function run() { try { await sequelize.authenticate(); - + const queryInterface = sequelize.getQueryInterface(); - + // Ensure migrations tracking table exists await ensureMigrationsTable(queryInterface); - + // Get already executed migrations const executedMigrations = await getExecutedMigrations(); - + // Find pending migrations const pendingMigrations = migrations.filter( m => !executedMigrations.includes(m.name) ); - + if (pendingMigrations.length === 0) { console.log('✅ Migrations up-to-date'); process.exit(0); return; } - + console.log(`🔄 Running ${pendingMigrations.length} migration(s)...`); - + // Run each pending migration for (const migration of pendingMigrations) { try { @@ -197,7 +199,7 @@ async function run() { throw error; } } - + console.log(`✅ Applied ${pendingMigrations.length} migration(s)`); process.exit(0); } catch (err: any) { diff --git a/src/services/dealerClaim.service.ts b/src/services/dealerClaim.service.ts index 926dfb1..6dcc69f 100644 --- a/src/services/dealerClaim.service.ts +++ b/src/services/dealerClaim.service.ts @@ -11,6 +11,7 @@ import { DealerCompletionExpense } from '../models/DealerCompletionExpense'; import { ApprovalLevel } from '../models/ApprovalLevel'; import { Participant } from '../models/Participant'; import { User } from '../models/User'; +import { DealerClaimHistory, SnapshotType } from '../models/DealerClaimHistory'; import { WorkflowService } from './workflow.service'; import { DealerClaimApprovalService } from './dealerClaimApproval.service'; import { generateRequestNumber } from '../utils/helpers'; @@ -69,13 +70,13 @@ export class DealerClaimService { 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 (!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}`); } - + const initiator = await User.findByPk(userId); if (!initiator) { throw new Error('Initiator not found'); @@ -137,11 +138,11 @@ export class DealerClaimService { // Schedule TAT jobs for Step 1 (Dealer Proposal Submission) - first active step // This ensures SLA tracking starts immediately from request creation const { tatSchedulerService } = await import('./tatScheduler.service'); - const dealerLevel = await ApprovalLevel.findOne({ - where: { - requestId: workflowRequest.requestId, + const dealerLevel = await ApprovalLevel.findOne({ + where: { + requestId: workflowRequest.requestId, levelNumber: 1 // Step 1: Dealer Proposal Submission - } + } }); if (dealerLevel && dealerLevel.approverId && dealerLevel.levelStartTime) { @@ -192,17 +193,17 @@ export class DealerClaimService { // Get approval levels for notifications // Step 1: Dealer Proposal Submission (first active step - log assignment at creation) // Subsequent steps will have assignment logged when they become active (via approval service) - + // Notify Step 1 (Dealer) - dealerLevel was already fetched above for TAT scheduling if (dealerLevel && dealerLevel.approverId) { // Skip notifications for system processes const approverEmail = dealerLevel.approverEmail || ''; - const isSystemProcess = approverEmail.toLowerCase() === 'system@royalenfield.com' + const isSystemProcess = approverEmail.toLowerCase() === 'system@royalenfield.com' || approverEmail.toLowerCase().includes('system') || dealerLevel.approverId === 'system' || dealerLevel.approverName === 'System Auto-Process'; - + if (!isSystemProcess) { // Send notification to Dealer (Step 1) for proposal submission await notificationService.sendToUsers([dealerLevel.approverId], { @@ -241,7 +242,7 @@ export class DealerClaimService { message: error.message, name: error.name, }; - + // Sequelize validation errors if (error.errors && Array.isArray(error.errors)) { errorDetails.validationErrors = error.errors.map((e: any) => ({ @@ -250,7 +251,7 @@ export class DealerClaimService { value: e.value, })); } - + // Sequelize database errors if (error.parent) { errorDetails.databaseError = { @@ -259,7 +260,7 @@ export class DealerClaimService { detail: error.parent.detail, }; } - + logger.error('[DealerClaimService] Error creating claim request:', errorDetails); throw error; } @@ -271,8 +272,8 @@ export class DealerClaimService { * Note: Activity Creation, E-Invoice Generation, and Credit Note Confirmation are handled as activity logs only, not approval steps */ private async createClaimApprovalLevelsFromApprovers( - requestId: string, - initiatorId: string, + requestId: string, + initiatorId: string, dealerEmail?: string, approvers: Array<{ email: string; @@ -304,10 +305,10 @@ export class DealerClaimService { // Sort approvers by level to process in order const sortedApprovers = [...approvers].sort((a, b) => a.level - b.level); - + // Track which original steps have been processed const processedOriginalSteps = new Set(); - + // Process approvers in order by their level for (const approver of sortedApprovers) { let approverId: string | null = null; @@ -317,13 +318,13 @@ export class DealerClaimService { let levelName = ''; let isSystemStep = false; let isFinalApprover = false; - + // Find the step definition this approver belongs to let stepDef = null; - + // Check if this is a system step by email (for backwards compatibility) const isSystemEmail = approver.email === 'system@royalenfield.com' || approver.email === 'finance@royalenfield.com'; - + if (approver.isAdditional) { // Additional approver - use stepName from frontend levelName = approver.stepName || 'Additional Approver'; @@ -333,12 +334,12 @@ export class DealerClaimService { // Fixed step - find by originalStepLevel first, then by matching level const originalLevel = approver.originalStepLevel || approver.level; stepDef = stepDefinitions.find(s => s.level === originalLevel); - + if (!stepDef) { // Try to find by current level if originalStepLevel not provided stepDef = stepDefinitions.find(s => s.level === approver.level); } - + // 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 @@ -346,7 +347,7 @@ export class DealerClaimService { 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 } - + if (stepDef) { levelName = stepDef.name; isSystemStep = false; // No system steps in approval levels anymore @@ -358,29 +359,29 @@ export class DealerClaimService { isSystemStep = false; 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) - 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}`); + + // 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) { + logger.warn(`[DealerClaimService] levelName too long (${levelName.length} chars) for level ${approver.level}, truncating to 100 chars`); + levelName = levelName.substring(0, 97) + '...'; + } } - - // Truncate levelName to max 100 characters (database constraint) - if (levelName.length > 100) { - logger.warn(`[DealerClaimService] levelName too long (${levelName.length} chars) for level ${approver.level}, truncating to 100 chars`); - levelName = levelName.substring(0, 97) + '...'; - } - } - + // System steps are no longer created as approval levels - they are activity logs only // This code path should not be reached anymore, but kept for safety if (isSystemStep) { logger.warn(`[DealerClaimService] System step detected but should not create approval level. Skipping.`); continue; // Skip creating approval level for system steps } - + { // User-provided approver (fixed or additional) if (!approver.email) { @@ -400,13 +401,13 @@ export class DealerClaimService { // 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 { @@ -416,11 +417,11 @@ export class DealerClaimService { 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`); @@ -459,16 +460,16 @@ export class DealerClaimService { 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 isStep1 = approver.level === 1; - + try { // Check for duplicate level_number for this request_id (unique constraint) const existingLevel = await ApprovalLevel.findOne({ @@ -477,12 +478,12 @@ export class DealerClaimService { levelNumber: approver.level } }); - + if (existingLevel) { logger.error(`[DealerClaimService] Duplicate level number ${approver.level} already exists for request ${requestId}`); throw new Error(`Level ${approver.level} already exists for this request. This may indicate a duplicate approver.`); } - + await ApprovalLevel.create({ requestId, levelNumber: approver.level, // Use the approver's level (may be shifted) @@ -512,7 +513,7 @@ export class DealerClaimService { approverName: approverName?.substring(0, 50), tatHours, }; - + // Sequelize validation errors if (createError.errors && Array.isArray(createError.errors)) { errorDetails.validationErrors = createError.errors.map((e: any) => ({ @@ -522,7 +523,7 @@ export class DealerClaimService { type: e.type, })); } - + // Database constraint errors if (createError.parent) { errorDetails.databaseError = { @@ -532,12 +533,12 @@ export class DealerClaimService { 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}`); } } - + // Validate that required fixed steps were processed const requiredSteps = stepDefinitions.filter(s => !s.isAuto); for (const requiredStep of requiredSteps) { @@ -589,7 +590,7 @@ export class DealerClaimService { let dealerUser = await User.findOne({ where: { email: dealerEmail.toLowerCase() }, }); - + if (!dealerUser) { logger.info(`[DealerClaimService] Dealer ${dealerEmail} not found in DB for participants, syncing from Okta`); try { @@ -603,7 +604,7 @@ export class DealerClaimService { logger.warn(`[DealerClaimService] Skipping dealer participant creation for ${dealerEmail}`); } } - + if (dealerUser) { participantsToAdd.push({ userId: dealerUser.userId, @@ -641,13 +642,13 @@ export class DealerClaimService { 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); }; - + // Only try to find user if approverId is a valid UUID if (!isValidUUID(approverId)) { logger.warn(`[DealerClaimService] Invalid UUID format for approverId: ${approverId}, skipping participant creation`); continue; } - + const approverUser = await User.findByPk(approverId); if (approverUser) { participantsToAdd.push({ @@ -727,7 +728,7 @@ export class DealerClaimService { try { // Get manager displayName from initiator's user record const managerDisplayName = initiator.manager; // This is the displayName of the manager - + if (!managerDisplayName) { logger.warn(`[DealerClaimService] Initiator ${initiator.email} has no manager displayName set`); // Return null - caller will handle the error @@ -749,7 +750,7 @@ export class DealerClaimService { // Single match - use this user const oktaUser = oktaUsers[0]; const managerEmail = oktaUser.profile.email || oktaUser.profile.login; - + logger.info(`[DealerClaimService] Found single manager match: ${managerEmail} for displayName: "${managerDisplayName}"`); // Check if user exists in DB, create if doesn't exist @@ -777,7 +778,7 @@ export class DealerClaimService { })); logger.warn(`[DealerClaimService] Multiple managers found (${oktaUsers.length}) for displayName: "${managerDisplayName}"`); - + // Create a custom error with the manager options const error: any = new Error(`Multiple reporting managers found. Please select one.`); error.code = 'MULTIPLE_MANAGERS_FOUND'; @@ -789,7 +790,7 @@ export class DealerClaimService { if (error.code === 'MULTIPLE_MANAGERS_FOUND') { throw error; } - + // For other errors, log and fallback to old method logger.error(`[DealerClaimService] Error resolving manager from Okta:`, error); return await this.resolveDepartmentLead(initiator); @@ -1070,7 +1071,7 @@ export class DealerClaimService { let transformedProposalDetails = null; if (proposalDetails) { const proposalData = (proposalDetails as any).toJSON ? (proposalDetails as any).toJSON() : proposalDetails; - + // Get cost items from separate table (dealer_proposal_cost_items) let costBreakup: any[] = []; if (proposalData.costItems && Array.isArray(proposalData.costItems) && proposalData.costItems.length > 0) { @@ -1179,7 +1180,8 @@ export class DealerClaimService { expectedCompletionDate?: Date; expectedCompletionDays?: number; dealerComments: string; - } + }, + dealerUserId?: string // Optional dealer user ID for history tracking ): Promise { try { const request = await WorkflowRequest.findByPk(requestId); @@ -1187,6 +1189,18 @@ export class DealerClaimService { throw new Error('Invalid claim request'); } + // Get dealer user ID if not provided - try to find by dealer email from claim details + let actualDealerUserId: string | null = dealerUserId || null; + if (!actualDealerUserId) { + const claimDetails = await DealerClaimDetails.findOne({ where: { requestId } }); + if (claimDetails?.dealerEmail) { + const dealerUser = await User.findOne({ + where: { email: claimDetails.dealerEmail } + }); + actualDealerUserId = dealerUser?.userId || null; + } + } + if (request.currentLevel !== 1) { throw new Error('Proposal can only be submitted at step 1'); } @@ -1208,9 +1222,9 @@ export class DealerClaimService { }); // Get proposalId - handle both Sequelize instance and plain object - let proposalId = (proposal as any).proposalId + let proposalId = (proposal as any).proposalId || (proposal as any).proposal_id; - + // If not found, try getDataValue method if (!proposalId && (proposal as any).getDataValue) { proposalId = (proposal as any).getDataValue('proposalId'); @@ -1218,11 +1232,11 @@ export class DealerClaimService { // If still not found, fetch the proposal by requestId if (!proposalId) { - const existingProposal = await DealerProposalDetails.findOne({ - where: { requestId } + const existingProposal = await DealerProposalDetails.findOne({ + where: { requestId } }); if (existingProposal) { - proposalId = (existingProposal as any).proposalId + proposalId = (existingProposal as any).proposalId || (existingProposal as any).proposal_id || ((existingProposal as any).getDataValue ? (existingProposal as any).getDataValue('proposalId') : null); } @@ -1263,8 +1277,8 @@ export class DealerClaimService { // Approve Dealer Proposal Submission step dynamically (by levelName, not hardcoded step number) let dealerProposalLevel = await ApprovalLevel.findOne({ - where: { - requestId, + where: { + requestId, levelName: 'Dealer Proposal Submission' } }); @@ -1277,10 +1291,40 @@ export class DealerClaimService { } if (dealerProposalLevel) { + // Use dealer's comment if provided, otherwise use default message + const approvalComment = proposalData.dealerComments?.trim() + ? proposalData.dealerComments.trim() + : 'Dealer proposal submitted'; + + // Save proposal history BEFORE approving + // Use dealer user ID if available, otherwise use initiator ID as fallback + const historyUserId = actualDealerUserId || (request as any).initiatorId || null; + if (!historyUserId) { + logger.warn(`[DealerClaimService] No user ID available for proposal history, skipping history save`); + } else { + await this.saveProposalHistory( + requestId, + dealerProposalLevel.levelId, + dealerProposalLevel.levelNumber, + `Proposal Submitted: ${approvalComment}`, + historyUserId + ); + + // Save workflow history - dealer submitting document is also an action + await this.saveWorkflowHistory( + requestId, + `Dealer submitted proposal document`, + historyUserId, + dealerProposalLevel.levelId, + dealerProposalLevel.levelNumber, + dealerProposalLevel.levelName || undefined + ); + } + await this.approvalService.approveLevel( dealerProposalLevel.levelId, - { action: 'APPROVE', comments: 'Dealer proposal submitted' }, - 'system', // System approval + { action: 'APPROVE', comments: approvalComment }, + actualDealerUserId || (request as any).initiatorId || 'system', // Use dealer or initiator ID { ipAddress: null, userAgent: null } ); } @@ -1304,7 +1348,9 @@ export class DealerClaimService { totalClosedExpenses: number; invoicesReceipts?: any[]; attendanceSheet?: any; - } + completionDescription?: string; + }, + dealerUserId?: string // Optional dealer user ID for history tracking ): Promise { try { const request = await WorkflowRequest.findByPk(requestId); @@ -1319,16 +1365,16 @@ export class DealerClaimService { where: { requestId }, order: [['levelNumber', 'ASC']] }); - + const dealerCompletionStep = approvalLevels.find((level: any) => { const levelName = (level.levelName || '').toLowerCase(); return levelName.includes('dealer completion') || levelName.includes('completion documents'); }); - + if (!dealerCompletionStep) { throw new Error('Dealer Completion Documents step not found'); } - + // Check if current level matches the Dealer Completion Documents step (handles step shifts) if (request.currentLevel !== dealerCompletionStep.levelNumber) { throw new Error(`Completion documents can only be submitted at the Dealer Completion Documents step (currently at step ${request.currentLevel})`); @@ -1368,8 +1414,8 @@ export class DealerClaimService { // Approve Dealer Completion Documents step dynamically (by levelName, not hardcoded step number) let dealerCompletionLevel = await ApprovalLevel.findOne({ - where: { - requestId, + where: { + requestId, levelName: 'Dealer Completion Documents' } }); @@ -1382,10 +1428,52 @@ export class DealerClaimService { } if (dealerCompletionLevel) { + // Use dealer's completion description if provided, otherwise use default message + const approvalComment = completionData.completionDescription?.trim() + ? completionData.completionDescription.trim() + : 'Completion documents submitted'; + + // Get dealer user ID if not provided - try to find by dealer email from claim details + let actualDealerUserId: string | null = dealerUserId || null; + if (!actualDealerUserId) { + const claimDetails = await DealerClaimDetails.findOne({ where: { requestId } }); + if (claimDetails?.dealerEmail) { + const dealerUser = await User.findOne({ + where: { email: claimDetails.dealerEmail } + }); + actualDealerUserId = dealerUser?.userId || null; + } + } + + // Use dealer user ID if available, otherwise use initiator ID as fallback + const historyUserId = actualDealerUserId || (request as any).initiatorId || null; + if (!historyUserId) { + logger.warn(`[DealerClaimService] No user ID available for completion history, skipping history save`); + } else { + // Save completion history BEFORE approving + await this.saveCompletionHistory( + requestId, + dealerCompletionLevel.levelId, + dealerCompletionLevel.levelNumber, + `Completion Submitted: ${approvalComment}`, + historyUserId + ); + + // Save workflow history - dealer submitting completion document is also an action + await this.saveWorkflowHistory( + requestId, + `Dealer submitted completion document`, + historyUserId, + dealerCompletionLevel.levelId, + dealerCompletionLevel.levelNumber, + dealerCompletionLevel.levelName || undefined + ); + } + await this.approvalService.approveLevel( dealerCompletionLevel.levelId, - { action: 'APPROVE', comments: 'Completion documents submitted' }, - 'system', + { action: 'APPROVE', comments: approvalComment }, + actualDealerUserId || (request as any).initiatorId || 'system', { ipAddress: null, userAgent: null } ); } @@ -1420,7 +1508,7 @@ export class DealerClaimService { try { // Ensure blockedAmount is rounded to exactly 2 decimal places from the start const blockedAmount = ioData.blockedAmount ? parseFloat(ioData.blockedAmount.toFixed(2)) : 0; - + // If blocking amount > 0, proceed with SAP integration and blocking // If blocking amount is 0 but ioNumber is provided, just save the IO details without blocking if (blockedAmount <= 0) { @@ -1428,7 +1516,7 @@ export class DealerClaimService { // This is useful when Requestor Evaluation is in progress but amount hasn't been blocked yet if (ioData.ioNumber) { const organizedBy = organizedByUserId || null; - + // Create or update Internal Order record with just IO details (no blocking) const [internalOrder, created] = await InternalOrder.findOrCreate({ where: { requestId }, @@ -1456,7 +1544,7 @@ export class DealerClaimService { organizedBy: organizedBy || internalOrder.organizedBy, organizedAt: new Date(), }); - + logger.info(`[DealerClaimService] IO details updated (preserved existing balance values) for request: ${requestId}`, { ioNumber: ioData.ioNumber, preservedAvailableBalance: internalOrder.ioAvailableBalance, @@ -1468,7 +1556,7 @@ export class DealerClaimService { logger.info(`[DealerClaimService] IO details saved (without blocking) for request: ${requestId}`, { ioNumber: ioData.ioNumber }); - + return; // Exit early - no SAP blocking needed } else { throw new Error('Blocked amount must be greater than 0, or ioNumber must be provided'); @@ -1477,7 +1565,7 @@ export class DealerClaimService { // Validate IO number with SAP const ioValidation = await sapIntegrationService.validateIONumber(ioData.ioNumber); - + if (!ioValidation.isValid) { throw new Error(`Invalid IO number: ${ioValidation.error || 'IO number not found in SAP'}`); } @@ -1485,7 +1573,7 @@ export class DealerClaimService { // Block budget in SAP const request = await WorkflowRequest.findByPk(requestId); const requestNumber = request ? ((request as any).requestNumber || (request as any).request_number) : 'UNKNOWN'; - + logger.info(`[DealerClaimService] Blocking budget in SAP:`, { requestId, requestNumber, @@ -1493,7 +1581,7 @@ export class DealerClaimService { amountToBlock: blockedAmount, availableBalance: ioData.availableBalance || ioValidation.availableBalance, }); - + const blockResult = await sapIntegrationService.blockBudget( ioData.ioNumber, blockedAmount, @@ -1511,21 +1599,21 @@ export class DealerClaimService { const sapDocumentNumber = blockResult.blockId || undefined; // Ensure availableBalance is rounded to 2 decimal places for accurate calculations const availableBalance = parseFloat((ioData.availableBalance || ioValidation.availableBalance).toFixed(2)); - + // Log if SAP reference number was received if (sapDocumentNumber) { logger.info(`[DealerClaimService] ✅ SAP Reference Number received: ${sapDocumentNumber}`); } else { logger.warn(`[DealerClaimService] ⚠️ No SAP Reference Number received from SAP response`); } - + // Use the amount we REQUESTED for calculation, not what SAP returned // SAP might return a slightly different amount due to rounding, but we calculate based on what we requested // Only use SAP's returned amount if it's significantly different (more than 1 rupee), which would indicate an actual issue const amountDifference = Math.abs(sapReturnedBlockedAmount - blockedAmount); const useSapAmount = amountDifference > 1.0; // Only use SAP's amount if difference is more than 1 rupee const finalBlockedAmount = useSapAmount ? sapReturnedBlockedAmount : blockedAmount; - + // Log SAP response vs what we sent logger.info(`[DealerClaimService] SAP block result:`, { requestedAmount: blockedAmount, @@ -1537,7 +1625,7 @@ export class DealerClaimService { usingSapAmount: useSapAmount, finalBlockedAmountUsed: finalBlockedAmount, }); - + // Warn if SAP blocked a significantly different amount than requested if (amountDifference > 0.01) { if (amountDifference > 1.0) { @@ -1546,33 +1634,33 @@ export class DealerClaimService { logger.info(`[DealerClaimService] Minor amount difference (likely rounding): Requested: ${blockedAmount}, SAP returned: ${sapReturnedBlockedAmount}, Using requested amount for calculation`); } } - + // Calculate remaining balance: availableBalance - requestedAmount // IMPORTANT: Use the amount we REQUESTED, not SAP's returned amount (unless SAP blocked significantly different amount) // This ensures accuracy: remaining = available - requested // Round to 2 decimal places to avoid floating point precision issues const calculatedRemainingBalance = parseFloat((availableBalance - finalBlockedAmount).toFixed(2)); - + // Only use SAP's value if it's valid AND matches our calculation (within 1 rupee tolerance) // This is a safety check - if SAP's value is way off, use our calculation // Round SAP's value to 2 decimal places for consistency const sapRemainingBalance = blockResult.remainingBalance ? parseFloat(blockResult.remainingBalance.toFixed(2)) : 0; - const sapValueIsValid = sapRemainingBalance > 0 && - sapRemainingBalance <= availableBalance && - Math.abs(sapRemainingBalance - calculatedRemainingBalance) < 1; - - const remainingBalance = sapValueIsValid - ? sapRemainingBalance + const sapValueIsValid = sapRemainingBalance > 0 && + sapRemainingBalance <= availableBalance && + Math.abs(sapRemainingBalance - calculatedRemainingBalance) < 1; + + const remainingBalance = sapValueIsValid + ? sapRemainingBalance : calculatedRemainingBalance; - + // Ensure remaining balance is not negative and round to 2 decimal places const finalRemainingBalance = parseFloat(Math.max(0, remainingBalance).toFixed(2)); - + // Warn if SAP's value doesn't match our calculation if (!sapValueIsValid && sapRemainingBalance !== calculatedRemainingBalance) { logger.warn(`[DealerClaimService] ⚠️ SAP returned invalid remaining balance (${sapRemainingBalance}), using calculated value (${calculatedRemainingBalance})`); } - + logger.info(`[DealerClaimService] Budget blocking calculation:`, { availableBalance, blockedAmount: finalBlockedAmount, @@ -1589,7 +1677,7 @@ export class DealerClaimService { const roundedAvailableBalance = parseFloat(availableBalance.toFixed(2)); const roundedBlockedAmount = parseFloat(finalBlockedAmount.toFixed(2)); const roundedRemainingBalance = parseFloat(finalRemainingBalance.toFixed(2)); - + // Create or update Internal Order record (only when blocking) const ioRecordData = { requestId, @@ -1603,7 +1691,7 @@ export class DealerClaimService { organizedAt: new Date(), status: IOStatus.BLOCKED, }; - + logger.info(`[DealerClaimService] Storing IO details in database:`, { ioNumber: ioData.ioNumber, ioAvailableBalance: availableBalance, @@ -1612,7 +1700,7 @@ export class DealerClaimService { sapDocumentNumber: sapDocumentNumber, requestId }); - + const [internalOrder, created] = await InternalOrder.findOrCreate({ where: { requestId }, defaults: ioRecordData @@ -1627,7 +1715,7 @@ export class DealerClaimService { ioAvailableBalance: ioRecordData.ioAvailableBalance, sapDocumentNumber: ioRecordData.sapDocumentNumber }); - + // Explicitly update all fields to ensure remainingBalance is saved const updateResult = await internalOrder.update({ ioNumber: ioRecordData.ioNumber, @@ -1640,16 +1728,16 @@ export class DealerClaimService { organizedAt: ioRecordData.organizedAt, status: ioRecordData.status }); - + logger.info(`[DealerClaimService] Update result:`, updateResult ? 'Success' : 'Failed'); } else { logger.info(`[DealerClaimService] Created new IO record for request: ${requestId}`); } - + // Verify what was actually saved - reload from database await internalOrder.reload(); const savedRemainingBalance = internalOrder.ioRemainingBalance; - + logger.info(`[DealerClaimService] ✅ IO record after save (verified from database):`, { ioId: internalOrder.ioId, ioNumber: internalOrder.ioNumber, @@ -1660,12 +1748,62 @@ export class DealerClaimService { match: savedRemainingBalance === finalRemainingBalance || Math.abs((savedRemainingBalance || 0) - finalRemainingBalance) < 0.01, status: internalOrder.status }); - + // Warn if remaining balance doesn't match if (Math.abs((savedRemainingBalance || 0) - finalRemainingBalance) >= 0.01) { logger.error(`[DealerClaimService] ⚠️ WARNING: Remaining balance mismatch! Expected: ${finalRemainingBalance}, Saved: ${savedRemainingBalance}`); } + // Save IO history after successful blocking + // Find the Department Lead IO Approval level (Step 3) + const ioApprovalLevel = await ApprovalLevel.findOne({ + where: { + requestId, + levelName: 'Department Lead IO Approval' + } + }); + + // Fallback: try to find by levelNumber 3 + const ioLevel = ioApprovalLevel || await ApprovalLevel.findOne({ + where: { requestId, levelNumber: 3 } + }); + + if (ioLevel) { + // Get user ID for history - use organizedBy if it's a UUID, otherwise try to find user + let ioHistoryUserId: string | null = null; + if (organizedBy) { + // Check if organizedBy is a valid UUID + const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + if (uuidRegex.test(organizedBy)) { + ioHistoryUserId = organizedBy; + } else { + // Try to find user by email or name + const user = await User.findOne({ + where: { email: organizedBy } + }); + ioHistoryUserId = user?.userId || null; + } + } + + // Fallback to initiator if no user found + if (!ioHistoryUserId) { + const request = await WorkflowRequest.findByPk(requestId); + ioHistoryUserId = (request as any)?.initiatorId || null; + } + + if (ioHistoryUserId) { + await this.saveIOHistory( + requestId, + ioLevel.levelId, + ioLevel.levelNumber, + `IO Blocked: ₹${finalBlockedAmount.toFixed(2)} blocked in SAP`, + ioHistoryUserId + ); + } else { + logger.warn(`[DealerClaimService] No user ID available for IO history, skipping history save`); + } + } + // Update budget tracking with blocked amount await ClaimBudgetTracking.upsert({ requestId, @@ -1727,10 +1865,10 @@ export class DealerClaimService { // If invoice data not provided, generate via DMS if (!invoiceData?.eInvoiceNumber) { const proposalDetails = await DealerProposalDetails.findOne({ where: { requestId } }); - const invoiceAmount = invoiceData?.amount - || proposalDetails?.totalEstimatedBudget - || budgetTracking?.proposalEstimatedBudget - || budgetTracking?.initialEstimatedBudget + const invoiceAmount = invoiceData?.amount + || proposalDetails?.totalEstimatedBudget + || budgetTracking?.proposalEstimatedBudget + || budgetTracking?.initialEstimatedBudget || 0; const invoiceResult = await dmsIntegrationService.generateEInvoice({ @@ -1783,11 +1921,11 @@ export class DealerClaimService { where: { requestId }, order: [['levelNumber', 'ASC']] }); - + let requestorClaimLevel = approvalLevels.find((level: any) => { const levelName = (level.levelName || '').toLowerCase(); - return levelName.includes('requestor') && - (levelName.includes('claim') || levelName.includes('approval')); + return levelName.includes('requestor') && + (levelName.includes('claim') || levelName.includes('approval')); }); // Fallback: try to find by levelNumber 5 (new position after removing system steps) @@ -1815,7 +1953,7 @@ export class DealerClaimService { // Requestor Claim Approval already approved logger.info(`[DealerClaimService] Requestor Claim Approval already approved. E-Invoice generation will be logged as activity when DMS webhook is received.`); } - + // Log E-Invoice generation as activity (no approval level needed) await activityService.log({ requestId, @@ -1900,9 +2038,9 @@ export class DealerClaimService { // If credit note data not provided, generate via DMS if (!creditNoteData?.creditNoteNumber) { - const creditNoteAmount = creditNoteData?.creditNoteAmount - || budgetTracking?.closedExpenses - || completionDetails?.totalClosedExpenses + const creditNoteAmount = creditNoteData?.creditNoteAmount + || budgetTracking?.closedExpenses + || completionDetails?.totalClosedExpenses || 0; // Only generate via DMS if invoice exists, otherwise allow manual entry @@ -2081,10 +2219,10 @@ export class DealerClaimService { // Get participants for email notifications const initiator = await User.findByPk((request as any).initiatorId); - const dealerUser = claimDetails.dealerEmail + const dealerUser = claimDetails.dealerEmail ? await User.findOne({ where: { email: claimDetails.dealerEmail } }) : null; - + // Get department lead dynamically (by levelName, not hardcoded step number) let deptLeadLevel = await ApprovalLevel.findOne({ where: { @@ -2092,7 +2230,7 @@ export class DealerClaimService { levelName: 'Department Lead Approval' } }); - + // Fallback: try to find by levelNumber 3 (for backwards compatibility) if (!deptLeadLevel) { deptLeadLevel = await ApprovalLevel.findOne({ @@ -2102,7 +2240,7 @@ export class DealerClaimService { } }); } - const departmentLead = deptLeadLevel?.approverId + const departmentLead = deptLeadLevel?.approverId ? await User.findByPk(deptLeadLevel.approverId) : null; @@ -2185,5 +2323,957 @@ export class DealerClaimService { throw error; } } + + /** + * Snapshot current claim state for version history before revisions + */ + /** + * Save proposal version history (Step 1) + */ + async saveProposalHistory( + requestId: string, + approvalLevelId: string, + levelNumber: number, + changeReason: string, + userId: string + ): Promise { + try { + const proposalDetails = await DealerProposalDetails.findOne({ where: { requestId } }); + if (!proposalDetails) { + logger.warn(`[DealerClaimService] No proposal found for request ${requestId}, skipping history`); + return; + } + + const costItems = await DealerProposalCostItem.findAll({ + where: { proposalId: (proposalDetails as any).proposalId || (proposalDetails as any).proposal_id } + }); + + // Get level name from approval level + const level = await ApprovalLevel.findByPk(approvalLevelId); + const levelName = level?.levelName || undefined; + + // Get next version for this level (match by levelName for consistency) + const lastVersion = await DealerClaimHistory.findOne({ + where: levelName ? { + requestId, + levelName, + snapshotType: SnapshotType.PROPOSAL + } : { + requestId, + levelNumber, + snapshotType: SnapshotType.PROPOSAL + }, + order: [['version', 'DESC']] + }); + const nextVersion = lastVersion ? lastVersion.version + 1 : 1; + + // Store all proposal data in JSONB + // Handle expectedCompletionDate - it might be a Date object, string, or null + let expectedCompletionDateStr = null; + if (proposalDetails.expectedCompletionDate) { + if (proposalDetails.expectedCompletionDate instanceof Date) { + expectedCompletionDateStr = proposalDetails.expectedCompletionDate.toISOString(); + } else if (typeof proposalDetails.expectedCompletionDate === 'string') { + expectedCompletionDateStr = proposalDetails.expectedCompletionDate; + } + } + + const snapshotData = { + documentUrl: proposalDetails.proposalDocumentUrl, + totalBudget: Number(proposalDetails.totalEstimatedBudget || 0), + comments: proposalDetails.dealerComments, + expectedCompletionDate: expectedCompletionDateStr, + costItems: costItems.map(i => ({ + description: i.itemDescription, + amount: Number(i.amount || 0), + order: i.itemOrder + })) + }; + + await DealerClaimHistory.create({ + requestId, + approvalLevelId, + levelNumber, + levelName, + version: nextVersion, + snapshotType: SnapshotType.PROPOSAL, + snapshotData, + changeReason, + changedBy: userId + }); + + logger.info(`[DealerClaimService] Saved proposal history (v${nextVersion}) for level ${levelNumber}, request ${requestId}`); + } catch (error) { + logger.error(`[DealerClaimService] Error saving proposal history for request ${requestId}:`, error); + } + } + + /** + * Save completion version history (Step 4/5) + */ + async saveCompletionHistory( + requestId: string, + approvalLevelId: string, + levelNumber: number, + changeReason: string, + userId: string + ): Promise { + try { + const completionDetails = await DealerCompletionDetails.findOne({ where: { requestId } }); + if (!completionDetails) { + logger.warn(`[DealerClaimService] No completion found for request ${requestId}, skipping history`); + return; + } + + const expenses = await DealerCompletionExpense.findAll({ where: { requestId } }); + + // Get level name from approval level + const level = await ApprovalLevel.findByPk(approvalLevelId); + const levelName = level?.levelName || undefined; + + // Get next version for this level (match by levelName for consistency) + const lastVersion = await DealerClaimHistory.findOne({ + where: levelName ? { + requestId, + levelName, + snapshotType: SnapshotType.COMPLETION + } : { + requestId, + levelNumber, + snapshotType: SnapshotType.COMPLETION + }, + order: [['version', 'DESC']] + }); + const nextVersion = lastVersion ? lastVersion.version + 1 : 1; + + // Store all completion data in JSONB + const snapshotData = { + documentUrl: (completionDetails as any).completionDocumentUrl || null, + totalExpenses: Number(completionDetails.totalClosedExpenses || 0), + comments: (completionDetails as any).completionDescription || null, + expenses: expenses.map(e => ({ + description: e.description, + amount: Number(e.amount || 0) + })) + }; + + await DealerClaimHistory.create({ + requestId, + approvalLevelId, + levelNumber, + levelName, + version: nextVersion, + snapshotType: SnapshotType.COMPLETION, + snapshotData, + changeReason, + changedBy: userId + }); + + logger.info(`[DealerClaimService] Saved completion history (v${nextVersion}) for level ${levelNumber}, request ${requestId}`); + } catch (error) { + logger.error(`[DealerClaimService] Error saving completion history for request ${requestId}:`, error); + } + } + + /** + * Save internal order version history + */ + async saveIOHistory( + requestId: string, + approvalLevelId: string, + levelNumber: number, + changeReason: string, + userId: string + ): Promise { + try { + const internalOrder = await InternalOrder.findOne({ where: { requestId } }); + if (!internalOrder || !internalOrder.ioBlockedAmount || internalOrder.ioBlockedAmount <= 0) { + logger.warn(`[DealerClaimService] No IO block found for request ${requestId}, skipping history`); + return; + } + + // Get level name from approval level + const level = await ApprovalLevel.findByPk(approvalLevelId); + const levelName = level?.levelName || undefined; + + // Get next version for this level (match by levelName for consistency) + const lastVersion = await DealerClaimHistory.findOne({ + where: levelName ? { + requestId, + levelName, + snapshotType: SnapshotType.INTERNAL_ORDER + } : { + requestId, + levelNumber, + snapshotType: SnapshotType.INTERNAL_ORDER + }, + order: [['version', 'DESC']] + }); + const nextVersion = lastVersion ? lastVersion.version + 1 : 1; + + // Store all IO data in JSONB + const snapshotData = { + ioNumber: internalOrder.ioNumber, + blockedAmount: Number(internalOrder.ioBlockedAmount || 0), + availableBalance: Number(internalOrder.ioAvailableBalance || 0), + remainingBalance: Number(internalOrder.ioRemainingBalance || 0), + sapDocumentNumber: internalOrder.sapDocumentNumber + }; + + await DealerClaimHistory.create({ + requestId, + approvalLevelId, + levelNumber, + levelName, + version: nextVersion, + snapshotType: SnapshotType.INTERNAL_ORDER, + snapshotData, + changeReason, + changedBy: userId + }); + + logger.info(`[DealerClaimService] Saved IO history (v${nextVersion}) for level ${levelNumber}, request ${requestId}`); + } catch (error) { + logger.error(`[DealerClaimService] Error saving IO history for request ${requestId}:`, error); + } + } + + /** + * Save approval version history (for approver actions) + */ + async saveApprovalHistory( + requestId: string, + approvalLevelId: string, + levelNumber: number, + action: 'APPROVE' | 'REJECT', + comments: string, + rejectionReason: string | undefined, + userId: string + ): Promise { + try { + const level = await ApprovalLevel.findByPk(approvalLevelId); + if (!level) { + logger.warn(`[DealerClaimService] No approval level found for ${approvalLevelId}, skipping history`); + return; + } + + // Get next version for this level (match by levelName for consistency) + const lastVersion = await DealerClaimHistory.findOne({ + where: level.levelName ? { + requestId, + levelName: level.levelName, + snapshotType: SnapshotType.APPROVE + } : { + requestId, + levelNumber, + snapshotType: SnapshotType.APPROVE + }, + order: [['version', 'DESC']] + }); + const nextVersion = lastVersion ? lastVersion.version + 1 : 1; + + // Store approval data in JSONB + const snapshotData = { + action, + comments: comments || undefined, + rejectionReason: rejectionReason || undefined, + approverName: level.approverName, + approverEmail: level.approverEmail, + levelName: level.levelName + }; + + const changeReason = action === 'APPROVE' + ? `Approved by ${level.approverName || level.approverEmail}` + : `Rejected by ${level.approverName || level.approverEmail}`; + + await DealerClaimHistory.create({ + requestId, + approvalLevelId, + levelNumber, + levelName: level.levelName || undefined, + version: nextVersion, + snapshotType: SnapshotType.APPROVE, + snapshotData, + changeReason, + changedBy: userId + }); + + logger.info(`[DealerClaimService] Saved approval history (v${nextVersion}) for level ${levelNumber}, request ${requestId}`); + } catch (error) { + logger.error(`[DealerClaimService] Error saving approval history for request ${requestId}:`, error); + } + } + + /** + * Save workflow-level version history (for actions that move workflow forward/backward) + */ + async saveWorkflowHistory( + requestId: string, + changeReason: string, + userId: string, + approvalLevelId?: string, + levelNumber?: number, + levelName?: string + ): Promise { + try { + const wf = await WorkflowRequest.findByPk(requestId); + if (!wf) return; + + // Get next version for workflow-level snapshots + const lastVersion = await DealerClaimHistory.findOne({ + where: { + requestId, + snapshotType: SnapshotType.WORKFLOW + }, + order: [['version', 'DESC']] + }); + const nextVersion = lastVersion ? lastVersion.version + 1 : 1; + + // Store workflow data in JSONB + const snapshotData = { + status: wf.status, + currentLevel: wf.currentLevel + }; + + await DealerClaimHistory.create({ + requestId, + approvalLevelId: approvalLevelId || undefined, + levelNumber: levelNumber || undefined, + levelName: levelName || undefined, + version: nextVersion, + snapshotType: SnapshotType.WORKFLOW, + snapshotData, + changeReason, + changedBy: userId + }); + + logger.info(`[DealerClaimService] Saved workflow history (v${nextVersion}) for request ${requestId}, level ${levelNumber || 'N/A'}`); + } catch (error) { + logger.error(`[DealerClaimService] Error saving workflow history for request ${requestId}:`, error); + } + } + + /** + * Create or activate initiator action level when request is rejected + * This allows initiator to take action (REVISE, CANCEL, REOPEN) directly from the step card + */ + async createOrActivateInitiatorLevel( + requestId: string, + userId: string + ): Promise { + try { + const wf = await WorkflowRequest.findByPk(requestId); + if (!wf) return null; + + // Check if initiator level already exists + let initiatorLevel = await ApprovalLevel.findOne({ + where: { + requestId, + levelName: 'Initiator Action' + } + }); + + if (initiatorLevel) { + // Activate existing level + await initiatorLevel.update({ + status: ApprovalStatus.IN_PROGRESS, + levelStartTime: new Date(), + tatStartTime: new Date(), + approverId: wf.initiatorId + }); + return initiatorLevel; + } + + // Create new initiator level + // Find the highest level number to place it after + const maxLevel = await ApprovalLevel.findOne({ + where: { requestId }, + order: [['levelNumber', 'DESC']] + }); + const nextLevelNumber = maxLevel ? maxLevel.levelNumber + 1 : 0; + + // Get initiator user details + const initiatorUser = await User.findByPk(wf.initiatorId); + if (!initiatorUser) { + throw new Error('Initiator user not found'); + } + + initiatorLevel = await ApprovalLevel.create({ + requestId, + levelNumber: nextLevelNumber, + levelName: 'Initiator Action', + approverId: wf.initiatorId, + approverEmail: initiatorUser.email || '', + approverName: initiatorUser.displayName || initiatorUser.email || 'Initiator', + status: ApprovalStatus.IN_PROGRESS, + levelStartTime: new Date(), + tatStartTime: new Date(), + tatHours: 0, // No TAT for initiator action + elapsedHours: 0, + remainingHours: 0, + tatPercentageUsed: 0, + isFinalApprover: false + } as any); + + logger.info(`[DealerClaimService] Created/activated initiator level for request ${requestId}`); + return initiatorLevel; + } catch (error) { + logger.error(`[DealerClaimService] Error creating/activating initiator level:`, error); + return null; + } + } + + /** + * @deprecated - Removed complex snapshot method. Snapshots are now taken at step execution. + */ + async saveCompleteRevisionSnapshot_DEPRECATED( + requestId: string, + changeReason: string, + userId: string + ): Promise { + try { + logger.info(`[DealerClaimService] Capturing complete revision snapshot for request ${requestId}`); + + // 1. Capture current proposal snapshot (if exists) + const proposalDetails = await DealerProposalDetails.findOne({ where: { requestId } }); + if (proposalDetails) { + const costItems = await DealerProposalCostItem.findAll({ + where: { proposalId: (proposalDetails as any).proposalId || (proposalDetails as any).proposal_id } + }); + + // Find dealer proposal level + const dealerLevel = await ApprovalLevel.findOne({ + where: { + requestId, + levelName: 'Dealer Proposal Submission' + } + }) || await ApprovalLevel.findOne({ + where: { requestId, levelNumber: 1 } + }); + + if (dealerLevel) { + const proposalSnapshotData = { + documentUrl: proposalDetails.proposalDocumentUrl, + totalBudget: Number(proposalDetails.totalEstimatedBudget || 0), + comments: proposalDetails.dealerComments, + expectedCompletionDate: proposalDetails.expectedCompletionDate ? proposalDetails.expectedCompletionDate.toISOString() : null, + costItems: costItems.map(i => ({ + description: i.itemDescription, + amount: Number(i.amount || 0), + order: i.itemOrder + })) + }; + + // Get next version for this level + const lastProposalVersion = await DealerClaimHistory.findOne({ + where: { + requestId, + levelName: dealerLevel.levelName || undefined, + snapshotType: SnapshotType.PROPOSAL + }, + order: [['version', 'DESC']] + }); + const nextProposalVersion = lastProposalVersion ? lastProposalVersion.version + 1 : 1; + + await DealerClaimHistory.create({ + requestId, + approvalLevelId: dealerLevel.levelId, + levelNumber: dealerLevel.levelNumber, + levelName: dealerLevel.levelName || undefined, + version: nextProposalVersion, + snapshotType: SnapshotType.PROPOSAL, + snapshotData: proposalSnapshotData, + changeReason: `${changeReason} - Pre-revision snapshot`, + changedBy: userId + }); + + logger.info(`[DealerClaimService] Captured proposal snapshot (v${nextProposalVersion}) for revision`); + } + } + + // 2. Capture current completion snapshot (if exists) + const completionDetails = await DealerCompletionDetails.findOne({ where: { requestId } }); + if (completionDetails) { + const expenses = await DealerCompletionExpense.findAll({ + where: { completionId: (completionDetails as any).completionId || (completionDetails as any).completion_id } + }); + + // Find completion level + const completionLevel = await ApprovalLevel.findOne({ + where: { + requestId, + levelName: 'Dealer Completion Documents' + } + }) || await ApprovalLevel.findOne({ + where: { requestId, levelNumber: 4 } + }); + + if (completionLevel) { + const completionSnapshotData = { + documentUrl: (completionDetails as any).completionDocumentUrl || null, + totalExpenses: Number(completionDetails.totalClosedExpenses || 0), + comments: (completionDetails as any).completionDescription || null, + expenses: expenses.map(e => ({ + description: e.description, + amount: Number(e.amount || 0) + })) + }; + + // Get next version for this level + const lastCompletionVersion = await DealerClaimHistory.findOne({ + where: { + requestId, + levelName: completionLevel.levelName || undefined, + snapshotType: SnapshotType.COMPLETION + }, + order: [['version', 'DESC']] + }); + const nextCompletionVersion = lastCompletionVersion ? lastCompletionVersion.version + 1 : 1; + + await DealerClaimHistory.create({ + requestId, + approvalLevelId: completionLevel.levelId, + levelNumber: completionLevel.levelNumber, + levelName: completionLevel.levelName || undefined, + version: nextCompletionVersion, + snapshotType: SnapshotType.COMPLETION, + snapshotData: completionSnapshotData, + changeReason: `${changeReason} - Pre-revision snapshot`, + changedBy: userId + }); + + logger.info(`[DealerClaimService] Captured completion snapshot (v${nextCompletionVersion}) for revision`); + } + } + + // 3. Capture current IO snapshot (if exists) + const internalOrder = await InternalOrder.findOne({ where: { requestId } }); + if (internalOrder && internalOrder.ioBlockedAmount && internalOrder.ioBlockedAmount > 0) { + const ioLevel = await ApprovalLevel.findOne({ + where: { + requestId, + levelName: 'Department Lead IO Approval' + } + }) || await ApprovalLevel.findOne({ + where: { requestId, levelNumber: 3 } + }); + + if (ioLevel) { + const ioSnapshotData = { + ioNumber: internalOrder.ioNumber, + blockedAmount: Number(internalOrder.ioBlockedAmount || 0), + availableBalance: Number(internalOrder.ioAvailableBalance || 0), + remainingBalance: Number(internalOrder.ioRemainingBalance || 0), + sapDocumentNumber: internalOrder.sapDocumentNumber + }; + + // Get next version for this level + const lastIOVersion = await DealerClaimHistory.findOne({ + where: { + requestId, + levelName: ioLevel.levelName || undefined, + snapshotType: SnapshotType.INTERNAL_ORDER + }, + order: [['version', 'DESC']] + }); + const nextIOVersion = lastIOVersion ? lastIOVersion.version + 1 : 1; + + await DealerClaimHistory.create({ + requestId, + approvalLevelId: ioLevel.levelId, + levelNumber: ioLevel.levelNumber, + levelName: ioLevel.levelName || undefined, + version: nextIOVersion, + snapshotType: SnapshotType.INTERNAL_ORDER, + snapshotData: ioSnapshotData, + changeReason: `${changeReason} - Pre-revision snapshot`, + changedBy: userId + }); + + logger.info(`[DealerClaimService] Captured IO snapshot (v${nextIOVersion}) for revision`); + } + } + + // 4. Capture ALL approval comments from all levels (so approvers can see their previous comments) + const allLevels = await ApprovalLevel.findAll({ + where: { requestId }, + order: [['levelNumber', 'ASC']] + }); + + for (const level of allLevels) { + // Only capture if level has been acted upon (has comments or action date) + if (level.comments || level.actionDate || level.status === ApprovalStatus.APPROVED || level.status === ApprovalStatus.REJECTED) { + const approver = level.approverId ? await User.findByPk(level.approverId) : null; + + const approvalSnapshotData = { + action: level.status === ApprovalStatus.APPROVED ? 'APPROVE' : level.status === ApprovalStatus.REJECTED ? 'REJECT' : 'PENDING', + comments: level.comments || undefined, + rejectionReason: level.status === ApprovalStatus.REJECTED ? (level.comments || undefined) : undefined, + approverName: approver?.displayName || approver?.email || undefined, + approverEmail: approver?.email || undefined, + levelName: level.levelName || undefined + }; + + // Get next version for this level's approval snapshot + const lastApprovalVersion = await DealerClaimHistory.findOne({ + where: { + requestId, + levelName: level.levelName || undefined, + snapshotType: SnapshotType.APPROVE + }, + order: [['version', 'DESC']] + }); + const nextApprovalVersion = lastApprovalVersion ? lastApprovalVersion.version + 1 : 1; + + await DealerClaimHistory.create({ + requestId, + approvalLevelId: level.levelId, + levelNumber: level.levelNumber, + levelName: level.levelName || undefined, + version: nextApprovalVersion, + snapshotType: SnapshotType.APPROVE, + snapshotData: approvalSnapshotData, + changeReason: `${changeReason} - Pre-revision approval snapshot`, + changedBy: userId + }); + + logger.info(`[DealerClaimService] Captured approval snapshot (v${nextApprovalVersion}) for level ${level.levelNumber} (${level.levelName})`); + } + } + + // 5. Save workflow-level snapshot + const wf = await WorkflowRequest.findByPk(requestId); + if (wf) { + const lastWorkflowVersion = await DealerClaimHistory.findOne({ + where: { + requestId, + snapshotType: SnapshotType.WORKFLOW + }, + order: [['version', 'DESC']] + }); + const nextWorkflowVersion = lastWorkflowVersion ? lastWorkflowVersion.version + 1 : 1; + + await DealerClaimHistory.create({ + requestId, + version: nextWorkflowVersion, + snapshotType: SnapshotType.WORKFLOW, + snapshotData: { + status: wf.status, + currentLevel: wf.currentLevel + }, + changeReason: `${changeReason} - Pre-revision workflow snapshot`, + changedBy: userId + }); + + logger.info(`[DealerClaimService] Captured workflow snapshot (v${nextWorkflowVersion}) for revision`); + } + + logger.info(`[DealerClaimService] Complete revision snapshot captured for request ${requestId}`); + } catch (error) { + logger.error(`[DealerClaimService] Error saving complete revision snapshot for request ${requestId}:`, error); + // Don't throw - we want to continue even if snapshot fails + } + } + + /** + * Handle initiator actions when a request is in RETURNED status + */ + async handleInitiatorAction( + requestId: string, + userId: string, + action: 'REOPEN' | 'DISCUSS' | 'REVISE' | 'CANCEL', + data?: { reason: string } + ): Promise { + const wf = await WorkflowRequest.findByPk(requestId); + if (!wf) throw new Error('Request not found'); + + // Check if the current user is the initiator + if (wf.initiatorId !== userId) { + throw new Error('Only the initiator can perform actions on a rejected/returned request'); + } + + // A returned request is REJECTED but has NO closureDate + if (wf.status !== WorkflowStatus.REJECTED || wf.closureDate) { + throw new Error(`Request is in ${wf.status} status (Closed: ${!!wf.closureDate}), expected an open REJECTED state to perform this action`); + } + + const initiator = await User.findByPk(userId); + const initiatorName = initiator?.displayName || initiator?.email || 'Initiator'; + const now = new Date(); + + switch (action) { + case 'CANCEL': { + // Format change reason to include the comment if provided + const changeReason = data?.reason && data.reason.trim() + ? `Request Cancelled: ${data.reason.trim()}` + : 'Request Cancelled'; + + // Find current level for workflow history + const currentLevel = await ApprovalLevel.findOne({ + where: { requestId, levelNumber: wf.currentLevel || 1 } + }); + + await this.saveWorkflowHistory( + requestId, + changeReason, + userId, + currentLevel?.levelId || undefined, + currentLevel?.levelNumber || wf.currentLevel || undefined, + currentLevel?.levelName || undefined + ); + + await wf.update({ + status: WorkflowStatus.CLOSED, + closureDate: now + }); + + await activityService.log({ + requestId, + type: 'status_change', + user: { userId, name: initiatorName }, + timestamp: now.toISOString(), + action: 'Request Cancelled', + details: data?.reason && data.reason.trim() + ? `Request was cancelled by initiator. Reason: ${data.reason.trim()}` + : 'Request was cancelled by initiator.' + }); + break; + } + + case 'REOPEN': { + // Format change reason to include the comment if provided + const changeReason = data?.reason && data.reason.trim() + ? `Request Reopened: ${data.reason.trim()}` + : 'Request Reopened'; + + // Find Department Lead level dynamically (handles step shifts) + const approvalsReopen = await ApprovalLevel.findAll({ where: { requestId } }); + const deptLeadLevel = approvalsReopen.find(l => { + const name = (l.levelName || '').toLowerCase(); + return name.includes('department lead') || name.includes('dept lead') || l.levelNumber === 3; + }); + + if (!deptLeadLevel) { + throw new Error('Department Lead approval level not found for this request'); + } + + const deptLeadLevelNumber = deptLeadLevel.levelNumber; + + // Move back to Department Lead Approval level + await wf.update({ + status: WorkflowStatus.PENDING, + currentLevel: deptLeadLevelNumber + }); + + // Capture workflow snapshot AFTER moving to department lead level + await this.saveWorkflowHistory( + requestId, + `Reopened and moved to Department Lead level (${deptLeadLevelNumber}) - ${changeReason}`, + userId, + deptLeadLevel.levelId, + deptLeadLevelNumber, + deptLeadLevel.levelName || undefined + ); + + // Reset the found level status to IN_PROGRESS so Dept Lead can approve again + await deptLeadLevel.update({ + status: ApprovalStatus.IN_PROGRESS, + levelStartTime: now, + tatStartTime: now, + actionDate: undefined, + comments: undefined + }); + + await activityService.log({ + requestId, + type: 'approval', + user: { userId, name: initiatorName }, + timestamp: now.toISOString(), + action: 'Request Reopened', + details: data?.reason && data.reason.trim() + ? `Initiator reopened the request for Department Lead approval. Reason: ${data.reason.trim()}` + : 'Initiator reopened the request for Department Lead approval.' + }); + + if (deptLeadLevel.approverId) { + await notificationService.sendToUsers([deptLeadLevel.approverId], { + title: `Request Reopened: ${wf.requestNumber}`, + body: `Initiator has reopened the request "${wf.title}" after revision/discussion.`, + requestNumber: wf.requestNumber, + requestId: wf.requestId, + url: `/request/${wf.requestNumber}`, + type: 'assignment', + priority: 'HIGH', + actionRequired: true + }); + } + break; + } + + case 'DISCUSS': { + // Format change reason to include the comment if provided + const changeReason = data?.reason && data.reason.trim() + ? `Discussion Requested: ${data.reason.trim()}` + : 'Discussion Requested'; + + // Find Dealer level dynamically + const approvalsDiscuss = await ApprovalLevel.findAll({ where: { requestId } }); + const dealerLevelDiscuss = approvalsDiscuss.find(l => { + const name = (l.levelName || '').toLowerCase(); + return name.includes('dealer proposal') || l.levelNumber === 1; + }); + + // Save workflow history with dealer level information + await this.saveWorkflowHistory( + requestId, + changeReason, + userId, + dealerLevelDiscuss?.levelId || undefined, + dealerLevelDiscuss?.levelNumber || undefined, + dealerLevelDiscuss?.levelName || undefined + ); + + await activityService.log({ + requestId, + type: 'status_change', + user: { userId, name: initiatorName }, + timestamp: now.toISOString(), + action: 'Discuss with Dealer', + details: data?.reason && data.reason.trim() + ? `Initiator indicated they will discuss with the dealer. Reason: ${data.reason.trim()}` + : 'Initiator indicated they will discuss with the dealer.' + }); + + if (dealerLevelDiscuss?.approverId) { + await notificationService.sendToUsers([dealerLevelDiscuss.approverId], { + title: `Discussion Requested: ${wf.requestNumber}`, + body: `The initiator of request "${wf.title}" wants to discuss the proposal with you.`, + requestNumber: wf.requestNumber, + requestId: wf.requestId, + url: `/request/${wf.requestNumber}`, + type: 'info', + priority: 'MEDIUM' + }); + } + break; + } + + case 'REVISE': { + // Format change reason + const changeReason = data?.reason && data.reason.trim() + ? `Revision Requested: ${data.reason.trim()}` + : 'Revision Requested'; + + // Find current level and previous level + const allLevels = await ApprovalLevel.findAll({ + where: { requestId }, + order: [['levelNumber', 'ASC']] + }); + + const currentLevelNumber = wf.currentLevel || 1; + const currentLevel = allLevels.find(l => l.levelNumber === currentLevelNumber); + + if (!currentLevel) { + throw new Error('Current approval level not found'); + } + + // Find previous level (the one before current) + const previousLevel = allLevels.find(l => l.levelNumber < currentLevelNumber); + + if (!previousLevel) { + throw new Error('No previous level found to revise to'); + } + + // Move back to previous level + await wf.update({ + status: WorkflowStatus.PENDING, + currentLevel: previousLevel.levelNumber + }); + + // Capture workflow snapshot when moving back to previous level + await this.saveWorkflowHistory( + requestId, + `Moved back to previous level (${previousLevel.levelNumber}) - ${changeReason}`, + userId, + previousLevel.levelId, + previousLevel.levelNumber, + previousLevel.levelName || undefined + ); + + // Reset current level to PENDING + await currentLevel.update({ + status: ApprovalStatus.PENDING, + actionDate: undefined, + levelStartTime: undefined, + levelEndTime: undefined, + tatStartTime: undefined, + elapsedHours: 0, + tatPercentageUsed: 0, + comments: undefined + }); + + // Activate previous level + await previousLevel.update({ + status: ApprovalStatus.IN_PROGRESS, + levelStartTime: now, + tatStartTime: now, + comments: changeReason, // Save revision reason as comment + actionDate: undefined, + levelEndTime: undefined, + elapsedHours: 0, + tatPercentageUsed: 0 + }); + + await activityService.log({ + requestId, + type: 'assignment', + user: { userId, name: initiatorName }, + timestamp: now.toISOString(), + action: 'Revision Requested', + details: data?.reason && data.reason.trim() + ? `Initiator requested revision. Moving back to previous step. Reason: ${data.reason.trim()}` + : 'Initiator requested revision. Moving back to previous step.' + }); + + // Notify the approver of the previous level + if (previousLevel.approverId) { + await notificationService.sendToUsers([previousLevel.approverId], { + title: `Revision Required: ${wf.requestNumber}`, + body: `Initiator has requested a revision for request "${wf.title}". The request has been moved back to your level.`, + requestNumber: wf.requestNumber, + requestId: wf.requestId, + url: `/request/${wf.requestNumber}`, + type: 'assignment', + priority: 'HIGH', + actionRequired: true + }); + } + break; + } + } + + const { emitToRequestRoom } = await import('../realtime/socket'); + emitToRequestRoom(requestId, 'request:updated', { + requestId, + requestNumber: wf.requestNumber, + action: `INITIATOR_${action}`, + timestamp: now.toISOString() + }); + } + + async getHistory(requestId: string): Promise { + const history = await DealerClaimHistory.findAll({ + where: { requestId }, + order: [['version', 'DESC']], + include: [ + { + model: User, + as: 'changer', + attributes: ['userId', 'displayName', 'email'] + } + ] + }); + return history; + } } diff --git a/src/services/dealerClaimApproval.service.ts b/src/services/dealerClaimApproval.service.ts index d155987..b532a04 100644 --- a/src/services/dealerClaimApproval.service.ts +++ b/src/services/dealerClaimApproval.service.ts @@ -26,14 +26,18 @@ import { DealerClaimService } from './dealerClaim.service'; import { emitToRequestRoom } from '../realtime/socket'; export class DealerClaimApprovalService { + // Use lazy initialization to avoid circular dependency + private getDealerClaimService(): DealerClaimService { + return new DealerClaimService(); + } /** * Approve a level in a dealer claim workflow * Handles dealer claim-specific logic including dynamic approvers and activity creation */ async approveLevel( - levelId: string, - action: ApprovalAction, - userId: string, + levelId: string, + action: ApprovalAction, + userId: string, requestMetadata?: { ipAddress?: string | null; userAgent?: string | null } ): Promise { try { @@ -43,14 +47,14 @@ export class DealerClaimApprovalService { // Get workflow to determine priority for working hours calculation const wf = await WorkflowRequest.findByPk(level.requestId); if (!wf) return null; - + // Verify this is a claim management workflow const workflowType = (wf as any)?.workflowType; if (workflowType !== 'CLAIM_MANAGEMENT') { logger.warn(`[DealerClaimApproval] Attempted to use DealerClaimApprovalService for non-claim-management workflow ${level.requestId}. Workflow type: ${workflowType}`); throw new Error('DealerClaimApprovalService can only be used for CLAIM_MANAGEMENT workflows'); } - + const priority = ((wf as any)?.priority || 'standard').toString().toLowerCase(); const isPaused = (wf as any).isPaused || (level as any).isPaused; @@ -67,14 +71,14 @@ export class DealerClaimApprovalService { } const now = new Date(); - + // Calculate elapsed hours using working hours logic (with pause handling) const isPausedLevel = (level as any).isPaused; - const wasResumed = !isPausedLevel && - (level as any).pauseElapsedHours !== null && + const wasResumed = !isPausedLevel && + (level as any).pauseElapsedHours !== null && (level as any).pauseElapsedHours !== undefined && (level as any).pauseResumeDate !== null; - + const pauseInfo = isPausedLevel ? { // Level is currently paused - return frozen elapsed hours at pause time isPaused: true, @@ -102,6 +106,34 @@ export class DealerClaimApprovalService { return await this.handleRejection(level, action, userId, requestMetadata, elapsedHours, tatPercentage, now); } + // Save approval history BEFORE updating level + await this.getDealerClaimService().saveApprovalHistory( + level.requestId, + level.levelId, + level.levelNumber, + 'APPROVE', + action.comments || '', + undefined, + userId + ); + + // Capture workflow snapshot for approval action (before moving to next level) + // This captures the approval action itself, including initiator evaluation + const levelName = (level.levelName || '').toLowerCase(); + const isInitiatorEvaluation = levelName.includes('requestor') || levelName.includes('evaluation'); + const approvalMessage = isInitiatorEvaluation + ? `Initiator evaluated and approved (level ${level.levelNumber})` + : `Approved level ${level.levelNumber}`; + + await this.getDealerClaimService().saveWorkflowHistory( + level.requestId, + approvalMessage, + userId, + level.levelId, + level.levelNumber, + level.levelName || undefined + ); + // Update level status and elapsed time for approval await level.update({ status: ApprovalStatus.APPROVED, @@ -122,8 +154,8 @@ export class DealerClaimApprovalService { if (isFinalApprover) { // Final approval - close workflow await WorkflowRequest.update( - { - status: WorkflowStatus.APPROVED, + { + status: WorkflowStatus.APPROVED, closureDate: now, currentLevel: level.levelNumber || 0 }, @@ -155,33 +187,33 @@ export class DealerClaimApprovalService { logger.warn(`[DealerClaimApproval] Cannot advance workflow ${level.requestId} - workflow is paused`); throw new Error('Cannot advance workflow - workflow is currently paused. Please resume the workflow first.'); } - + // Find the next PENDING level (supports dynamically added approvers) // Strategy: First try sequential, then find next PENDING level if sequential doesn't exist const currentLevelNumber = level.levelNumber || 0; logger.info(`[DealerClaimApproval] Finding next level after level ${currentLevelNumber} for request ${level.requestId}`); - + // First, try sequential approach let nextLevel = await ApprovalLevel.findOne({ - where: { + where: { requestId: level.requestId, levelNumber: currentLevelNumber + 1 } }); - + // If sequential level doesn't exist, search for next PENDING level // This handles cases where additional approvers are added dynamically between steps if (!nextLevel) { logger.info(`[DealerClaimApproval] Sequential level ${currentLevelNumber + 1} not found, searching for next PENDING level (dynamic approvers)`); nextLevel = await ApprovalLevel.findOne({ - where: { + where: { requestId: level.requestId, levelNumber: { [Op.gt]: currentLevelNumber }, status: ApprovalStatus.PENDING }, order: [['levelNumber', 'ASC']] }); - + if (nextLevel) { logger.info(`[DealerClaimApproval] Using fallback level ${nextLevel.levelNumber} (${(nextLevel as any).levelName || 'unnamed'})`); } @@ -195,9 +227,9 @@ export class DealerClaimApprovalService { logger.warn(`[DealerClaimApproval] Sequential level ${currentLevelNumber + 1} exists but status is ${nextLevel.status}, expected PENDING. Proceeding with sequential level.`); } } - + const nextLevelNumber = nextLevel ? (nextLevel.levelNumber || 0) : null; - + 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}`); } else { @@ -210,18 +242,18 @@ export class DealerClaimApprovalService { logger.warn(`[DealerClaimApproval] Cannot activate next level ${nextLevelNumber} - level is paused`); throw new Error('Cannot activate next level - the next approval level is currently paused. Please resume it first.'); } - + // Activate next level await nextLevel.update({ status: ApprovalStatus.IN_PROGRESS, levelStartTime: now, tatStartTime: now }); - + // Schedule TAT jobs for the next level try { const workflowPriority = (wf as any)?.priority || 'STANDARD'; - + await tatSchedulerService.scheduleTatJobs( level.requestId, (nextLevel as any).levelId, @@ -235,29 +267,40 @@ export class DealerClaimApprovalService { logger.error(`[DealerClaimApproval] Failed to schedule TAT jobs for next level:`, tatError); // Don't fail the approval if TAT scheduling fails } - + // Update workflow current level if (nextLevelNumber !== null) { await WorkflowRequest.update( { currentLevel: nextLevelNumber }, { where: { requestId: level.requestId } } ); + + // Capture workflow snapshot when moving to next level + // Include both the approved level and the next level in the message + await this.getDealerClaimService().saveWorkflowHistory( + level.requestId, + `Level ${level.levelNumber} approved, moved to next level (${nextLevelNumber})`, + userId, + nextLevel?.levelId || undefined, // Store next level's ID since we're moving to it + nextLevelNumber || undefined, + (nextLevel as any)?.levelName || undefined + ); logger.info(`[DealerClaimApproval] Approved level ${level.levelNumber}. Activated next level ${nextLevelNumber} for workflow ${level.requestId}`); } - + // Handle dealer claim-specific step processing const currentLevelName = (level.levelName || '').toLowerCase(); // Check by levelName first, use levelNumber only as fallback if levelName is missing // This handles cases where additional approvers shift step numbers const hasLevelNameForDeptLead = level.levelName && level.levelName.trim() !== ''; - const isDeptLeadApproval = hasLevelNameForDeptLead + const isDeptLeadApproval = hasLevelNameForDeptLead ? currentLevelName.includes('department lead') : (level.levelNumber === 3); // Only use levelNumber if levelName is missing - + const isRequestorClaimApproval = hasLevelNameForDeptLead ? (currentLevelName.includes('requestor') && (currentLevelName.includes('claim') || currentLevelName.includes('approval'))) : (level.levelNumber === 5); // Only use levelNumber if levelName is missing - + if (isDeptLeadApproval) { // Activity Creation is now an activity log only - process it automatically logger.info(`[DealerClaimApproval] Department Lead approved. Processing Activity Creation as activity log.`); @@ -273,7 +316,7 @@ export class DealerClaimApprovalService { // E-Invoice Generation is now an activity log only - will be logged when invoice is generated via DMS webhook logger.info(`[DealerClaimApproval] Requestor Claim Approval approved. E-Invoice generation will be logged as activity when DMS webhook is received.`); } - + // Log approval activity activityService.log({ requestId: level.requestId, @@ -285,7 +328,7 @@ export class DealerClaimApprovalService { ipAddress: requestMetadata?.ipAddress || undefined, userAgent: requestMetadata?.userAgent || undefined }); - + // Notify initiator about the approval // BUT skip this if it's a dealer proposal or dealer completion step - those have special notifications below // Priority: levelName check first, then levelNumber only if levelName is missing @@ -297,11 +340,11 @@ export class DealerClaimApprovalService { const isDealerCompletionApproval = hasLevelNameForApproval ? (levelNameForApproval.includes('dealer') && (levelNameForApproval.includes('completion') || levelNameForApproval.includes('documents'))) : (level.levelNumber === 5); // Only use levelNumber if levelName is missing - + // Skip sending approval notification to initiator if they are the approver // (they don't need to be notified that they approved their own request) const isApproverInitiator = level.approverId && (wf as any).initiatorId && level.approverId === (wf as any).initiatorId; - + if (wf && !isDealerProposalApproval && !isDealerCompletionApproval && !isApproverInitiator) { await notificationService.sendToUsers([(wf as any).initiatorId], { title: `Request Approved - Level ${level.levelNumber}`, @@ -315,23 +358,23 @@ export class DealerClaimApprovalService { } else if (isApproverInitiator) { logger.info(`[DealerClaimApproval] Skipping approval notification to initiator - they are the approver`); } - + // Notify next approver - ALWAYS send notification when there's a next level if (wf && nextLevel) { const nextApproverId = (nextLevel as any).approverId; const nextApproverEmail = (nextLevel as any).approverEmail || ''; const nextApproverName = (nextLevel as any).approverName || nextApproverEmail || 'approver'; - + // Check if it's an auto-step or system process - const isAutoStep = nextApproverEmail === 'system@royalenfield.com' + const isAutoStep = nextApproverEmail === 'system@royalenfield.com' || (nextLevel as any).approverName === 'System Auto-Process' || nextApproverId === 'system'; - - const isSystemEmail = nextApproverEmail.toLowerCase() === 'system@royalenfield.com' + + const isSystemEmail = nextApproverEmail.toLowerCase() === 'system@royalenfield.com' || nextApproverEmail.toLowerCase().includes('system'); const isSystemName = nextApproverName.toLowerCase() === 'system auto-process' || nextApproverName.toLowerCase().includes('system'); - + // Notify initiator when dealer submits documents (Dealer Proposal or Dealer Completion Documents) // Check this BEFORE sending assignment notification to avoid duplicates // Priority: levelName check first, then levelNumber only if levelName is missing @@ -343,19 +386,19 @@ export class DealerClaimApprovalService { const isDealerCompletionApproval = hasLevelNameForNotification ? (levelNameForNotification.includes('dealer') && (levelNameForNotification.includes('completion') || levelNameForNotification.includes('documents'))) : (level.levelNumber === 5); // Only use levelNumber if levelName is missing - + // Check if next approver is the initiator (to avoid duplicate notifications) const isNextApproverInitiator = nextApproverId && (wf as any).initiatorId && nextApproverId === (wf as any).initiatorId; - + if (isDealerProposalApproval && (wf as any).initiatorId) { // Get dealer and proposal data for the email template const { DealerClaimDetails } = await import('@models/DealerClaimDetails'); const { DealerProposalDetails } = await import('@models/DealerProposalDetails'); const { DealerProposalCostItem } = await import('@models/DealerProposalCostItem'); - + const claimDetails = await DealerClaimDetails.findOne({ where: { requestId: level.requestId } }); const proposalDetails = await DealerProposalDetails.findOne({ where: { requestId: level.requestId } }); - + // Get cost items if proposal exists let costBreakup: any[] = []; if (proposalDetails) { @@ -371,7 +414,7 @@ export class DealerClaimApprovalService { })); } } - + // Get dealer user const dealerUser = level.approverId ? await User.findByPk(level.approverId) : null; const dealerData = dealerUser ? dealerUser.toJSON() : { @@ -379,15 +422,15 @@ export class DealerClaimApprovalService { email: level.approverEmail || '', displayName: level.approverName || level.approverEmail || 'Dealer' }; - + // Get next approver (could be Step 2 - Requestor Evaluation, or an additional approver if one was added between Step 1 and Step 2) // The nextLevel is already found above using dynamic logic that handles additional approvers correctly const nextApproverData = nextLevel ? await User.findByPk((nextLevel as any).approverId) : null; - + // Check if next approver is an additional approver (handles cases where additional approvers are added between Step 1 and Step 2) const nextLevelName = nextLevel ? ((nextLevel as any).levelName || '').toLowerCase() : ''; const isNextAdditionalApprover = nextLevelName.includes('additional approver'); - + // Send proposal submitted notification with proper type and metadata // This will use the dealerProposalSubmitted template, not the multi-level approval template await notificationService.sendToUsers([(wf as any).initiatorId], { @@ -416,17 +459,17 @@ export class DealerClaimApprovalService { activityType: claimDetails ? (claimDetails as any).activityType : undefined } }); - + logger.info(`[DealerClaimApproval] Sent proposal_submitted notification to initiator for Dealer Proposal Submission. Next approver: ${isNextApproverInitiator ? 'Initiator (self)' : (isNextAdditionalApprover ? 'Additional Approver' : 'Step 2 (Requestor Evaluation)')}`); } else if (isDealerCompletionApproval && (wf as any).initiatorId) { // Get dealer and completion data for the email template const { DealerClaimDetails } = await import('@models/DealerClaimDetails'); const { DealerCompletionDetails } = await import('@models/DealerCompletionDetails'); const { DealerCompletionExpense } = await import('@models/DealerCompletionExpense'); - + const claimDetails = await DealerClaimDetails.findOne({ where: { requestId: level.requestId } }); const completionDetails = await DealerCompletionDetails.findOne({ where: { requestId: level.requestId } }); - + // Get expense items if completion exists let closedExpenses: any[] = []; if (completionDetails) { @@ -439,7 +482,7 @@ export class DealerClaimApprovalService { amount: Number(item.amount) || 0 })); } - + // Get dealer user const dealerUser = level.approverId ? await User.findByPk(level.approverId) : null; const dealerData = dealerUser ? dealerUser.toJSON() : { @@ -447,17 +490,17 @@ export class DealerClaimApprovalService { email: level.approverEmail || '', displayName: level.approverName || level.approverEmail || 'Dealer' }; - + // Get next approver (could be Step 5 - Requestor Claim Approval, or an additional approver if one was added between Step 4 and Step 5) const nextApproverData = nextLevel ? await User.findByPk((nextLevel as any).approverId) : null; - + // Check if next approver is an additional approver (handles cases where additional approvers are added between Step 4 and Step 5) const nextLevelName = nextLevel ? ((nextLevel as any).levelName || '').toLowerCase() : ''; const isNextAdditionalApprover = nextLevelName.includes('additional approver'); - + // Check if next approver is the initiator (to show appropriate message in email) const isNextApproverInitiator = nextApproverData && (wf as any).initiatorId && nextApproverData.userId === (wf as any).initiatorId; - + // Send completion submitted notification with proper type and metadata // This will use the completionDocumentsSubmitted template, not the multi-level approval template await notificationService.sendToUsers([(wf as any).initiatorId], { @@ -484,10 +527,10 @@ export class DealerClaimApprovalService { nextApproverId: nextApproverData ? nextApproverData.userId : undefined } }); - + logger.info(`[DealerClaimApproval] Sent completion_submitted notification to initiator for Dealer Completion Documents. Next approver: ${isNextAdditionalApprover ? 'Additional Approver' : 'Step 5 (Requestor Claim Approval)'}`); } - + // Only send assignment notification to next approver if: // 1. It's NOT a dealer proposal/completion step (those have special notifications above) // 2. Next approver is NOT the initiator (to avoid duplicate notifications) @@ -496,8 +539,8 @@ export class DealerClaimApprovalService { if (!isAutoStep && !isSystemEmail && !isSystemName && nextApproverId && nextApproverId !== 'system') { try { logger.info(`[DealerClaimApproval] Sending assignment notification to next approver: ${nextApproverName} (${nextApproverId}) at level ${nextLevelNumber} for request ${(wf as any).requestNumber}`); - - await notificationService.sendToUsers([ nextApproverId ], { + + await notificationService.sendToUsers([nextApproverId], { title: `Action required: ${(wf as any).requestNumber}`, body: `${(wf as any).title}`, requestNumber: (wf as any).requestNumber, @@ -541,15 +584,15 @@ export class DealerClaimApprovalService { // No next level found but not final approver - this shouldn't happen logger.warn(`[DealerClaimApproval] No next level found for workflow ${level.requestId} after approving level ${level.levelNumber}`); await WorkflowRequest.update( - { - status: WorkflowStatus.APPROVED, + { + status: WorkflowStatus.APPROVED, closureDate: now, currentLevel: level.levelNumber || 0 }, { where: { requestId: level.requestId } } ); if (wf) { - await notificationService.sendToUsers([ (wf as any).initiatorId ], { + await notificationService.sendToUsers([(wf as any).initiatorId], { title: `Approved: ${(wf as any).requestNumber}`, body: `${(wf as any).title}`, requestNumber: (wf as any).requestNumber, @@ -570,9 +613,9 @@ export class DealerClaimApprovalService { levelNumber: level.levelNumber, timestamp: now.toISOString() }); - + logger.info(`[DealerClaimApproval] Approval level ${levelId} ${action.action.toLowerCase()}ed and socket event emitted`); - + return level; } catch (error) { logger.error('[DealerClaimApproval] Error approving level:', error); @@ -596,6 +639,125 @@ export class DealerClaimApprovalService { const wf = await WorkflowRequest.findByPk(level.requestId); if (!wf) return null; + // Check if this is the Department Lead approval step (Step 3) + // Robust check: check level name for variations and level number as fallback + const levelName = (level.levelName || '').toLowerCase(); + const isDeptLeadResult = + levelName.includes('department lead') || + levelName.includes('dept lead'); + + if (isDeptLeadResult) { + logger.info(`[DealerClaimApproval] Department Lead rejected request ${level.requestId}. Circling back to initiator.`); + + // Save approval history (rejection) BEFORE updating level + await this.getDealerClaimService().saveApprovalHistory( + level.requestId, + level.levelId, + level.levelNumber, + 'REJECT', + action.comments || '', + action.rejectionReason || undefined, + userId + ); + + // Update level status to REJECTED (but signifies a return at this level) + await level.update({ + status: ApprovalStatus.REJECTED, + actionDate: rejectionNow, + levelEndTime: rejectionNow, + elapsedHours: elapsedHours || 0, + tatPercentageUsed: tatPercentage || 0, + comments: action.comments || action.rejectionReason || undefined + }); + + // Create or activate initiator action level + const initiatorLevel = await this.getDealerClaimService().createOrActivateInitiatorLevel( + level.requestId, + (wf as any).initiatorId + ); + + // Update workflow status to REJECTED but DO NOT set closureDate + // Set currentLevel to initiator level if created + const newCurrentLevel = initiatorLevel ? initiatorLevel.levelNumber : wf.currentLevel; + await WorkflowRequest.update( + { + status: WorkflowStatus.REJECTED, + currentLevel: newCurrentLevel + }, + { where: { requestId: level.requestId } } + ); + + // Capture workflow snapshot when moving back to initiator + // Include the rejected level information in the message + await this.getDealerClaimService().saveWorkflowHistory( + level.requestId, + `Department Lead rejected (level ${level.levelNumber}) and moved back to initiator (level ${newCurrentLevel})`, + userId, + level.levelId, // Store the rejected level's ID + level.levelNumber, // Store the rejected level's number + level.levelName || undefined // Store the rejected level's name + ); + + // Log activity + activityService.log({ + requestId: level.requestId, + type: 'rejection', + user: { userId: level.approverId, name: level.approverName }, + timestamp: rejectionNow.toISOString(), + action: 'Returned to Initiator', + details: `Request returned to initiator by ${level.approverName || level.approverEmail}. Reason: ${action.rejectionReason || action.comments || 'No reason provided'}`, + ipAddress: requestMetadata?.ipAddress || undefined, + userAgent: requestMetadata?.userAgent || undefined + }); + + // Notify ONLY the initiator + await notificationService.sendToUsers([(wf as any).initiatorId], { + title: `Action Required: Request Returned - ${(wf as any).requestNumber}`, + body: `Your request "${(wf as any).title}" has been returned to you by the Department Lead for revision/discussion. Reason: ${action.rejectionReason || action.comments || 'No reason provided'}`, + requestNumber: (wf as any).requestNumber, + requestId: level.requestId, + url: `/request/${(wf as any).requestNumber}`, + type: 'rejection', + priority: 'HIGH', + actionRequired: true + }); + + // Emit real-time update + emitToRequestRoom(level.requestId, 'request:updated', { + requestId: level.requestId, + requestNumber: (wf as any)?.requestNumber, + action: 'RETURN', + levelNumber: level.levelNumber, + timestamp: rejectionNow.toISOString() + }); + + return level; + } + + // Default terminal rejection logic for other steps + logger.info(`[DealerClaimApproval] Standard rejection for request ${level.requestId} by level ${level.levelNumber}`); + + // Save approval history (rejection) BEFORE updating level + await this.getDealerClaimService().saveApprovalHistory( + level.requestId, + level.levelId, + level.levelNumber, + 'REJECT', + action.comments || '', + action.rejectionReason || undefined, + userId + ); + + // Capture workflow snapshot for terminal rejection action + await this.getDealerClaimService().saveWorkflowHistory( + level.requestId, + `Level ${level.levelNumber} rejected (terminal rejection)`, + userId, + level.levelId, + level.levelNumber, + level.levelName || undefined + ); + // Update level status await level.update({ status: ApprovalStatus.REJECTED, @@ -608,8 +770,8 @@ export class DealerClaimApprovalService { // Close workflow await WorkflowRequest.update( - { - status: WorkflowStatus.REJECTED, + { + status: WorkflowStatus.REJECTED, closureDate: rejectionNow }, { where: { requestId: level.requestId } } @@ -688,15 +850,15 @@ export class DealerClaimApprovalService { } const now = new Date(); - + // Calculate elapsed hours const priority = ((wf as any)?.priority || 'standard').toString().toLowerCase(); const isPausedLevel = (level as any).isPaused; - const wasResumed = !isPausedLevel && - (level as any).pauseElapsedHours !== null && + const wasResumed = !isPausedLevel && + (level as any).pauseElapsedHours !== null && (level as any).pauseElapsedHours !== undefined && (level as any).pauseResumeDate !== null; - + const pauseInfo = isPausedLevel ? { // Level is currently paused - return frozen elapsed hours at pause time isPaused: true, diff --git a/src/services/gcsStorage.service.ts b/src/services/gcsStorage.service.ts index 35f5932..ab2734c 100644 --- a/src/services/gcsStorage.service.ts +++ b/src/services/gcsStorage.service.ts @@ -21,16 +21,24 @@ interface UploadResult { class GCSStorageService { private storage: Storage | null = null; - private bucketName: string; - private projectId: string; + private bucketName: string = ''; + private projectId: string = ''; constructor() { + // Check if Google Secret Manager should be used + const useGoogleSecretManager = process.env.USE_GOOGLE_SECRET_MANAGER === 'true'; + + if (!useGoogleSecretManager) { + logger.info('[GCS] USE_GOOGLE_SECRET_MANAGER is not enabled. Will use local storage fallback.'); + return; + } + this.projectId = process.env.GCP_PROJECT_ID || ''; this.bucketName = process.env.GCP_BUCKET_NAME || ''; const keyFilePath = process.env.GCP_KEY_FILE || ''; if (!this.projectId || !this.bucketName || !keyFilePath) { - logger.warn('[GCS] GCP configuration missing. File uploads will fail.'); + logger.warn('[GCS] GCP configuration missing. File uploads will use local storage fallback.'); return; } @@ -41,7 +49,7 @@ class GCSStorageService { : path.resolve(process.cwd(), keyFilePath); if (!fs.existsSync(resolvedKeyPath)) { - logger.error(`[GCS] Key file not found at: ${resolvedKeyPath}`); + logger.error(`[GCS] Key file not found at: ${resolvedKeyPath}. Will use local storage fallback.`); return; } @@ -55,7 +63,7 @@ class GCSStorageService { bucketName: this.bucketName, }); } catch (error) { - logger.error('[GCS] Failed to initialize:', error); + logger.error('[GCS] Failed to initialize. Will use local storage fallback:', error); } } @@ -341,6 +349,12 @@ class GCSStorageService { * Check if GCS is properly configured */ isConfigured(): boolean { + // Check if Google Secret Manager is enabled + const useGoogleSecretManager = process.env.USE_GOOGLE_SECRET_MANAGER === 'true'; + if (!useGoogleSecretManager) { + return false; + } + return this.storage !== null && this.bucketName !== '' && this.projectId !== ''; } }