dealer claim multi iteration implementaion started

This commit is contained in:
laxmanhalaki 2026-01-13 19:18:39 +05:30
parent f89514eb2b
commit e3bda6df15
12 changed files with 2068 additions and 387 deletions

View File

@ -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');

View File

@ -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<void> {
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<void> {
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<void> {
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);
}
}
}

View File

@ -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');
};

View File

@ -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<DealerClaimHistoryAttributes, 'historyId' | 'approvalLevelId' | 'levelNumber' | 'levelName' | 'changeReason' | 'createdAt'> { }
class DealerClaimHistory extends Model<DealerClaimHistoryAttributes, DealerClaimHistoryCreationAttributes> 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 };

View File

@ -29,11 +29,12 @@ interface WorkflowRequestAttributes {
pauseReason?: string;
pauseResumeDate?: Date;
pauseTatSnapshot?: any;
version: number;
createdAt: Date;
updatedAt: Date;
}
interface WorkflowRequestCreationAttributes extends Optional<WorkflowRequestAttributes, 'requestId' | 'submissionDate' | 'closureDate' | 'conclusionRemark' | 'aiGeneratedConclusion' | 'isPaused' | 'pausedAt' | 'pausedBy' | 'pauseReason' | 'pauseResumeDate' | 'pauseTatSnapshot' | 'createdAt' | 'updatedAt'> {}
interface WorkflowRequestCreationAttributes extends Optional<WorkflowRequestAttributes, 'requestId' | 'submissionDate' | 'closureDate' | 'conclusionRemark' | 'aiGeneratedConclusion' | 'isPaused' | 'pausedAt' | 'pausedBy' | 'pauseReason' | 'pauseResumeDate' | 'pauseTatSnapshot' | 'version' | 'createdAt' | 'updatedAt'> { }
class WorkflowRequest extends Model<WorkflowRequestAttributes, WorkflowRequestCreationAttributes> implements WorkflowRequestAttributes {
public requestId!: string;
@ -61,6 +62,7 @@ class WorkflowRequest extends Model<WorkflowRequestAttributes, WorkflowRequestCr
public pauseReason?: string;
public pauseResumeDate?: Date;
public pauseTatSnapshot?: any;
public version!: number;
public createdAt!: Date;
public updatedAt!: Date;
@ -211,6 +213,11 @@ WorkflowRequest.init(
allowNull: true,
field: 'pause_tat_snapshot'
},
version: {
type: DataTypes.INTEGER,
defaultValue: 1,
allowNull: false
},
createdAt: {
type: DataTypes.DATE,
allowNull: false,

View File

@ -25,6 +25,7 @@ import { InternalOrder } from './InternalOrder';
import { ClaimBudgetTracking } from './ClaimBudgetTracking';
import { Dealer } from './Dealer';
import { ActivityType } from './ActivityType';
import { DealerClaimHistory } from './DealerClaimHistory';
// Define associations
const defineAssociations = () => {
@ -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

View File

@ -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;

View File

@ -135,6 +135,7 @@ async function runMigrations(): Promise<void> {
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<void> {
{ 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();

View File

@ -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<void> {
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<void> {
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) {

File diff suppressed because it is too large Load Diff

View File

@ -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<ApprovalLevel | null> {
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,

View File

@ -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 !== '';
}
}