dealer claim multi iteration implementaion started

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

View File

@ -250,6 +250,7 @@ export class DealerClaimController {
numberOfParticipants,
closedExpenses,
totalClosedExpenses,
completionDescription,
} = req.body;
// Parse closedExpenses if it's a JSON string
@ -540,6 +541,7 @@ export class DealerClaimController {
totalClosedExpenses: totalClosedExpenses ? parseFloat(totalClosedExpenses) : 0,
invoicesReceipts: invoicesReceipts.length > 0 ? invoicesReceipts : undefined,
attendanceSheet: attendanceSheet || undefined,
completionDescription: completionDescription || undefined,
});
return ResponseHandler.success(res, { message: 'Completion documents submitted successfully' }, 'Completion submitted');

View File

@ -13,9 +13,11 @@ 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> {
@ -886,4 +888,54 @@ export class WorkflowController {
ResponseHandler.error(res, 'Failed to submit workflow', 400, errorMessage);
}
}
async handleInitiatorAction(req: Request, res: Response): Promise<void> {
try {
const { id } = req.params;
const { action, ...data } = req.body;
const userId = req.user?.userId;
if (!userId) {
ResponseHandler.unauthorized(res, 'User ID missing from request');
return;
}
await dealerClaimService.handleInitiatorAction(id, userId, action as any, data);
ResponseHandler.success(res, null, `Action ${action} performed successfully`);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
logger.error('[WorkflowController] handleInitiatorAction failed', {
error: errorMessage,
requestId: req.params.id,
userId: req.user?.userId,
action: req.body.action
});
ResponseHandler.error(res, 'Failed to perform initiator action', 400, errorMessage);
}
}
async getHistory(req: Request, res: Response): Promise<void> {
try {
const { id } = req.params;
// Resolve requestId UUID from identifier (could be requestNumber or UUID)
const workflowService = new WorkflowService();
const wf = await (workflowService as any).findWorkflowByIdentifier(id);
if (!wf) {
ResponseHandler.notFound(res, 'Workflow not found');
return;
}
const requestId = wf.getDataValue('requestId');
const history = await dealerClaimService.getHistory(requestId);
ResponseHandler.success(res, history, 'Revision history fetched successfully');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
logger.error('[WorkflowController] getHistory failed', {
error: errorMessage,
requestId: req.params.id
});
ResponseHandler.error(res, 'Failed to fetch revision history', 400, errorMessage);
}
}
}

View File

@ -0,0 +1,136 @@
import { QueryInterface, DataTypes } from 'sequelize';
export const up = async (queryInterface: QueryInterface) => {
// 1. Drop the old dealer_claim_history table if it exists
const tables = await queryInterface.showAllTables();
if (tables.includes('dealer_claim_history')) {
await queryInterface.dropTable('dealer_claim_history');
}
// 2. Create or update the enum type for snapshot_type
// Check if enum exists, if not create it, if yes update it
try {
await queryInterface.sequelize.query(`
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'enum_dealer_claim_history_snapshot_type') THEN
CREATE TYPE enum_dealer_claim_history_snapshot_type AS ENUM ('PROPOSAL', 'COMPLETION', 'INTERNAL_ORDER', 'WORKFLOW', 'APPROVE');
ELSE
-- Check if APPROVE exists in the enum
IF NOT EXISTS (
SELECT 1 FROM pg_enum
WHERE enumlabel = 'APPROVE'
AND enumtypid = (SELECT oid FROM pg_type WHERE typname = 'enum_dealer_claim_history_snapshot_type')
) THEN
ALTER TYPE enum_dealer_claim_history_snapshot_type ADD VALUE 'APPROVE';
END IF;
END IF;
END $$;
`);
} catch (error) {
// If enum creation fails, try to continue (might already exist)
console.warn('Enum creation/update warning:', error);
}
// 3. Create new simplified level-based dealer_claim_history table
await queryInterface.createTable('dealer_claim_history', {
history_id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true
},
request_id: {
type: DataTypes.UUID,
allowNull: false,
references: {
model: 'workflow_requests',
key: 'request_id'
},
onUpdate: 'CASCADE',
onDelete: 'CASCADE'
},
approval_level_id: {
type: DataTypes.UUID,
allowNull: true, // Nullable for workflow-level snapshots
references: {
model: 'approval_levels',
key: 'level_id'
},
onUpdate: 'CASCADE',
onDelete: 'SET NULL'
},
level_number: {
type: DataTypes.INTEGER,
allowNull: true, // Nullable for workflow-level snapshots
comment: 'Level number for easier querying (e.g., 1=Dealer, 3=Dept Lead, 4/5=Completion)'
},
level_name: {
type: DataTypes.STRING(255),
allowNull: true, // Nullable for workflow-level snapshots
comment: 'Level name for consistent matching (e.g., "Dealer Proposal Submission", "Department Lead Approval")'
},
version: {
type: DataTypes.INTEGER,
allowNull: false,
comment: 'Version number for this specific level (starts at 1 per level)'
},
snapshot_type: {
type: DataTypes.ENUM('PROPOSAL', 'COMPLETION', 'INTERNAL_ORDER', 'WORKFLOW', 'APPROVE'),
allowNull: false,
comment: 'Type of snapshot: PROPOSAL (Step 1), COMPLETION (Step 4/5), INTERNAL_ORDER (Step 3), WORKFLOW (general), APPROVE (approver actions with comments)'
},
snapshot_data: {
type: DataTypes.JSONB,
allowNull: false,
comment: 'JSON object containing all snapshot data specific to this level and type. Structure varies by snapshot_type.'
},
change_reason: {
type: DataTypes.TEXT,
allowNull: true,
comment: 'Reason for this version change (e.g., "Revision Requested: ...")'
},
changed_by: {
type: DataTypes.UUID,
allowNull: false,
references: {
model: 'users',
key: 'user_id'
}
},
created_at: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW
}
});
// Add indexes for efficient querying
await queryInterface.addIndex('dealer_claim_history', ['request_id', 'level_number', 'version'], {
name: 'idx_history_request_level_version'
});
await queryInterface.addIndex('dealer_claim_history', ['approval_level_id', 'version'], {
name: 'idx_history_level_version'
});
await queryInterface.addIndex('dealer_claim_history', ['request_id', 'snapshot_type'], {
name: 'idx_history_request_type'
});
await queryInterface.addIndex('dealer_claim_history', ['snapshot_type', 'level_number'], {
name: 'idx_history_type_level'
});
await queryInterface.addIndex('dealer_claim_history', ['request_id', 'level_name'], {
name: 'idx_history_request_level_name'
});
await queryInterface.addIndex('dealer_claim_history', ['level_name', 'snapshot_type'], {
name: 'idx_history_level_name_type'
});
// Index for JSONB queries on snapshot_data
await queryInterface.addIndex('dealer_claim_history', ['snapshot_type'], {
name: 'idx_history_snapshot_type',
using: 'BTREE'
});
};
export const down = async (queryInterface: QueryInterface) => {
// Drop the new table
await queryInterface.dropTable('dealer_claim_history');
};

View File

@ -0,0 +1,190 @@
import { DataTypes, Model, Optional } from 'sequelize';
import { sequelize } from '@config/database';
import { WorkflowRequest } from './WorkflowRequest';
import { ApprovalLevel } from './ApprovalLevel';
import { User } from './User';
export enum SnapshotType {
PROPOSAL = 'PROPOSAL',
COMPLETION = 'COMPLETION',
INTERNAL_ORDER = 'INTERNAL_ORDER',
WORKFLOW = 'WORKFLOW',
APPROVE = 'APPROVE'
}
// Type definitions for snapshot data structures
export interface ProposalSnapshotData {
documentUrl?: string;
totalBudget?: number;
comments?: string;
expectedCompletionDate?: string;
costItems?: Array<{
description: string;
amount: number;
order: number;
}>;
}
export interface CompletionSnapshotData {
documentUrl?: string;
totalExpenses?: number;
comments?: string;
expenses?: Array<{
description: string;
amount: number;
}>;
}
export interface IOSnapshotData {
ioNumber?: string;
blockedAmount?: number;
availableBalance?: number;
remainingBalance?: number;
sapDocumentNumber?: string;
}
export interface WorkflowSnapshotData {
status?: string;
currentLevel?: number;
}
export interface ApprovalSnapshotData {
action: 'APPROVE' | 'REJECT';
comments?: string;
rejectionReason?: string;
approverName?: string;
approverEmail?: string;
levelName?: string;
}
interface DealerClaimHistoryAttributes {
historyId: string;
requestId: string;
approvalLevelId?: string;
levelNumber?: number;
levelName?: string;
version: number;
snapshotType: SnapshotType;
snapshotData: ProposalSnapshotData | CompletionSnapshotData | IOSnapshotData | WorkflowSnapshotData | ApprovalSnapshotData | any;
changeReason?: string;
changedBy: string;
createdAt: Date;
}
interface DealerClaimHistoryCreationAttributes extends Optional<DealerClaimHistoryAttributes, 'historyId' | 'approvalLevelId' | 'levelNumber' | 'levelName' | 'changeReason' | 'createdAt'> { }
class DealerClaimHistory extends Model<DealerClaimHistoryAttributes, DealerClaimHistoryCreationAttributes> implements DealerClaimHistoryAttributes {
public historyId!: string;
public requestId!: string;
public approvalLevelId?: string;
public levelNumber?: number;
public version!: number;
public snapshotType!: SnapshotType;
public snapshotData!: ProposalSnapshotData | CompletionSnapshotData | IOSnapshotData | WorkflowSnapshotData | any;
public changeReason?: string;
public changedBy!: string;
public createdAt!: Date;
}
DealerClaimHistory.init(
{
historyId: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
field: 'history_id'
},
requestId: {
type: DataTypes.UUID,
allowNull: false,
field: 'request_id',
references: {
model: 'workflow_requests',
key: 'request_id'
}
},
approvalLevelId: {
type: DataTypes.UUID,
allowNull: true,
field: 'approval_level_id',
references: {
model: 'approval_levels',
key: 'level_id'
}
},
levelNumber: {
type: DataTypes.INTEGER,
allowNull: true,
field: 'level_number'
},
levelName: {
type: DataTypes.STRING(255),
allowNull: true,
field: 'level_name'
},
version: {
type: DataTypes.INTEGER,
allowNull: false
},
snapshotType: {
type: DataTypes.ENUM('PROPOSAL', 'COMPLETION', 'INTERNAL_ORDER', 'WORKFLOW', 'APPROVE'),
allowNull: false,
field: 'snapshot_type'
},
snapshotData: {
type: DataTypes.JSONB,
allowNull: false,
field: 'snapshot_data'
},
changeReason: {
type: DataTypes.TEXT,
allowNull: true,
field: 'change_reason'
},
changedBy: {
type: DataTypes.UUID,
allowNull: false,
field: 'changed_by',
references: {
model: 'users',
key: 'user_id'
}
},
createdAt: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW,
field: 'created_at'
}
},
{
sequelize,
modelName: 'DealerClaimHistory',
tableName: 'dealer_claim_history',
timestamps: false,
indexes: [
{
fields: ['request_id', 'level_number', 'version'],
name: 'idx_history_request_level_version'
},
{
fields: ['approval_level_id', 'version'],
name: 'idx_history_level_version'
},
{
fields: ['request_id', 'snapshot_type'],
name: 'idx_history_request_type'
},
{
fields: ['snapshot_type', 'level_number'],
name: 'idx_history_type_level'
}
]
}
);
DealerClaimHistory.belongsTo(WorkflowRequest, { foreignKey: 'requestId' });
DealerClaimHistory.belongsTo(ApprovalLevel, { foreignKey: 'approvalLevelId' });
DealerClaimHistory.belongsTo(User, { as: 'changer', foreignKey: 'changedBy' });
export { DealerClaimHistory };

View File

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

View File

@ -25,6 +25,7 @@ import { InternalOrder } from './InternalOrder';
import { ClaimBudgetTracking } from './ClaimBudgetTracking';
import { Dealer } from './Dealer';
import { ActivityType } from './ActivityType';
import { DealerClaimHistory } from './DealerClaimHistory';
// Define associations
const defineAssociations = () => {
@ -137,6 +138,13 @@ const defineAssociations = () => {
sourceKey: 'requestId'
});
// DealerClaimHistory associations
WorkflowRequest.hasMany(DealerClaimHistory, {
as: 'history',
foreignKey: 'requestId',
sourceKey: 'requestId'
});
// Note: belongsTo associations are defined in individual model files to avoid duplicate alias conflicts
// Only hasMany associations from WorkflowRequest are defined here since they're one-way
};
@ -170,7 +178,8 @@ export {
InternalOrder,
ClaimBudgetTracking,
Dealer,
ActivityType
ActivityType,
DealerClaimHistory
};
// Export default sequelize instance

View File

@ -874,4 +874,19 @@ router.get('/:id/pause',
asyncHandler(pauseController.getPauseDetails.bind(pauseController))
);
// Initiator actions for rejected/returned requests
router.post('/:id/initiator-action',
authenticateToken,
requireParticipantTypes(['INITIATOR']),
validateParams(workflowParamsSchema),
asyncHandler(workflowController.handleInitiatorAction.bind(workflowController))
);
// Get revision history
router.get('/:id/history',
authenticateToken,
validateParams(workflowParamsSchema),
asyncHandler(workflowController.getHistory.bind(workflowController))
);
export default router;

View File

@ -135,6 +135,7 @@ async function runMigrations(): Promise<void> {
const m40 = require('../migrations/20251218-fix-claim-invoice-credit-note-columns');
const m41 = require('../migrations/20250120-create-dealers-table');
const m42 = require('../migrations/20250125-create-activity-types');
const m43 = require('../migrations/20260113-redesign-dealer-claim-history');
const migrations = [
{ name: '2025103000-create-users', module: m0 },
@ -182,6 +183,7 @@ async function runMigrations(): Promise<void> {
{ name: '20251218-fix-claim-invoice-credit-note-columns', module: m40 },
{ name: '20250120-create-dealers-table', module: m41 },
{ name: '20250125-create-activity-types', module: m42 },
{ name: '20260113-redesign-dealer-claim-history', module: m43 },
];
const queryInterface = sequelize.getQueryInterface();

View File

@ -45,6 +45,7 @@ import * as m39 from '../migrations/20251214-create-dealer-completion-expenses';
import * as m40 from '../migrations/20251218-fix-claim-invoice-credit-note-columns';
import * as m41 from '../migrations/20250120-create-dealers-table';
import * as m42 from '../migrations/20250125-create-activity-types';
import * as m43 from '../migrations/20260113-redesign-dealer-claim-history';
interface Migration {
name: string;
@ -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 },
];
/**

File diff suppressed because it is too large Load Diff

View File

@ -26,6 +26,10 @@ 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
@ -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,
@ -242,6 +274,17 @@ export class DealerClaimApprovalService {
{ 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}`);
}
@ -497,7 +540,7 @@ export class DealerClaimApprovalService {
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,
@ -549,7 +592,7 @@ export class DealerClaimApprovalService {
{ 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,
@ -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,

View File

@ -21,16 +21,24 @@ interface UploadResult {
class GCSStorageService {
private storage: Storage | null = null;
private bucketName: string;
private projectId: string;
private bucketName: string = '';
private projectId: string = '';
constructor() {
// Check if Google Secret Manager should be used
const useGoogleSecretManager = process.env.USE_GOOGLE_SECRET_MANAGER === 'true';
if (!useGoogleSecretManager) {
logger.info('[GCS] USE_GOOGLE_SECRET_MANAGER is not enabled. Will use local storage fallback.');
return;
}
this.projectId = process.env.GCP_PROJECT_ID || '';
this.bucketName = process.env.GCP_BUCKET_NAME || '';
const keyFilePath = process.env.GCP_KEY_FILE || '';
if (!this.projectId || !this.bucketName || !keyFilePath) {
logger.warn('[GCS] GCP configuration missing. File uploads will fail.');
logger.warn('[GCS] GCP configuration missing. File uploads will use local storage fallback.');
return;
}
@ -41,7 +49,7 @@ class GCSStorageService {
: path.resolve(process.cwd(), keyFilePath);
if (!fs.existsSync(resolvedKeyPath)) {
logger.error(`[GCS] Key file not found at: ${resolvedKeyPath}`);
logger.error(`[GCS] Key file not found at: ${resolvedKeyPath}. Will use local storage fallback.`);
return;
}
@ -55,7 +63,7 @@ class GCSStorageService {
bucketName: this.bucketName,
});
} catch (error) {
logger.error('[GCS] Failed to initialize:', error);
logger.error('[GCS] Failed to initialize. Will use local storage fallback:', error);
}
}
@ -341,6 +349,12 @@ class GCSStorageService {
* Check if GCS is properly configured
*/
isConfigured(): boolean {
// Check if Google Secret Manager is enabled
const useGoogleSecretManager = process.env.USE_GOOGLE_SECRET_MANAGER === 'true';
if (!useGoogleSecretManager) {
return false;
}
return this.storage !== null && this.bucketName !== '' && this.projectId !== '';
}
}