Dealer_Onboarding_Backend/src/modules/termination/termination.controller.ts

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