954 lines
44 KiB
TypeScript
954 lines
44 KiB
TypeScript
import { Response, NextFunction } from 'express';
|
|
import { Op } from 'sequelize';
|
|
import db from '../../database/models/index.js';
|
|
import logger from '../../common/utils/logger.js';
|
|
import {
|
|
TERMINATION_STAGES,
|
|
AUDIT_ACTIONS,
|
|
ROLES,
|
|
TERMINATION_DOCUMENT_TYPES,
|
|
TERMINATION_DOCUMENT_STAGES
|
|
} from '../../common/config/constants.js';
|
|
import { Transaction } from 'sequelize';
|
|
import { AuthRequest } from '../../types/express.types.js';
|
|
import ExternalMocksService from '../../common/utils/externalMocks.service.js';
|
|
|
|
import { TerminationWorkflowService } from '../../services/TerminationWorkflowService.js';
|
|
import { NomenclatureService } from '../../common/utils/nomenclature.js';
|
|
import { ParticipantService } from '../../services/ParticipantService.js';
|
|
import { getTerminationStatusForStage, normalizeClearanceStatus, getLegacyTerminationRowFixes } from '../../common/utils/offboardingStatus.js';
|
|
import {
|
|
buildJointRoundCreatedAtFilter,
|
|
getJointRoundCutoffMsFromTimeline
|
|
} from '../../common/utils/terminationJointReviewRound.util.js';
|
|
import { resolveEntityUuidByType } from '../../common/utils/requestResolver.js';
|
|
import { OFFBOARDING_ACTIONS, REQUEST_TYPES } from '../../common/config/constants.js';
|
|
import { validateOffboardingAction, getPreviousStage } from '../../common/utils/offboardingWorkflow.utils.js';
|
|
import { NotificationService } from '../../services/NotificationService.js';
|
|
import { sendEmail } from '../../common/utils/email.service.js';
|
|
|
|
const resolveTerminationUuid = async (id: string) => {
|
|
const { resolvedId } = await resolveEntityUuidByType(db as any, id, 'termination');
|
|
return resolvedId;
|
|
};
|
|
|
|
// Create termination request
|
|
export const createTermination = async (req: AuthRequest, res: Response, next: NextFunction) => {
|
|
const transaction: Transaction = await db.sequelize.transaction();
|
|
try {
|
|
if (!req.user) throw new Error('Unauthorized');
|
|
|
|
const allowedRoles = [ROLES.DD_LEAD, ROLES.ASM, ROLES.DD_ADMIN, ROLES.DD_AM, ROLES.SUPER_ADMIN];
|
|
if (!allowedRoles.includes(req.user.roleCode as any)) {
|
|
return res.status(403).json({
|
|
success: false,
|
|
message: 'Only DD Lead, ASM, DD Admin, or DD AM are authorized to initiate termination requests.'
|
|
});
|
|
}
|
|
|
|
const { dealerId, category, reason, proposedLwd, comments } = req.body;
|
|
|
|
const requestId = await NomenclatureService.generateTerminationId();
|
|
const isUnethical = String(category).trim().toLowerCase().includes('unethical');
|
|
const startStage = isUnethical ? TERMINATION_STAGES.DD_LEAD_REVIEW : TERMINATION_STAGES.RBM_REVIEW;
|
|
|
|
const termination = await db.TerminationRequest.create({
|
|
requestId,
|
|
dealerId,
|
|
category,
|
|
reason,
|
|
proposedLwd,
|
|
comments,
|
|
initiatedBy: req.user.id,
|
|
currentStage: startStage,
|
|
status: getTerminationStatusForStage(startStage),
|
|
progressPercentage: TerminationWorkflowService.calculateProgress(startStage),
|
|
timeline: [{
|
|
stage: 'Submitted',
|
|
targetStage: startStage,
|
|
timestamp: new Date(),
|
|
user: req.user.fullName,
|
|
action: isUnethical ? 'Immediate escalation due to Unethical Practice' : `Termination request initiated and forwarded to ${startStage}`,
|
|
remarks: comments
|
|
}]
|
|
}, { transaction });
|
|
|
|
await db.TerminationAudit.create({
|
|
userId: req.user.id,
|
|
action: AUDIT_ACTIONS.CREATED,
|
|
terminationRequestId: termination.id,
|
|
remarks: 'Admin initiated termination request'
|
|
}, { transaction });
|
|
|
|
await transaction.commit();
|
|
|
|
// Add as chat participants (Async)
|
|
ParticipantService.assignTerminationParticipants(termination.id)
|
|
.catch(err => logger.error('Error assigning participants to termination:', err));
|
|
|
|
// SRS §4.3.2.1 — Notify appropriate stakeholders that a new termination has been initiated
|
|
const notifyOnCreateRoles = isUnethical ? [ROLES.DD_LEAD] : [ROLES.RBM, ROLES.DD_ZM];
|
|
for (const role of notifyOnCreateRoles) {
|
|
const roleUsers = await db.User.findAll({ where: { roleCode: role } });
|
|
for (const u of roleUsers) {
|
|
const phone = (u as any).mobileNumber || null;
|
|
NotificationService.notify(u.id, u.email, {
|
|
title: `New Termination Request: ${termination.requestId}`,
|
|
message: `A termination request has been initiated by ${req.user!.fullName || 'Admin'}. Your review is required.`,
|
|
channels: phone ? ['system', 'email', 'whatsapp'] : ['system', 'email'],
|
|
templateCode: 'TERMINATION_INITIATED',
|
|
placeholders: {
|
|
dealerName: '',
|
|
requestId: termination.requestId,
|
|
reason: reason || '',
|
|
link: `${process.env.FRONTEND_URL || 'http://localhost:5173'}/termination/${termination.id}`,
|
|
ctaLabel: 'Review Request',
|
|
phone: phone || ''
|
|
}
|
|
}).catch((e: any) => logger.error('[Termination] Create notify failed:', e));
|
|
}
|
|
}
|
|
|
|
res.status(201).json({ success: true, message: 'Termination request created', termination });
|
|
} catch (error) {
|
|
if (transaction) await transaction.rollback();
|
|
logger.error('Error creating termination:', error);
|
|
next(error);
|
|
}
|
|
};
|
|
|
|
// Get all termination requests
|
|
export const getTerminations = async (req: AuthRequest, res: Response, next: NextFunction) => {
|
|
try {
|
|
if (!req.user) throw new Error('Unauthorized');
|
|
const { dealerId } = req.query;
|
|
const where: any = {};
|
|
|
|
if (dealerId) where.dealerId = dealerId;
|
|
if (req.user.roleCode === ROLES.DEALER) {
|
|
const dealer = await db.Dealer.findOne({ where: { userId: req.user.id } });
|
|
if (dealer) where.dealerId = dealer.id;
|
|
} else {
|
|
const { status } = req.query;
|
|
if (status) {
|
|
if (status === 'open') {
|
|
where.status = { [Op.notIn]: ['Terminated', 'Completed', 'Closed', 'Rejected'] };
|
|
} else if (status === 'completed') {
|
|
where.status = { [Op.in]: ['Terminated', 'Completed', 'Closed'] };
|
|
} else {
|
|
where.status = status;
|
|
}
|
|
}
|
|
}
|
|
|
|
const page = parseInt(req.query.page as string) || 1;
|
|
const limit = parseInt(req.query.limit as string) || 10;
|
|
const offset = (page - 1) * limit;
|
|
|
|
const { count, rows: terminations } = await db.TerminationRequest.findAndCountAll({
|
|
where,
|
|
include: [
|
|
{
|
|
model: db.Dealer,
|
|
as: 'dealer',
|
|
include: [{ model: db.DealerCode, as: 'dealerCode' }]
|
|
}
|
|
],
|
|
order: [['createdAt', 'DESC']],
|
|
limit,
|
|
offset,
|
|
distinct: true
|
|
});
|
|
res.json({
|
|
success: true,
|
|
terminations,
|
|
meta: {
|
|
total: count,
|
|
totalPages: Math.ceil(count / limit),
|
|
currentPage: page,
|
|
limit,
|
|
stats: {
|
|
total: count,
|
|
open: await db.TerminationRequest.count({ where: { ...where, status: { [Op.notIn]: ['Terminated', 'Completed', 'Closed', 'Rejected'] } } }),
|
|
completed: await db.TerminationRequest.count({ where: { ...where, status: { [Op.in]: ['Terminated', 'Completed', 'Closed'] } } })
|
|
}
|
|
}
|
|
});
|
|
} catch (error) {
|
|
logger.error('Error fetching terminations:', error);
|
|
next(error);
|
|
}
|
|
};
|
|
|
|
// Get termination request by ID
|
|
export const getTerminationById = async (req: AuthRequest, res: Response, next: NextFunction) => {
|
|
try {
|
|
const { id } = req.params;
|
|
const resolvedId = await resolveTerminationUuid(String(id));
|
|
|
|
const termination = await db.TerminationRequest.findOne({
|
|
where: { id: resolvedId },
|
|
include: [
|
|
{
|
|
model: db.Dealer,
|
|
as: 'dealer',
|
|
include: [
|
|
{
|
|
model: db.Application,
|
|
as: 'application',
|
|
include: [
|
|
{ model: db.District, as: 'district' },
|
|
{
|
|
model: db.LoiRequest,
|
|
as: 'loiRequests',
|
|
where: { status: 'approved' },
|
|
required: false
|
|
},
|
|
{
|
|
model: db.LoaRequest,
|
|
as: 'loaRequests',
|
|
where: { status: 'approved' },
|
|
required: false
|
|
}
|
|
]
|
|
},
|
|
{ model: db.DealerCode, as: 'dealerCode' },
|
|
{ model: db.User, as: 'user', attributes: ['id', 'email', 'mobileNumber', 'status'] }
|
|
]
|
|
},
|
|
{ model: db.User, as: 'initiator', attributes: ['id', 'fullName', 'email'] },
|
|
{
|
|
model: db.TerminationDocument,
|
|
as: 'uploadedDocuments',
|
|
include: [{ model: db.User, as: 'uploader', attributes: ['fullName'] }]
|
|
},
|
|
{ model: db.FnF, as: 'fnfSettlement' },
|
|
{
|
|
model: db.RequestParticipant,
|
|
as: 'participants',
|
|
include: [{ model: db.User, as: 'user', attributes: ['id', 'fullName', 'email', 'roleCode'] }]
|
|
}
|
|
]
|
|
});
|
|
|
|
if (!termination) {
|
|
return res.status(404).json({ success: false, message: 'Termination request not found' });
|
|
}
|
|
|
|
const legacyTerminationFixes = getLegacyTerminationRowFixes(termination as any);
|
|
if (legacyTerminationFixes) {
|
|
await termination.update(legacyTerminationFixes);
|
|
(termination as any).setDataValue('currentStage', legacyTerminationFixes.currentStage ?? termination.currentStage);
|
|
(termination as any).setDataValue('status', legacyTerminationFixes.status ?? termination.status);
|
|
}
|
|
|
|
res.json({ success: true, termination });
|
|
} catch (error) {
|
|
logger.error('Error fetching termination:', error);
|
|
next(error);
|
|
}
|
|
};
|
|
|
|
export const uploadTerminationDocument = async (req: AuthRequest, res: Response, next: NextFunction) => {
|
|
const transaction: Transaction = await db.sequelize.transaction();
|
|
try {
|
|
if (!req.user) throw new Error('Unauthorized');
|
|
const { id } = req.params;
|
|
const { documentType = TERMINATION_DOCUMENT_TYPES[0], stage = null } = req.body;
|
|
|
|
if (!req.file) {
|
|
await transaction.rollback();
|
|
return res.status(400).json({ success: false, message: 'File is required' });
|
|
}
|
|
|
|
if (!TERMINATION_DOCUMENT_TYPES.includes(documentType)) {
|
|
await transaction.rollback();
|
|
return res.status(400).json({
|
|
success: false,
|
|
message: `Invalid document type. Allowed values: ${TERMINATION_DOCUMENT_TYPES.join(', ')}`
|
|
});
|
|
}
|
|
if (stage && !TERMINATION_DOCUMENT_STAGES.includes(stage)) {
|
|
await transaction.rollback();
|
|
return res.status(400).json({
|
|
success: false,
|
|
message: `Invalid stage. Allowed values: ${TERMINATION_DOCUMENT_STAGES.join(', ')}`
|
|
});
|
|
}
|
|
const resolvedId = await resolveTerminationUuid(String(id));
|
|
const termination = await db.TerminationRequest.findOne({
|
|
where: { id: resolvedId }
|
|
});
|
|
|
|
if (!termination) {
|
|
await transaction.rollback();
|
|
return res.status(404).json({ success: false, message: 'Termination request not found' });
|
|
}
|
|
|
|
const filePath = `/uploads/documents/${req.file.filename}`;
|
|
const document = await db.TerminationDocument.create({
|
|
terminationRequestId: termination.id,
|
|
documentType,
|
|
fileName: req.file.originalname,
|
|
filePath,
|
|
fileSize: req.file.size,
|
|
mimeType: req.file.mimetype,
|
|
stage,
|
|
uploadedBy: req.user.id
|
|
}, { transaction });
|
|
|
|
await db.TerminationAudit.create({
|
|
userId: req.user.id,
|
|
terminationRequestId: termination.id,
|
|
action: AUDIT_ACTIONS.DOCUMENT_UPLOADED,
|
|
remarks: `${documentType} uploaded`,
|
|
details: { fileName: req.file.originalname, stage, documentType }
|
|
}, { transaction });
|
|
|
|
const timeline = [...(termination.timeline || []), {
|
|
stage: stage || termination.currentStage,
|
|
timestamp: new Date(),
|
|
user: req.user.fullName,
|
|
action: `Document uploaded: ${documentType}`,
|
|
remarks: `Attachment: ${req.file.originalname}`
|
|
}];
|
|
await termination.update({ timeline }, { transaction });
|
|
|
|
const normalizedStage = String(stage || '').trim().toLowerCase();
|
|
const isScnStageUpload = normalizedStage === 'show cause notice' || normalizedStage === 'show cause notice (scn)' || normalizedStage === 'scn';
|
|
const isScnResponseDoc = String(documentType || '').trim().toLowerCase() === 'scn response';
|
|
if (
|
|
termination.currentStage === TERMINATION_STAGES.SCN_ISSUED &&
|
|
(isScnStageUpload || isScnResponseDoc)
|
|
) {
|
|
await TerminationWorkflowService.handleScnResponse(termination, {
|
|
responseBody: `SCN response uploaded: ${req.file.originalname}`,
|
|
documents: [{ fileName: req.file.originalname, filePath }]
|
|
}, req.user.id);
|
|
}
|
|
|
|
await transaction.commit();
|
|
res.status(201).json({ success: true, message: 'Document uploaded successfully', document });
|
|
} catch (error) {
|
|
await transaction.rollback();
|
|
logger.error('Error uploading termination document:', error);
|
|
next(error);
|
|
}
|
|
};
|
|
|
|
// Update termination status (Approve/Reject)
|
|
export const updateTerminationStatus = async (req: AuthRequest, res: Response, next: NextFunction) => {
|
|
const transaction: Transaction = await db.sequelize.transaction();
|
|
try {
|
|
if (!req.user) throw new Error('Unauthorized');
|
|
const { id } = req.params;
|
|
const { action, remarks } = req.body;
|
|
const resolvedId = await resolveTerminationUuid(String(id));
|
|
|
|
const termination = await db.TerminationRequest.findByPk(resolvedId);
|
|
if (!termination) {
|
|
await transaction.rollback();
|
|
return res.status(404).json({ success: false, message: 'Termination not found' });
|
|
}
|
|
|
|
const legacyTerminationFixes = getLegacyTerminationRowFixes(termination as any);
|
|
if (legacyTerminationFixes) {
|
|
await termination.update(legacyTerminationFixes, { transaction });
|
|
if (legacyTerminationFixes.currentStage) {
|
|
(termination as any).setDataValue('currentStage', legacyTerminationFixes.currentStage);
|
|
}
|
|
if (legacyTerminationFixes.status) {
|
|
(termination as any).setDataValue('status', legacyTerminationFixes.status);
|
|
}
|
|
}
|
|
|
|
const fromStage = termination.currentStage;
|
|
let approvedToStage: string | null = null;
|
|
|
|
if (action === OFFBOARDING_ACTIONS.REJECT) {
|
|
await TerminationWorkflowService.transitionTermination(termination, TERMINATION_STAGES.REJECTED, req.user.id, {
|
|
action: 'Rejected',
|
|
status: 'Rejected',
|
|
remarks
|
|
});
|
|
} else if (action === OFFBOARDING_ACTIONS.HOLD) {
|
|
// SRS §4.3.2.7 — Hold Decision (Pause temporarily); NBH may hold at evaluation or final approval
|
|
const holdStages = [TERMINATION_STAGES.NBH_EVALUATION, TERMINATION_STAGES.NBH_FINAL_APPROVAL];
|
|
if (!holdStages.includes(termination.currentStage as any) && req.user.roleCode !== ROLES.SUPER_ADMIN) {
|
|
await transaction.rollback();
|
|
return res.status(400).json({
|
|
success: false,
|
|
message: 'Hold action is only available at NBH Evaluation or NBH Final Approval stage.'
|
|
});
|
|
}
|
|
await termination.update({ status: 'On Hold' }, { transaction });
|
|
await db.TerminationAudit.create({
|
|
userId: req.user.id,
|
|
terminationRequestId: termination.id,
|
|
action: 'ON_HOLD',
|
|
remarks: remarks || 'Case placed on hold for further monitoring.',
|
|
details: { stage: fromStage }
|
|
}, { transaction });
|
|
|
|
await transaction.commit();
|
|
return res.json({ success: true, message: 'Termination case placed on hold.' });
|
|
} else if (action === OFFBOARDING_ACTIONS.REVOKE) {
|
|
// Validation: Remarks mandatory for Revoke
|
|
const validation = validateOffboardingAction(action, remarks);
|
|
if (!validation.valid) {
|
|
await transaction.rollback();
|
|
return res.status(400).json({ success: false, message: validation.message });
|
|
}
|
|
|
|
await TerminationWorkflowService.transitionTermination(termination, TERMINATION_STAGES.REJECTED, req.user.id, {
|
|
action: 'Revoked',
|
|
status: 'Revoked',
|
|
remarks
|
|
});
|
|
} else if (action === OFFBOARDING_ACTIONS.SEND_BACK || action === 'sendback') {
|
|
// Validation: Remarks mandatory for Send Back
|
|
const validation = validateOffboardingAction(OFFBOARDING_ACTIONS.SEND_BACK, remarks);
|
|
if (!validation.valid) {
|
|
await transaction.rollback();
|
|
return res.status(400).json({ success: false, message: validation.message });
|
|
}
|
|
|
|
const previousStage = getPreviousStage(REQUEST_TYPES.TERMINATION, termination.currentStage);
|
|
if (!previousStage) {
|
|
await transaction.rollback();
|
|
return res.status(400).json({ success: false, message: 'Cannot send back from current stage' });
|
|
}
|
|
|
|
await TerminationWorkflowService.transitionTermination(termination, previousStage, req.user.id, {
|
|
action: 'Sent Back',
|
|
remarks
|
|
});
|
|
} else if (action === 'pushfnf') {
|
|
if (termination.currentStage !== TERMINATION_STAGES.TERMINATED && termination.currentStage !== TERMINATION_STAGES.LEGAL_LETTER) {
|
|
await transaction.rollback();
|
|
return res.status(400).json({
|
|
success: false,
|
|
message: `Cannot trigger F&F from ${termination.currentStage}. Complete the CEO and Legal approvals first.`
|
|
});
|
|
}
|
|
|
|
logger.info(`[TerminationController] Forcibly initiating F&F (pushfnf) for Termination ${termination.requestId}`);
|
|
await TerminationWorkflowService.initiateFnF(termination, req.user.id, transaction);
|
|
|
|
// Maintain timeline visibility
|
|
const timeline = [...(termination.timeline || []), {
|
|
stage: termination.currentStage,
|
|
timestamp: new Date(),
|
|
user: req.user.fullName,
|
|
action: 'Forced F&F Initiation',
|
|
remarks: remarks || 'F&F settlement initiated manually via Push to F&F'
|
|
}];
|
|
await termination.update({
|
|
currentStage: TERMINATION_STAGES.TERMINATED, // Lock out further approvals
|
|
status: 'F&F Initiated',
|
|
timeline
|
|
}, { transaction });
|
|
} else {
|
|
const stageFlow: Record<string, string> = {
|
|
[TERMINATION_STAGES.SUBMITTED]: TERMINATION_STAGES.RBM_REVIEW,
|
|
[TERMINATION_STAGES.RBM_REVIEW]: TERMINATION_STAGES.ZBH_REVIEW,
|
|
[TERMINATION_STAGES.ZBH_REVIEW]: TERMINATION_STAGES.DD_LEAD_REVIEW,
|
|
[TERMINATION_STAGES.DD_LEAD_REVIEW]: TERMINATION_STAGES.LEGAL_VERIFICATION,
|
|
[TERMINATION_STAGES.LEGAL_VERIFICATION]: TERMINATION_STAGES.DD_HEAD_REVIEW,
|
|
[TERMINATION_STAGES.DD_HEAD_REVIEW]: TERMINATION_STAGES.NBH_EVALUATION,
|
|
[TERMINATION_STAGES.NBH_EVALUATION]: TERMINATION_STAGES.SCN_ISSUED,
|
|
[TERMINATION_STAGES.SCN_ISSUED]: TERMINATION_STAGES.PERSONAL_HEARING,
|
|
[TERMINATION_STAGES.PERSONAL_HEARING]: TERMINATION_STAGES.NBH_FINAL_APPROVAL,
|
|
[TERMINATION_STAGES.NBH_FINAL_APPROVAL]: TERMINATION_STAGES.CCO_APPROVAL,
|
|
[TERMINATION_STAGES.CCO_APPROVAL]: TERMINATION_STAGES.CEO_APPROVAL,
|
|
[TERMINATION_STAGES.CEO_APPROVAL]: TERMINATION_STAGES.LEGAL_LETTER,
|
|
[TERMINATION_STAGES.LEGAL_LETTER]: TERMINATION_STAGES.TERMINATED
|
|
};
|
|
|
|
const sourceStage = termination.currentStage;
|
|
const nextStage = stageFlow[sourceStage];
|
|
logger.info(`[TerminationController] attempting transition from ${sourceStage} to ${nextStage}`);
|
|
|
|
if (!nextStage) {
|
|
await transaction.rollback();
|
|
return res.status(400).json({ success: false, message: 'Cannot approve from current stage' });
|
|
}
|
|
|
|
// SRS §4.3.2.2 — JOINT APPROVAL LOGIC FOR RBM STAGE
|
|
if (sourceStage === TERMINATION_STAGES.RBM_REVIEW && req.user.roleCode !== ROLES.SUPER_ADMIN) {
|
|
const rbmRoundTime = buildJointRoundCreatedAtFilter(
|
|
getJointRoundCutoffMsFromTimeline(termination.timeline, 'rbm_review')
|
|
);
|
|
// Prevent duplicate approval from same user
|
|
const existingUserApproval = await db.TerminationAudit.findOne({
|
|
where: {
|
|
terminationRequestId: termination.id,
|
|
userId: req.user.id,
|
|
action: 'PARTIAL_APPROVE',
|
|
'details.stage': sourceStage,
|
|
...rbmRoundTime
|
|
},
|
|
transaction
|
|
});
|
|
|
|
if (existingUserApproval) {
|
|
await transaction.rollback();
|
|
return res.status(400).json({ success: false, message: 'You have already recorded your approval for this stage.' });
|
|
}
|
|
|
|
// 1. Record this partial approval in Audit Logs
|
|
await db.TerminationAudit.create({
|
|
userId: req.user.id,
|
|
terminationRequestId: termination.id,
|
|
action: 'PARTIAL_APPROVE',
|
|
remarks: `Partial approval by ${req.user.roleCode}${remarks ? ': ' + remarks : ''}`,
|
|
details: { roleCode: req.user.roleCode, stage: sourceStage }
|
|
}, { transaction });
|
|
|
|
|
|
// 2. Check for both RBM and DD_ZM approvals in this stage
|
|
const requiredRoles = [ROLES.RBM, ROLES.DD_ZM];
|
|
const partialLogs = await db.TerminationAudit.findAll({
|
|
where: {
|
|
terminationRequestId: termination.id,
|
|
action: 'PARTIAL_APPROVE',
|
|
'details.stage': sourceStage,
|
|
...rbmRoundTime
|
|
},
|
|
transaction
|
|
});
|
|
|
|
const approvedRoles = partialLogs.map(log => (log as any).details?.roleCode);
|
|
const isComplete = requiredRoles.every(role => approvedRoles.includes(role));
|
|
|
|
if (!isComplete) {
|
|
// Record partial approval in timeline ONLY if not complete yet
|
|
// (The final approver's entry will be handled by transitionTermination)
|
|
const partialTimeline = [...(termination.timeline || []), {
|
|
stage: sourceStage,
|
|
timestamp: new Date(),
|
|
user: req.user.fullName,
|
|
role: req.user.roleCode,
|
|
action: 'Partial Approved',
|
|
remarks: remarks || `Partial approval recorded by ${req.user.roleCode}`
|
|
}];
|
|
await termination.update({ timeline: partialTimeline }, { transaction });
|
|
|
|
await transaction.commit();
|
|
return res.json({
|
|
success: true,
|
|
message: `Partial approval recorded. Waiting for ${requiredRoles.find(r => !approvedRoles.includes(r))} approval to proceed to ZBH Review.`,
|
|
isPartial: true
|
|
});
|
|
}
|
|
|
|
logger.info(`[TerminationController] Joint approval complete for ${termination.requestId}. Moving to ${nextStage}.`);
|
|
}
|
|
|
|
// SRS §4.3.2.9 — JOINT APPROVAL LOGIC FOR SCN EVALUATION (PERSONAL HEARING STAGE)
|
|
if (sourceStage === TERMINATION_STAGES.PERSONAL_HEARING && req.user.roleCode !== ROLES.SUPER_ADMIN) {
|
|
const scnEvalAuditStages = [TERMINATION_STAGES.PERSONAL_HEARING, 'Personal Hearing'];
|
|
const scnRoundTime = buildJointRoundCreatedAtFilter(
|
|
getJointRoundCutoffMsFromTimeline(termination.timeline, 'scn_response_eval')
|
|
);
|
|
const existingUserApproval = await db.TerminationAudit.findOne({
|
|
where: {
|
|
terminationRequestId: termination.id,
|
|
userId: req.user.id,
|
|
action: 'PARTIAL_APPROVE',
|
|
[Op.or]: scnEvalAuditStages.map((s) => ({ 'details.stage': s })),
|
|
...scnRoundTime
|
|
},
|
|
transaction
|
|
});
|
|
|
|
if (existingUserApproval) {
|
|
await transaction.rollback();
|
|
return res.status(400).json({ success: false, message: 'You have already recorded your approval for this stage.' });
|
|
}
|
|
|
|
await db.TerminationAudit.create({
|
|
userId: req.user.id,
|
|
terminationRequestId: termination.id,
|
|
action: 'PARTIAL_APPROVE',
|
|
remarks: `SCN Response Review by ${req.user.roleCode}${remarks ? ': ' + remarks : ''}`,
|
|
details: { roleCode: req.user.roleCode, stage: sourceStage }
|
|
}, { transaction });
|
|
|
|
const requiredRoles = [ROLES.DD_LEAD, ROLES.ZBH, ROLES.RBM, ROLES.DD_HEAD];
|
|
const partialLogs = await db.TerminationAudit.findAll({
|
|
where: {
|
|
terminationRequestId: termination.id,
|
|
action: 'PARTIAL_APPROVE',
|
|
[Op.or]: scnEvalAuditStages.map((s) => ({ 'details.stage': s })),
|
|
...scnRoundTime
|
|
},
|
|
transaction
|
|
});
|
|
|
|
const approvedRoles = partialLogs.map(log => (log as any).details?.roleCode);
|
|
const isComplete = requiredRoles.every(role => approvedRoles.includes(role));
|
|
|
|
if (!isComplete) {
|
|
const partialTimeline = [...(termination.timeline || []), {
|
|
stage: sourceStage,
|
|
timestamp: new Date(),
|
|
user: req.user.fullName,
|
|
role: req.user.roleCode,
|
|
action: 'Partial Approved (SCN Review)',
|
|
remarks: remarks || `Review recorded by ${req.user.roleCode}`
|
|
}];
|
|
await termination.update({ timeline: partialTimeline }, { transaction });
|
|
|
|
await transaction.commit();
|
|
return res.json({
|
|
success: true,
|
|
message: `Review recorded. Waiting for ${requiredRoles.filter(r => !approvedRoles.includes(r)).join(', ')} approval to proceed to NBH Final Approval.`,
|
|
isPartial: true
|
|
});
|
|
}
|
|
logger.info(`[TerminationController] SCN Joint evaluation complete for ${termination.requestId}. Moving to ${nextStage}.`);
|
|
}
|
|
|
|
approvedToStage = nextStage;
|
|
|
|
await TerminationWorkflowService.transitionTermination(termination, nextStage, req.user.id, {
|
|
remarks: remarks || `Jointly approved by RBM & DD-ZM`,
|
|
status: getTerminationStatusForStage(nextStage),
|
|
transaction
|
|
});
|
|
|
|
// F&F is never started automatically on termination; authorized users run Push to F&F when ready.
|
|
if (nextStage === TERMINATION_STAGES.TERMINATED) {
|
|
const today = new Date();
|
|
const lwd = new Date(termination.proposedLwd);
|
|
today.setHours(0, 0, 0, 0);
|
|
lwd.setHours(0, 0, 0, 0);
|
|
const statusAfterTerm =
|
|
today < lwd ? 'Awaiting F&F (LWD Pending)' : 'Awaiting F&F';
|
|
await termination.update({ status: statusAfterTerm }, { transaction });
|
|
logger.info(
|
|
`[TerminationController] Termination reached TERMINATED. F&F must be started manually (Push to F&F). LWD=${termination.proposedLwd}, status=${statusAfterTerm}`
|
|
);
|
|
}
|
|
}
|
|
|
|
await transaction.commit();
|
|
res.json({ success: true, message: 'Termination updated', termination });
|
|
} catch (error) {
|
|
if (transaction) await transaction.rollback();
|
|
logger.error('Error updating termination:', error);
|
|
next(error);
|
|
}
|
|
};
|
|
|
|
|
|
|
|
// Submit SCN Response (Dealer Principal)
|
|
export const submitScnResponse = async (req: AuthRequest, res: Response, next: NextFunction) => {
|
|
const transaction: Transaction = await db.sequelize.transaction();
|
|
try {
|
|
if (!req.user) throw new Error('Unauthorized');
|
|
|
|
const authorizedRoles = [ROLES.DD_ADMIN, ROLES.DD_LEAD, ROLES.LEGAL_ADMIN, ROLES.SUPER_ADMIN];
|
|
if (!authorizedRoles.includes(req.user.roleCode as any)) {
|
|
return res.status(403).json({ success: false, message: 'Direct SCN submission is restricted. Please submit your response to DD Admin.' });
|
|
}
|
|
const { terminationRequestId, responseBody, documents } = req.body;
|
|
|
|
const termination = await db.TerminationRequest.findByPk(terminationRequestId);
|
|
if (!termination) throw new Error('Termination request not found');
|
|
|
|
const response = await TerminationWorkflowService.handleScnResponse(termination, { responseBody, documents }, req.user.id);
|
|
|
|
await transaction.commit();
|
|
res.status(201).json({ success: true, message: 'SCN Response submitted successfully', response });
|
|
} catch (error) {
|
|
if (transaction) await transaction.rollback();
|
|
logger.error('Error submitting SCN response:', error);
|
|
next(error);
|
|
}
|
|
};
|
|
|
|
// Issue SCN for a specific termination request id (frontend-compatible route)
|
|
export const issueScn = async (req: AuthRequest, res: Response, next: NextFunction) => {
|
|
const transaction: Transaction = await db.sequelize.transaction();
|
|
try {
|
|
if (!req.user) throw new Error('Unauthorized');
|
|
const { id } = req.params;
|
|
const { remarks } = req.body;
|
|
const resolvedId = await resolveTerminationUuid(String(id));
|
|
|
|
const termination = await db.TerminationRequest.findByPk(resolvedId);
|
|
if (!termination) {
|
|
await transaction.rollback();
|
|
return res.status(404).json({ success: false, message: 'Termination request not found' });
|
|
}
|
|
|
|
if (termination.currentStage === TERMINATION_STAGES.NBH_EVALUATION) {
|
|
await TerminationWorkflowService.transitionTermination(termination, TERMINATION_STAGES.SCN_ISSUED, req.user.id, {
|
|
action: 'SCN Issued',
|
|
status: 'Show Cause Notice',
|
|
remarks: remarks || 'Show Cause Notice issued'
|
|
});
|
|
|
|
// SRS §4.3.2.8 — SCN issued: send official email to dealer (no WhatsApp)
|
|
const dealer = await db.Dealer.findByPk(termination.dealerId, {
|
|
include: [{ model: db.User, as: 'user', attributes: ['email', 'mobileNumber', 'fullName'] }]
|
|
});
|
|
const dealerUser = (dealer as any)?.user;
|
|
if (dealerUser?.email) {
|
|
sendEmail(
|
|
dealerUser.email,
|
|
`Show Cause Notice: ${termination.requestId}`,
|
|
'TERMINATION_SCN_ISSUED',
|
|
{
|
|
dealerName: dealerUser.fullName || 'Dealer',
|
|
requestId: termination.requestId,
|
|
link: `${process.env.FRONTEND_URL || 'http://localhost:5173'}/dealer-termination/${termination.id}`,
|
|
ctaLabel: 'View Notice'
|
|
}
|
|
).catch((e: any) => logger.error('[Termination] SCN email to dealer failed:', e));
|
|
}
|
|
|
|
// Notify DD-Admin + Legal of SCN issuance
|
|
const scnAlertRoles = [ROLES.DD_ADMIN, ROLES.LEGAL_ADMIN];
|
|
for (const role of scnAlertRoles) {
|
|
const roleUsers = await db.User.findAll({ where: { roleCode: role } });
|
|
for (const u of roleUsers) {
|
|
NotificationService.notify(u.id, u.email, {
|
|
title: `SCN Issued: ${termination.requestId}`,
|
|
message: `Show Cause Notice has been issued for termination case ${termination.requestId}.`,
|
|
channels: ['system', 'email'],
|
|
templateCode: 'TERMINATION_SCN_ISSUED',
|
|
placeholders: {
|
|
dealerName: dealerUser?.fullName || '',
|
|
requestId: termination.requestId,
|
|
link: `${process.env.FRONTEND_URL || 'http://localhost:5173'}/termination/${termination.id}`,
|
|
ctaLabel: 'View Case'
|
|
}
|
|
}).catch((e: any) => logger.error('[Termination] SCN admin/legal notify failed:', e));
|
|
}
|
|
}
|
|
}
|
|
|
|
await transaction.commit();
|
|
return res.json({ success: true, message: 'SCN issued successfully', termination });
|
|
} catch (error) {
|
|
if (transaction) await transaction.rollback();
|
|
logger.error('Error issuing SCN:', error);
|
|
next(error);
|
|
}
|
|
};
|
|
|
|
// Upload SCN response by route param id (frontend-compatible route)
|
|
export const uploadScnResponse = async (req: AuthRequest, res: Response, next: NextFunction) => {
|
|
const transaction: Transaction = await db.sequelize.transaction();
|
|
try {
|
|
if (!req.user) throw new Error('Unauthorized');
|
|
|
|
const authorizedRoles = [ROLES.DD_ADMIN, ROLES.DD_LEAD, ROLES.LEGAL_ADMIN, ROLES.SUPER_ADMIN];
|
|
if (!authorizedRoles.includes(req.user.roleCode as any)) {
|
|
return res.status(403).json({ success: false, message: 'Only DD Admin or DD Lead can upload the dealer SCN response.' });
|
|
}
|
|
const { id } = req.params;
|
|
const { remarks } = req.body;
|
|
const resolvedId = await resolveTerminationUuid(String(id));
|
|
|
|
const termination = await db.TerminationRequest.findByPk(resolvedId);
|
|
if (!termination) {
|
|
await transaction.rollback();
|
|
return res.status(404).json({ success: false, message: 'Termination request not found' });
|
|
}
|
|
|
|
if (req.file) {
|
|
const filePath = `/uploads/documents/${req.file.filename}`;
|
|
await db.TerminationDocument.create({
|
|
terminationRequestId: termination.id,
|
|
documentType: 'SCN Response',
|
|
fileName: req.file.originalname,
|
|
filePath,
|
|
fileSize: req.file.size,
|
|
mimeType: req.file.mimetype,
|
|
stage: TERMINATION_STAGES.SCN_ISSUED,
|
|
uploadedBy: req.user.id
|
|
}, { transaction });
|
|
}
|
|
|
|
// Move SCN -> Personal Hearing after response submission.
|
|
if (termination.currentStage === TERMINATION_STAGES.SCN_ISSUED) {
|
|
await TerminationWorkflowService.handleScnResponse(termination, {
|
|
responseBody: remarks || 'SCN response uploaded via portal',
|
|
documents: req.file ? [{ fileName: req.file.originalname }] : []
|
|
}, req.user.id);
|
|
}
|
|
|
|
await transaction.commit();
|
|
return res.status(201).json({ success: true, message: 'SCN response uploaded successfully', termination });
|
|
} catch (error) {
|
|
if (transaction) await transaction.rollback();
|
|
logger.error('Error uploading SCN response:', error);
|
|
next(error);
|
|
}
|
|
};
|
|
|
|
// Record Personal Hearing Outcome
|
|
export const recordPersonalHearing = async (req: AuthRequest, res: Response, next: NextFunction) => {
|
|
const transaction: Transaction = await db.sequelize.transaction();
|
|
try {
|
|
if (!req.user) throw new Error('Unauthorized');
|
|
const { terminationRequestId, attendees, summary, recommendation, momDocumentId } = req.body;
|
|
|
|
const termination = await db.TerminationRequest.findByPk(terminationRequestId);
|
|
if (!termination) throw new Error('Termination request not found');
|
|
|
|
const hearing = await TerminationWorkflowService.handleHearingOutcome(termination, { attendees, summary, recommendation, momDocumentId }, req.user.id);
|
|
|
|
await transaction.commit();
|
|
res.status(201).json({ success: true, message: 'Hearing record saved', recommendation });
|
|
} catch (error) {
|
|
if (transaction) await transaction.rollback();
|
|
logger.error('Error recording hearing:', error);
|
|
next(error);
|
|
}
|
|
};
|
|
|
|
// Final Authorization (NBH Final / CCO / CEO)
|
|
export const finalizeTermination = async (req: AuthRequest, res: Response, next: NextFunction) => {
|
|
const transaction: Transaction = await db.sequelize.transaction();
|
|
try {
|
|
if (!req.user) throw new Error('Unauthorized');
|
|
const { id } = req.params;
|
|
const { decision, remarks } = req.body as { decision?: 'Approve' | 'Reject' | 'Reconsider'; remarks?: string };
|
|
const resolvedId = await resolveTerminationUuid(String(id));
|
|
|
|
const termination = await db.TerminationRequest.findByPk(resolvedId);
|
|
if (!termination) {
|
|
await transaction.rollback();
|
|
return res.status(404).json({ success: false, message: 'Termination request not found' });
|
|
}
|
|
|
|
const currentStage = termination.currentStage;
|
|
const allowedFinalizeStages = [
|
|
TERMINATION_STAGES.NBH_FINAL_APPROVAL,
|
|
TERMINATION_STAGES.CCO_APPROVAL,
|
|
TERMINATION_STAGES.CEO_APPROVAL
|
|
];
|
|
if (!allowedFinalizeStages.includes(currentStage as any)) {
|
|
await transaction.rollback();
|
|
return res.status(400).json({
|
|
success: false,
|
|
message: `Finalize action is not allowed at stage: ${currentStage}`
|
|
});
|
|
}
|
|
|
|
if (decision === 'Reject') {
|
|
await TerminationWorkflowService.transitionTermination(termination, TERMINATION_STAGES.REJECTED, req.user.id, {
|
|
action: 'Final Authorization Rejected',
|
|
actionType: OFFBOARDING_ACTIONS.REJECT,
|
|
status: 'Rejected',
|
|
remarks: remarks || 'Rejected during final authorization'
|
|
});
|
|
} else if (decision === 'Reconsider' || decision === 'Send Back' as any) {
|
|
// Standardizing Send Back / Reconsideration logic
|
|
const validation = validateOffboardingAction(OFFBOARDING_ACTIONS.SEND_BACK, remarks || '');
|
|
if (!validation.valid) {
|
|
await transaction.rollback();
|
|
return res.status(400).json({ success: false, message: validation.message });
|
|
}
|
|
|
|
const previousStage = getPreviousStage(REQUEST_TYPES.TERMINATION, termination.currentStage);
|
|
const targetStage = previousStage || TERMINATION_STAGES.NBH_EVALUATION; // Fallback to NBH if manual resolve fails
|
|
|
|
await TerminationWorkflowService.transitionTermination(termination, targetStage, req.user.id, {
|
|
action: 'Sent for Reconsideration',
|
|
actionType: OFFBOARDING_ACTIONS.RECONSIDER,
|
|
status: getTerminationStatusForStage(targetStage),
|
|
remarks: remarks || 'Sent back for reconsideration'
|
|
});
|
|
} else {
|
|
const approveFlow: Record<string, string> = {
|
|
[TERMINATION_STAGES.NBH_FINAL_APPROVAL]: TERMINATION_STAGES.CCO_APPROVAL,
|
|
[TERMINATION_STAGES.CCO_APPROVAL]: TERMINATION_STAGES.CEO_APPROVAL,
|
|
[TERMINATION_STAGES.CEO_APPROVAL]: TERMINATION_STAGES.LEGAL_LETTER
|
|
};
|
|
const targetStage = approveFlow[currentStage];
|
|
await TerminationWorkflowService.transitionTermination(termination, targetStage, req.user.id, {
|
|
action: `Final Authorization Approved to ${targetStage}`,
|
|
actionType: OFFBOARDING_ACTIONS.APPROVE,
|
|
status: getTerminationStatusForStage(targetStage),
|
|
remarks: remarks || 'Approved'
|
|
});
|
|
}
|
|
|
|
await transaction.commit();
|
|
return res.json({ success: true, message: 'Final authorization processed', termination });
|
|
} catch (error) {
|
|
if (transaction) await transaction.rollback();
|
|
logger.error('Error finalizing termination:', error);
|
|
next(error);
|
|
}
|
|
};
|
|
|
|
// Record Clearance from Departments (16-Department F&F)
|
|
export const updateClearance = async (req: AuthRequest, res: Response, next: NextFunction) => {
|
|
const transaction: Transaction = await db.sequelize.transaction();
|
|
try {
|
|
if (!req.user) throw new Error('Unauthorized');
|
|
const { id } = req.params;
|
|
const { department, status, amount, type, remarks } = req.body;
|
|
const resolvedId = await resolveTerminationUuid(String(id));
|
|
|
|
const termination = await db.TerminationRequest.findByPk(resolvedId);
|
|
if (!termination) throw new Error('Termination request not found');
|
|
|
|
const clearances = { ...(termination.departmentalClearances || {}) };
|
|
const normalizedStatus = normalizeClearanceStatus(status, Number(amount) || 0);
|
|
clearances[department] = {
|
|
status: normalizedStatus,
|
|
amount: Number(amount) || 0,
|
|
type: type || 'Receivable',
|
|
remarks: remarks || '',
|
|
updatedAt: new Date().toISOString(),
|
|
updatedBy: req.user.fullName
|
|
};
|
|
|
|
await termination.update({ departmentalClearances: clearances }, { transaction });
|
|
|
|
// Update individual clearance record for unified dashboard
|
|
const fnf = await db.FnF.findOne({ where: { terminationRequestId: resolvedId } });
|
|
if (fnf) {
|
|
await db.FffClearance.update(
|
|
{ status: normalizedStatus, remarks, amount: Number(amount) || 0 },
|
|
{ where: { fnfId: fnf.id, department }, transaction }
|
|
);
|
|
}
|
|
|
|
await db.TerminationAudit.create({
|
|
userId: req.user.id,
|
|
action: 'CLEARANCE_UPDATED',
|
|
terminationRequestId: resolvedId,
|
|
remarks: remarks || `Cleared ${department}`,
|
|
details: { department, status: normalizedStatus, amount }
|
|
}, { transaction });
|
|
|
|
if (fnf) {
|
|
await db.FnFAudit.create({
|
|
userId: req.user.id,
|
|
fnfId: fnf.id,
|
|
action: 'CLEARANCE_UPDATED',
|
|
remarks: remarks || `Departmental clearance recorded for ${department}`,
|
|
details: { department, status: normalizedStatus, source: 'Termination Workflow' }
|
|
}, { transaction });
|
|
}
|
|
|
|
await transaction.commit();
|
|
res.json({ success: true, message: `Clearance updated for ${department}`, clearances });
|
|
} catch (error) {
|
|
if (transaction) await transaction.rollback();
|
|
logger.error('Error updating termination clearance:', error);
|
|
next(error);
|
|
}
|
|
};
|
|
|