dealer claim multi iteration implementaion started
This commit is contained in:
parent
f89514eb2b
commit
e3bda6df15
@ -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');
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
136
src/migrations/20260113-redesign-dealer-claim-history.ts
Normal file
136
src/migrations/20260113-redesign-dealer-claim-history.ts
Normal 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');
|
||||
};
|
||||
190
src/models/DealerClaimHistory.ts
Normal file
190
src/models/DealerClaimHistory.ts
Normal 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 };
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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
@ -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,
|
||||
|
||||
@ -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 !== '';
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user