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 = { [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 = { [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); } };