reported bugs around 15 were coverd from even new bug list also coverd from bangalore meet

This commit is contained in:
Laxman 2026-05-25 22:51:47 +05:30
parent ac31b9ba57
commit 29d67f6ca6
26 changed files with 1413 additions and 957 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,14 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Royal Enfield Onboarding</title>
<script type="module" crossorigin src="/assets/index-ny6fNePT.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index--4WsqmvE.css">
</head>
<body>
<div id="root"></div>
</body>
</html>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Royal Enfield Onboarding</title>
<script type="module" crossorigin src="/assets/index-KxZzWQFD.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BvWiaLmW.css">
</head>
<body>
<div id="root"></div>
</body>
</html>

View File

@ -229,14 +229,30 @@ mandatory remarks captured for traceability.
- The **DD-Lead** consolidates all discussions, documents, and feedback.
- Prepares a **Resignation Presentation** with recommendations and supporting data.
- Uploads the presentation to the portal.
- Forwards the case to **NBH** for final decision.
- The resignation request is reviewed by the **DD-Lead and DD-Head**. At this stage, both
roles are authorized to **Send Back or Revoke** the resignation request for clarification,
correction, or reconsideration. **Send Back actions are communicated through Work**
**Notes** , with **mandatory remarks** recorded for audit and traceability.
- Forwards the case to **DD-Head** for the next-level Dealer Development review.
- At this stage, the DD-Lead is authorized to **Send Back or Revoke** the resignation
request for clarification, correction, or reconsideration. **Send Back actions are**
**communicated through Work Notes** , with **mandatory remarks** recorded for audit and
traceability.
```
4.2.2.6 NBH Final Approval
4.2.2.6 DD-Head Review
```
- The **DD-Head** reviews the DD-Lead's consolidated dossier, presentation, and the
cumulative feedback captured by ASM, RBM/DD-ZM, ZBH, and DD-Lead.
- Validates that all Dealer Development checks (asset recovery readiness, transition
plan, territory impact) have been adequately addressed at the DD layer.
- Adds final Dealer Development remarks and either:
o **Approve** → Forwards the case to **NBH** for the National-level decision.
o **Send Back for Clarification** → Returns the case to **DD-Lead** with mandatory
remarks logged through Work Notes.
o **Revoke** → Closes the request with a documented rationale captured in Work Notes.
- The transition from **DD-Lead → DD-Head → NBH** is fully inline; on DD-Head approval
the workflow moves to NBH **without manual intervention** , and the SLA timer for the
next stage starts automatically.
```
4.2.2.7 NBH Final Approval
```
- The **National Business Head (NBH)** reviews the entire resignation dossier.
- Adds final remarks with one of the following outcomes:
@ -251,7 +267,7 @@ mandatory remarks captured for traceability.
communication and governance.
```
4.2.2.7 Legal Acceptance Letter
4.2.2.8 Legal Acceptance Letter
```
- Once approved by **NBH** , the request is **auto-assigned to the Legal team**.
- Legal verifies the uploaded resignation and issues a **Resignation Acceptance Letter**.
@ -264,7 +280,7 @@ mandatory remarks captured for traceability.
```
4.2.2.8 DD-Admin Closure
4.2.2.9 DD-Admin Closure
```
- The **DD-Admin** downloads and shares the final **Resignation Acceptance Letter** with the
dealer.

View File

@ -334,7 +334,7 @@ describe('Resignation Revoke — Dealer notified on terminal event', () => {
// ─── F&F Trigger (LWD) ────────────────────────────────────────────────────────
describe('F&F Trigger — LWD-based gate (SRS §1.1.5 + §4.2.2.8)', () => {
describe('F&F Trigger — LWD-based gate (SRS §1.1.5 + §4.2.2.9)', () => {
it('TC-RES-15: F&F initiation is allowed when today >= LWD', () => {
const lwd = new Date('2026-01-01'); // in the past
const today = new Date();

View File

@ -200,6 +200,7 @@ export const RESIGNATION_STAGES = {
RBM: 'RBM + DD-ZM Review',
ZBH: 'ZBH',
DD_LEAD: 'DD Lead',
DD_HEAD: 'DD Head',
NBH: 'NBH',
DD_ADMIN: 'DD Admin',
/** Post DD Admin — workflow paused until an authorized user runs Push to F&F (no automatic F&F). */
@ -420,6 +421,7 @@ export const AUDIT_ACTIONS = {
RESIGNATION_REJECTED: 'RESIGNATION_REJECTED',
RESIGNATION_REVOKED: 'RESIGNATION_REVOKED',
RESIGNATION_SENT_BACK: 'RESIGNATION_SENT_BACK',
RESIGNATION_LETTER_DISPATCHED: 'RESIGNATION_LETTER_DISPATCHED',
TERMINATION_REVOKED: 'TERMINATION_REVOKED',
TERMINATION_SENT_BACK: 'TERMINATION_SENT_BACK',
RELOCATION_SENT_BACK: 'RELOCATION_SENT_BACK',
@ -538,6 +540,7 @@ export const RESIGNATION_DOCUMENT_STAGES = [
] as const;
export const TERMINATION_DOCUMENT_TYPES = [
'Presentation',
'Termination Recommendation',
'Show Cause Notice',
'SCN Response',

View File

@ -108,6 +108,7 @@ const RESIGNATION_SLA: SlaCatalogEntry[] = [
d('RESIGNATION', RESIGNATION_STAGES.RBM, 'RBM, DD-ZM', 3),
d('RESIGNATION', RESIGNATION_STAGES.ZBH, 'ZBH', 3),
d('RESIGNATION', RESIGNATION_STAGES.DD_LEAD, 'DD Lead', 5),
d('RESIGNATION', RESIGNATION_STAGES.DD_HEAD, 'DD Head', 5),
d('RESIGNATION', RESIGNATION_STAGES.NBH, 'NBH', 5),
d('RESIGNATION', RESIGNATION_STAGES.LEGAL, 'Legal Admin', 7),
d('RESIGNATION', RESIGNATION_STAGES.DD_ADMIN, 'DD Admin', 3),

View File

@ -88,6 +88,18 @@ export const uploadSingleIfMultipart = (req: Request, res: Response, next: NextF
// Multiple files upload
export const uploadMultiple = upload.array('files', 10); // Max 10 files
/**
* Only parse multipart with multiple files when the client sends multipart/form-data.
* Otherwise JSON bodies from express.json() are preserved.
*/
export const uploadMultipleIfMultipart = (req: Request, res: Response, next: NextFunction) => {
const ct = String(req.headers['content-type'] || '').toLowerCase();
if (ct.includes('multipart/form-data')) {
return uploadMultiple(req, res, next);
}
next();
};
// Error handler for multer
export const handleUploadError = (err: any, req: Request, res: Response, next: NextFunction) => {
if (err instanceof multer.MulterError) {

View File

@ -119,11 +119,21 @@ async function resolveEmailTemplate(templateCode: string) {
return null;
}
export interface SendEmailOptions {
attachments?: Array<{
filename: string;
path?: string;
content?: Buffer | string;
contentType?: string;
}>;
}
export const sendEmail = async (
to: string,
subject: string,
templateCode: string,
replacements: Record<string, string>
replacements: Record<string, string>,
options?: SendEmailOptions
) => {
try {
let finalHtml = '';
@ -181,7 +191,8 @@ export const sendEmail = async (
from: fromAddress,
to,
subject: finalSubject,
html: finalHtml
html: finalHtml,
...(options?.attachments?.length ? { attachments: options.attachments } : {})
};
const info = await readyTransporter.sendMail(mail);

View File

@ -27,6 +27,7 @@ export const getResignationStatusForStage = (stage: string): string => {
case RESIGNATION_STAGES.ASM:
case RESIGNATION_STAGES.ZBH:
case RESIGNATION_STAGES.DD_LEAD:
case RESIGNATION_STAGES.DD_HEAD:
case RESIGNATION_STAGES.NBH:
case RESIGNATION_STAGES.DD_ADMIN:
return `${stage} Review`;

View File

@ -38,7 +38,8 @@ export const getPreviousStage = (requestType: string, currentStage: string): str
[RESIGNATION_STAGES.RBM]: RESIGNATION_STAGES.ASM,
[RESIGNATION_STAGES.ZBH]: RESIGNATION_STAGES.RBM,
[RESIGNATION_STAGES.DD_LEAD]: RESIGNATION_STAGES.ZBH,
[RESIGNATION_STAGES.NBH]: RESIGNATION_STAGES.DD_LEAD,
[RESIGNATION_STAGES.DD_HEAD]: RESIGNATION_STAGES.DD_LEAD,
[RESIGNATION_STAGES.NBH]: RESIGNATION_STAGES.DD_HEAD,
[RESIGNATION_STAGES.LEGAL]: RESIGNATION_STAGES.NBH,
[RESIGNATION_STAGES.DD_ADMIN]: RESIGNATION_STAGES.LEGAL,
[RESIGNATION_STAGES.AWAITING_FNF]: RESIGNATION_STAGES.DD_ADMIN,

View File

@ -3,7 +3,6 @@ import { Op } from 'sequelize';
import { sendEmail } from './email.service.js';
import { NotificationService } from '../../services/NotificationService.js';
import {
import { getFrontendBaseUrl } from './frontendUrl.js';
APPLICATION_STAGES,
TERMINATION_STAGES,
CONSTITUTIONAL_STAGES,
@ -11,6 +10,7 @@ import { getFrontendBaseUrl } from './frontendUrl.js';
REQUEST_TYPES,
ROLES
} from '../config/constants.js';
import { getFrontendBaseUrl } from './frontendUrl.js';
const { RequestParticipant, User, Outlet, District, Dealer } = db;

View File

@ -47,6 +47,7 @@ export const ALLOWED_EMAIL_TEMPLATE_CODES = [
'RESIGNATION_RECEIVED',
'RESIGNATION_SUBMITTED',
'RESIGNATION_UPDATE',
'RESIGNATION_LETTER_DISPATCHED_DEALER',
'SLA_BREACH_WARNING',
'STATUTORY_DOCUMENT_REQUEST',
'SLA_REMINDER',

View File

@ -0,0 +1,21 @@
{{> email_header}}
<h2>Your Resignation Acceptance Letter</h2>
<p>Dear {{dealerName}},</p>
<p>
The Dealer Development office has formally issued the acceptance letter for your
resignation request (Request ID: <strong>{{resignationId}}</strong>).
</p>
<p><strong>Proposed Last Working Day:</strong> {{lwd}}</p>
<p>
The signed acceptance letter is <strong>attached to this email</strong>. Please review
it and retain a copy for your records. The Full &amp; Final settlement process will be
initiated by the team after your Last Working Day.
</p>
<p>
For any questions, please contact <strong>{{dispatchedBy}}</strong> or the Dealer
Development team using the link below.
</p>
<p>
<a href="{{link}}">{{ctaLabel}}</a>
</p>
{{> email_footer}}

View File

@ -24,6 +24,9 @@ import { OFFBOARDING_ACTIONS } from '../../common/config/constants.js';
import { validateOffboardingAction, getPreviousStage } from '../../common/utils/offboardingWorkflow.utils.js';
import { writeWorkflowActivityWorknote } from '../../common/utils/workflowWorknote.js';
import { getFrontendBaseUrl } from '../../common/utils/frontendUrl.js';
import { sendEmail } from '../../common/utils/email.service.js';
import fs from 'fs';
import path from 'path';
// Removed generateResignationId and moved to NomenclatureService
const resolveResignationUuid = async (id: string) => {
@ -395,7 +398,8 @@ export const approveResignation = async (req: AuthRequest, res: Response, next:
[RESIGNATION_STAGES.ASM]: RESIGNATION_STAGES.RBM,
[RESIGNATION_STAGES.RBM]: RESIGNATION_STAGES.ZBH,
[RESIGNATION_STAGES.ZBH]: RESIGNATION_STAGES.DD_LEAD,
[RESIGNATION_STAGES.DD_LEAD]: RESIGNATION_STAGES.NBH,
[RESIGNATION_STAGES.DD_LEAD]: RESIGNATION_STAGES.DD_HEAD,
[RESIGNATION_STAGES.DD_HEAD]: RESIGNATION_STAGES.NBH,
[RESIGNATION_STAGES.NBH]: RESIGNATION_STAGES.LEGAL,
[RESIGNATION_STAGES.LEGAL]: RESIGNATION_STAGES.DD_ADMIN,
// DD Admin approval completes internal review; F&F is started only via explicit Push to F&F.
@ -410,7 +414,7 @@ export const approveResignation = async (req: AuthRequest, res: Response, next:
}
// F&F records are created only from explicit Push to F&F (targetStage), not from sequential approvals.
// LWD gate applies to that manual push (SRS §4.2.2.8).
// LWD gate applies to that manual push (SRS §4.2.2.9).
let shouldTriggerFnF = false;
if (nextStage === RESIGNATION_STAGES.FNF_INITIATED && targetOverride) {
const lwdString = resignation.lastOperationalDateServices || resignation.lastOperationalDateSales;
@ -1098,6 +1102,9 @@ export const updateResignationStatus = async (req: AuthRequest, res: Response, n
case 'assign':
return assignResignation(req, res, next);
case 'dispatch':
return dispatchResignationLetter(req, res, next);
default:
return res.status(400).json({
success: false,
@ -1109,3 +1116,247 @@ export const updateResignationStatus = async (req: AuthRequest, res: Response, n
next(error);
}
};
/**
* Dispatch the Resignation Acceptance Letter to the dealer (DD Admin step).
*
* Workflow context:
* Legal Admin uploads the Resignation Acceptance Letter at the Legal stage and
* approves the request transitions to DD Admin. At DD Admin, the assigned admin
* formally sends the dispatched copy of the letter to the dealer (email +
* download link). On successful dispatch the request advances to Awaiting F&F.
*
* Authorization:
* - DD Admin (assigned role for the DD Admin stage)
* - Super Admin (bypass)
*/
export const dispatchResignationLetter = async (req: AuthRequest, res: Response, next: NextFunction) => {
const transaction: Transaction = await db.sequelize.transaction();
try {
if (!req.user) throw new Error('Unauthorized');
const allowedRoles: string[] = [ROLES.DD_ADMIN, ROLES.SUPER_ADMIN];
if (!allowedRoles.includes(req.user.roleCode as any)) {
await transaction.rollback();
return res.status(403).json({
success: false,
message: 'Only DD Admin or Super Admin can dispatch the resignation acceptance letter.'
});
}
const { id } = req.params;
const remarks: string = (req.body?.remarks || '').trim();
const resolvedId = await resolveResignationUuid(String(id));
const resignation = await db.Resignation.findOne({
where: { id: resolvedId },
include: [{ model: db.Outlet, as: 'outlet' }],
transaction
});
if (!resignation) {
await transaction.rollback();
return res.status(404).json({ success: false, message: 'Resignation not found' });
}
// Dispatch is available from DD Admin onwards. If the admin forgot to
// send the letter at DD Admin, the action remains accessible while the
// request moves through Awaiting F&F / F&F Initiated, so the dealer can
// still be informed retroactively. Once the case is closed (Completed /
// Rejected / Withdrawn / Revoked) dispatch is no longer meaningful.
const dispatchAllowedStages: string[] = [
RESIGNATION_STAGES.DD_ADMIN,
RESIGNATION_STAGES.AWAITING_FNF,
RESIGNATION_STAGES.FNF_INITIATED
];
const closedStatuses = ['Completed', 'Rejected', 'Withdrawn', 'Revoked', 'Settled'];
if (
!dispatchAllowedStages.includes(resignation.currentStage as any) ||
closedStatuses.includes(String(resignation.status || ''))
) {
await transaction.rollback();
return res.status(400).json({
success: false,
message: `Dispatch can be performed only from DD Admin stage onwards while the request is still open. Current stage: ${resignation.currentStage}, status: ${resignation.status}.`
});
}
// Block duplicate dispatch — once the letter has been sent we don't
// want a second email going out without the user explicitly knowing.
const existingDispatch = await db.ResignationAudit.findOne({
where: {
resignationId: resignation.id,
action: AUDIT_ACTIONS.RESIGNATION_LETTER_DISPATCHED
},
transaction
});
if (existingDispatch) {
await transaction.rollback();
return res.status(400).json({
success: false,
message: 'Resignation acceptance letter has already been dispatched to the dealer.'
});
}
// Pick the most recent acceptance letter — preferentially typed as
// "Resignation Acceptance Letter"; fall back to any document uploaded at
// the Legal stage (covers legacy uploads where documentType was generic).
const acceptanceLetter = await db.ResignationDocument.findOne({
where: {
resignationId: resignation.id,
[Op.or]: [
{ documentType: 'Resignation Acceptance Letter' },
{ stage: RESIGNATION_STAGES.LEGAL }
]
},
order: [['createdAt', 'DESC']],
transaction
});
if (!acceptanceLetter) {
await transaction.rollback();
return res.status(400).json({
success: false,
message: 'No Resignation Acceptance Letter is available. Ask Legal to upload the letter before dispatching.'
});
}
// Lookup dealer user for the dealer email channel.
// `Resignation.dealerId` is used inconsistently across the codebase —
// it may point at the dealer-User directly (User.id) for internal /
// legacy rows, or at the Dealer record (Dealer.id) which is then
// referenced from User.dealerId. Mirror the resolution used by
// ResignationWorkflowService.transitionResignation so we cover both.
const dealerUser = await db.User.findOne({
where: {
[Op.or]: [
{ id: resignation.dealerId },
{ dealerId: resignation.dealerId }
]
},
transaction
});
const portalBase = getFrontendBaseUrl();
const dealerName = dealerUser?.fullName || (resignation as any).outlet?.name || 'Dealer';
const resignationCode = resignation.resignationId || resignation.id;
const lwd = resignation.lastOperationalDateServices || resignation.lastOperationalDateSales || 'N/A';
const portalLink = `${portalBase}/dealer-resignation/${resignation.id}`;
// Resolve the acceptance letter on disk so we can attach the file itself
// to the email instead of redirecting the dealer to our portal.
// `acceptanceLetter.filePath` is stored as `/uploads/documents/<name>`;
// map it back to UPLOAD_DIR using just the basename so we never let an
// injected path escape the documents folder.
const uploadDir = process.env.UPLOAD_DIR || './uploads';
const documentsDir = path.resolve(uploadDir, 'documents');
const letterDiskPath = path.resolve(documentsDir, path.basename(acceptanceLetter.filePath));
const letterFileExists = fs.existsSync(letterDiskPath);
if (!letterFileExists) {
logger.warn(
`[dispatchResignationLetter] Acceptance letter file missing on disk: ${letterDiskPath}. Email will be sent without attachment.`
);
}
// Record audit + timeline entry before sending notifications so the trail
// is consistent even if the email transport blips. The transition below
// is the workflow gate; this row provides a verbose human-readable note.
await db.ResignationAudit.create({
userId: req.user.id,
resignationId: resignation.id,
action: AUDIT_ACTIONS.RESIGNATION_LETTER_DISPATCHED,
remarks: remarks || `Resignation acceptance letter dispatched to ${dealerName}`,
details: {
fileName: acceptanceLetter.fileName,
documentId: acceptanceLetter.id,
dealerEmail: dealerUser?.email || null,
stage: RESIGNATION_STAGES.DD_ADMIN
}
}, { transaction });
const timeline = [...(resignation.timeline || []), {
stage: RESIGNATION_STAGES.DD_ADMIN,
timestamp: new Date(),
user: req.user.fullName,
role: req.user.roleCode,
action: 'Resignation Letter Dispatched',
remarks: remarks || `Acceptance letter sent to ${dealerName}${dealerUser?.email ? ` (${dealerUser.email})` : ''}`
}];
await resignation.update({ timeline }, { transaction });
const wasAtDDAdmin = resignation.currentStage === RESIGNATION_STAGES.DD_ADMIN;
await transaction.commit();
// Advance the workflow only when the dispatch happens at the DD Admin step.
// If the admin missed dispatch and the case has already moved on
// (Awaiting F&F / F&F Initiated), we send the letter without touching the
// current stage to avoid an artificial "step back".
if (wasAtDDAdmin) {
await ResignationWorkflowService.transitionResignation(
resignation,
RESIGNATION_STAGES.AWAITING_FNF,
req.user.id,
{
action: 'Resignation Letter Dispatched',
remarks: remarks || 'Acceptance letter dispatched to dealer.'
}
);
}
// Send the formal dispatch email to the dealer with the acceptance
// letter file attached. Non-fatal: log and proceed if the mailer hiccups.
if (dealerUser?.email) {
try {
await sendEmail(
dealerUser.email,
`Resignation Acceptance Letter Dispatched: ${resignationCode}`,
'RESIGNATION_LETTER_DISPATCHED_DEALER',
{
dealerName,
resignationId: String(resignationCode),
lwd: String(lwd),
dispatchedBy: req.user.fullName || 'Dealer Development Office',
link: portalLink,
ctaLabel: 'View Resignation Status'
},
letterFileExists
? {
attachments: [
{
filename: acceptanceLetter.fileName,
path: letterDiskPath,
contentType: acceptanceLetter.mimeType || undefined
}
]
}
: undefined
);
} catch (mailErr) {
logger.error('[dispatchResignationLetter] dealer email failed:', mailErr);
}
} else {
logger.warn(
`[dispatchResignationLetter] No dealer user/email found for resignation ${resignation.resignationId}; in-app/portal notification only.`
);
}
return res.json({
success: true,
message: wasAtDDAdmin
? 'Resignation acceptance letter dispatched to the dealer. Request moved to Awaiting F&F.'
: 'Resignation acceptance letter dispatched to the dealer. Workflow stage left unchanged.',
nextStage: wasAtDDAdmin ? RESIGNATION_STAGES.AWAITING_FNF : resignation.currentStage,
document: {
id: acceptanceLetter.id,
fileName: acceptanceLetter.fileName,
filePath: acceptanceLetter.filePath
}
});
} catch (error) {
if (transaction) {
try { await transaction.rollback(); } catch (_) { /* already finalized */ }
}
logger.error('Error dispatching resignation letter:', error);
next(error);
}
};

View File

@ -19,6 +19,7 @@ router.put('/:id/withdraw', authenticate as any, resignationController.withdrawR
router.post('/:id/withdraw', authenticate as any, resignationController.withdrawResignation);
router.put('/:id/sendback', authenticate as any, resignationController.sendBackResignation);
router.post('/:id/sendback', authenticate as any, resignationController.sendBackResignation);
router.post('/:id/dispatch', authenticate as any, resignationController.dispatchResignationLetter);
router.put('/:id/clearance', authenticate as any, uploadSingleIfMultipart, resignationController.updateClearance);
router.post('/:id/documents', authenticate as any, uploadSingle, resignationController.uploadResignationDocument);

View File

@ -12,6 +12,7 @@ import { resolveEntityUuidByType } from '../../common/utils/requestResolver.js';
import { NomenclatureService } from '../../common/utils/nomenclature.js';
import { NotificationService } from '../../services/NotificationService.js';
import { getFrontendBaseUrl } from '../../common/utils/frontendUrl.js';
import { v4 as uuidv4 } from 'uuid';
const portalBase = getFrontendBaseUrl();
@ -89,31 +90,64 @@ export const uploadFnFDocument = async (req: AuthRequest, res: Response) => {
try {
const { id } = req.params;
const fileUrl = req.file ? `/uploads/documents/${req.file.filename}` : req.body.fileUrl;
if (!fileUrl) {
return res.status(400).json({ success: false, message: 'No file uploaded' });
}
const documentName = (req.body?.documentName || '').toString().trim();
if (!documentName) {
return res.status(400).json({
success: false,
message: 'Document name is required.'
});
}
const documentType = (req.body?.documentType || '').toString().trim() || null;
const fnf = await FnF.findByPk(id);
if (!fnf) {
return res.status(404).json({ success: false, message: 'F&F settlement not found' });
}
const newEntry = {
id: uuidv4(),
name: documentName,
documentType,
originalFileName: req.file?.originalname || null,
supportingDocument: fileUrl,
department: 'Finance', // Default to Finance for generic F&F docs
clearedAt: new Date().toISOString(),
uploadedBy: req.user?.id || null
};
const updatedClearances = [
...(fnf.clearanceDocuments || []),
{
name: req.file?.originalname || 'Uploaded Document',
supportingDocument: fileUrl,
department: 'Finance', // Default to Finance for generic F&F docs
clearedAt: new Date().toISOString()
}
newEntry
];
await fnf.update({
clearanceDocuments: updatedClearances
});
res.json({ success: true, url: fileUrl, name: req.file?.originalname || 'Document' });
try {
await db.FnFAudit.create({
userId: req.user?.id || null,
fnfId: String(id),
action: AUDIT_ACTIONS.DOCUMENT_UPLOADED,
remarks: `Document "${newEntry.name}" uploaded`,
details: {
documentName: newEntry.name,
documentType: newEntry.documentType,
originalFileName: newEntry.originalFileName,
department: newEntry.department
}
});
} catch (auditErr) {
console.error('[uploadFnFDocument] Non-fatal audit failure:', auditErr);
}
res.json({ success: true, document: newEntry });
} catch (error) {
console.error('Error uploading F&F document:', error);
res.status(500).json({ success: false, message: 'Error uploading F&F document' });

View File

@ -47,6 +47,40 @@ export const createTermination = async (req: AuthRequest, res: Response, next: N
});
}
const uploadedFiles: Express.Multer.File[] = Array.isArray(req.files)
? (req.files as Express.Multer.File[])
: [];
const PPT_MIME_TYPES = [
'application/vnd.ms-powerpoint',
'application/vnd.openxmlformats-officedocument.presentationml.presentation'
];
const isPptFile = (file: Express.Multer.File) => {
const lowerName = String(file.originalname || '').toLowerCase();
const hasPptExt = lowerName.endsWith('.ppt') || lowerName.endsWith('.pptx');
const hasPptMime = PPT_MIME_TYPES.includes(file.mimetype);
return hasPptMime || hasPptExt;
};
const isPresentationMandatory = req.user.roleCode !== ROLES.SUPER_ADMIN;
if (isPresentationMandatory) {
if (uploadedFiles.length === 0) {
await transaction.rollback();
return res.status(400).json({
success: false,
message: 'At least one Presentation (.ppt or .pptx) is mandatory to initiate a termination request.'
});
}
const hasAtLeastOnePpt = uploadedFiles.some(isPptFile);
if (!hasAtLeastOnePpt) {
await transaction.rollback();
return res.status(400).json({
success: false,
message: 'At least one PowerPoint file (.ppt or .pptx) is required as the Presentation.'
});
}
}
const { dealerId, category, reason, proposedLwd, comments } = req.body;
const requestId = await NomenclatureService.generateTerminationId();
@ -81,6 +115,33 @@ export const createTermination = async (req: AuthRequest, res: Response, next: N
remarks: 'Admin initiated termination request'
}, { transaction });
if (uploadedFiles.length > 0) {
const stage = 'Submitted';
for (const file of uploadedFiles) {
const documentType = isPptFile(file) ? 'Presentation' : 'Other';
const filePath = `/uploads/documents/${file.filename}`;
await db.TerminationDocument.create({
terminationRequestId: termination.id,
documentType,
fileName: file.originalname,
filePath,
fileSize: file.size,
mimeType: 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: file.originalname, stage, documentType }
}, { transaction });
}
}
await transaction.commit();
// Add as chat participants (Async)
@ -363,6 +424,32 @@ export const updateTerminationStatus = async (req: AuthRequest, res: Response, n
}
}
// Workflow-changing actions are gated by role. Only the role assigned to the
// current stage can act; Super Admin is the sole role that can bypass any stage.
// DD Admin is administrative and intentionally has no approve/reject power.
const workflowChangingActions: string[] = [
OFFBOARDING_ACTIONS.REJECT,
OFFBOARDING_ACTIONS.REVOKE,
OFFBOARDING_ACTIONS.SEND_BACK,
'sendback',
'approve'
];
const isWorkflowChanging =
workflowChangingActions.includes(action) || (action !== 'pushfnf' && action !== OFFBOARDING_ACTIONS.HOLD);
if (isWorkflowChanging) {
const isSuperAdmin = req.user.roleCode === ROLES.SUPER_ADMIN;
const allowed = isSuperAdmin
? true
: await TerminationWorkflowService.canUserAction(termination, req.user);
if (!allowed) {
await transaction.rollback();
return res.status(403).json({
success: false,
message: 'You are not authorized to approve, reject, send back, or revoke this termination request at the current stage. Only the stage owner or a Super Admin may take this action.'
});
}
}
const fromStage = termination.currentStage;
let approvedToStage: string | null = null;
let scheduleLwdFnfReminder = false;

View File

@ -5,11 +5,11 @@ import {
submitScnResponse, recordPersonalHearing, updateClearance, uploadTerminationDocument, issueScn, uploadScnResponse, finalizeTermination
} from './termination.controller.js';
import { authenticate } from '../../common/middleware/auth.js';
import { uploadSingle } from '../../common/middleware/upload.js';
import { uploadSingle, uploadMultipleIfMultipart } from '../../common/middleware/upload.js';
router.use(authenticate as any);
router.post('/', createTermination);
router.post('/', uploadMultipleIfMultipart, createTermination);
router.get('/', getTerminations);
router.get('/:id', getTerminationById);
router.put('/:id/status', updateTerminationStatus);

View File

@ -108,6 +108,13 @@ const seedTemplates = async () => {
fileName: 'resignation_approved.html',
placeholders: ['dealerName', 'resignationId', 'lwd']
},
{
templateCode: 'RESIGNATION_LETTER_DISPATCHED_DEALER',
description: 'Notification sent to the dealer when the DD Admin dispatches the resignation acceptance letter (letter is delivered as an email attachment)',
subject: 'Resignation Acceptance Letter Dispatched: {{resignationId}}',
fileName: 'resignation_letter_dispatched_dealer.html',
placeholders: ['dealerName', 'resignationId', 'lwd', 'dispatchedBy', 'link', 'ctaLabel']
},
{
templateCode: 'TERMINATION_INITIATED',
description: 'Notification for new Termination request initiation',

View File

@ -137,12 +137,13 @@ export class ResignationWorkflowService {
*/
static calculateProgress(stage: string): number {
const progress: Record<string, number> = {
[RESIGNATION_STAGES.ASM]: 15,
[RESIGNATION_STAGES.RBM]: 30,
[RESIGNATION_STAGES.ZBH]: 40,
[RESIGNATION_STAGES.ASM]: 12,
[RESIGNATION_STAGES.RBM]: 25,
[RESIGNATION_STAGES.ZBH]: 38,
[RESIGNATION_STAGES.DD_LEAD]: 50,
[RESIGNATION_STAGES.NBH]: 65,
[RESIGNATION_STAGES.LEGAL]: 80,
[RESIGNATION_STAGES.DD_HEAD]: 60,
[RESIGNATION_STAGES.NBH]: 70,
[RESIGNATION_STAGES.LEGAL]: 82,
[RESIGNATION_STAGES.DD_ADMIN]: 90,
[RESIGNATION_STAGES.AWAITING_FNF]: 92,
[RESIGNATION_STAGES.FNF_INITIATED]: 95,
@ -164,6 +165,7 @@ export class ResignationWorkflowService {
[RESIGNATION_STAGES.RBM]: [ROLES.RBM, ROLES.DD_ZM],
[RESIGNATION_STAGES.ZBH]: ROLES.ZBH,
[RESIGNATION_STAGES.DD_LEAD]: ROLES.DD_LEAD,
[RESIGNATION_STAGES.DD_HEAD]: ROLES.DD_HEAD,
[RESIGNATION_STAGES.NBH]: ROLES.NBH,
[RESIGNATION_STAGES.LEGAL]: ROLES.LEGAL_ADMIN,
[RESIGNATION_STAGES.DD_ADMIN]: ROLES.DD_ADMIN,
@ -180,7 +182,7 @@ export class ResignationWorkflowService {
/**
* Initiates the F&F settlement process for a resignation
* SRS §4.2.2.8 Standardized trigger mechanism
* SRS §4.2.2.9 Standardized trigger mechanism
*/
static async initiateFnF(resignation: any, userId: string, transaction: Transaction) {
try {

View File

@ -8,12 +8,12 @@ import { resolveRecipientsForRoles } from './slaGeographyResolver.js';
import { writeWorkflowActivityWorknote } from '../common/utils/workflowWorknote.js';
import type { WorkflowActivityRequestType } from '../common/utils/workflowWorknote.js';
import {
import { getFrontendBaseUrl } from '../common/utils/frontendUrl.js';
claimSlaNotificationDispatch,
SlaDispatchKeys,
updateSlaDispatchRecipientCount,
wasSlaNotificationDispatched
} from './slaNotificationDispatch.service.js';
import { getFrontendBaseUrl } from '../common/utils/frontendUrl.js';
export type SlaTrackRef = {
entityType: string;

View File

@ -17,6 +17,7 @@ const EMAILS = {
DD_ZM: 'piyush@royalenfield.com',
ZBH: 'manav@royalenfield.com',
DD_LEAD: 'jaya@royalenfield.com',
DD_HEAD: 'ganesh@royalenfield.com',
NBH: 'yashwin@royalenfield.com',
LEGAL: 'legal@royalenfield.com',
FINANCE: 'finance@royalenfield.com',
@ -164,6 +165,7 @@ async function run() {
{ stage: 'RBM + DD-ZM Review', name: 'DD-ZM', email: EMAILS.DD_ZM, remarks: 'Joint approval evaluated and verified by DD-ZM.' },
{ stage: 'ZBH', name: 'ZBH', email: EMAILS.ZBH, remarks: 'Zone-level resource reallocation planned. Approved.' },
{ stage: 'DD Lead', name: 'DD Lead', email: EMAILS.DD_LEAD, remarks: 'Dealer development criteria satisfied for exit.' },
{ stage: 'DD Head', name: 'DD Head', email: EMAILS.DD_HEAD, remarks: 'Dealer development head sign-off recorded. Recommended for NBH.' },
{ stage: 'NBH', name: 'NBH', email: EMAILS.NBH, remarks: 'HQ clearance granted. Proceed with F&F initiation.' },
{ stage: 'Legal', name: 'Legal Admin', email: EMAILS.LEGAL, remarks: 'Legal documentation verified. No active litigation found.' },
{ stage: 'DD Admin', name: 'DD Admin', email: EMAILS.DD_ADMIN, remarks: 'Administrative checks complete. Initiating termination of commercial contract.' }
@ -176,7 +178,7 @@ async function run() {
console.log(`Current Stage: ${currentStage}`);
const stageOrder = [
'Request Submitted', 'ASM', 'RBM + DD-ZM Review', 'ZBH', 'DD Lead', 'NBH', 'Legal', 'DD Admin', 'F&F Initiated', 'Completed'
'Request Submitted', 'ASM', 'RBM + DD-ZM Review', 'ZBH', 'DD Lead', 'DD Head', 'NBH', 'Legal', 'DD Admin', 'F&F Initiated', 'Completed'
];
let startStageIndex = stageOrder.indexOf(currentStage) === -1 ? 0 : stageOrder.indexOf(currentStage);

View File

@ -23,7 +23,7 @@ const EMAILS = {
RBM_L1: 'manish@royalenfield.com',
ZM_L1: 'piyush@royalenfield.com',
DD_LEAD: 'jaya@royalenfield.com',
ZBH: 'laxmanhalaki814@royalenfield.com',
ZBH: 'laxmanhalaki814@gmail.com',
NBH: 'yashwin@royalenfield.com',
DD_HEAD: 'ganesh@royalenfield.com',
FDD: 'fdd@royalenfield.com',
@ -252,141 +252,141 @@ async function triggerWorkflow() {
await delay();
// 5. LEVEL-2 INTERVIEW
// log(5, 'Scheduling Level 2 Interview...');
log(5, 'Scheduling Level 2 Interview...');
// const intv2Response = await apiRequest('/assessment/interviews', 'POST', {
// applicationId: applicationUUID,
// level: 2,
// scheduledAt: new Date(Date.now() + 172800000).toISOString(),
// type: 'Online',
// location: 'Teams',
// participants: [ddLead.id, zbhUser.id]
// }, leadToken);
// const interviewId2 = intv2Response.data.id;
const intv2Response = await apiRequest('/assessment/interviews', 'POST', {
applicationId: applicationUUID,
level: 2,
scheduledAt: new Date(Date.now() + 172800000).toISOString(),
type: 'Online',
location: 'Teams',
participants: [ddLead.id, zbhUser.id]
}, leadToken);
const interviewId2 = intv2Response.data.id;
// log(5.1, 'DD-Lead Giving Feedback...');
// await apiRequest('/assessment/level2-feedback', 'POST', {
// interviewId: interviewId2,
// overallScore: 9.5,
// feedbackItems: [
// { type: 'Strategic Vision', comments: 'Excellent strategic planning.' },
// { type: 'Management Capabilities', comments: 'Strong team leadership.' },
// { type: 'Operational Understanding', comments: 'Knows the local market well.' }
// ],
// recommendation: 'Selected'
// }, leadToken);
log(5.1, 'DD-Lead Giving Feedback...');
await apiRequest('/assessment/level2-feedback', 'POST', {
interviewId: interviewId2,
overallScore: 9.5,
feedbackItems: [
{ type: 'Strategic Vision', comments: 'Excellent strategic planning.' },
{ type: 'Management Capabilities', comments: 'Strong team leadership.' },
{ type: 'Operational Understanding', comments: 'Knows the local market well.' }
],
recommendation: 'Selected'
}, leadToken);
// log(5.15, 'ZBH Giving Feedback...');
// const zbhToken = await login(zbhUser.email);
// await apiRequest('/assessment/level2-feedback', 'POST', {
// interviewId: interviewId2,
// overallScore: 9.0,
// feedbackItems: [
// { type: 'Strategic Vision', comments: 'Good alignment with brand.' },
// { type: 'Key Strengths', comments: 'Great location proposed.' },
// { type: 'Areas of Concern', comments: 'None at this time.' }
// ],
// recommendation: 'Selected'
// }, zbhToken);
log(5.15, 'ZBH Giving Feedback...');
const zbhToken = await login(zbhUser.email);
await apiRequest('/assessment/level2-feedback', 'POST', {
interviewId: interviewId2,
overallScore: 9.0,
feedbackItems: [
{ type: 'Strategic Vision', comments: 'Good alignment with brand.' },
{ type: 'Key Strengths', comments: 'Great location proposed.' },
{ type: 'Areas of Concern', comments: 'None at this time.' }
],
recommendation: 'Selected'
}, zbhToken);
// log(5.2, 'DD-Lead Finalizing Level 2 Decision...');
// await apiRequest('/assessment/decision', 'POST', {
// interviewId: interviewId2,
// decision: 'Approved',
// remarks: 'Cleared Level 2'
// }, leadToken);
// log(5, 'Level 2 Complete.');
// await delay();
log(5.2, 'DD-Lead Finalizing Level 2 Decision...');
await apiRequest('/assessment/decision', 'POST', {
interviewId: interviewId2,
decision: 'Approved',
remarks: 'Cleared Level 2'
}, leadToken);
log(5, 'Level 2 Complete.');
await delay();
// // 6. LEVEL-3 INTERVIEW
// log(6, 'Scheduling Level 3 Interview...');
// const headUser = users.data.find(u => u.email === EMAILS.DD_HEAD);
// const nbhUser = users.data.find(u => u.email === EMAILS.NBH);
// 6. LEVEL-3 INTERVIEW
log(6, 'Scheduling Level 3 Interview...');
const headUser = users.data.find(u => u.email === EMAILS.DD_HEAD);
const nbhUser = users.data.find(u => u.email === EMAILS.NBH);
// const intv3Response = await apiRequest('/assessment/interviews', 'POST', {
// applicationId: applicationUUID,
// level: 3,
// scheduledAt: new Date(Date.now() + 259200000).toISOString(),
// type: 'In-Person',
// location: 'HO',
// participants: [headUser.id, nbhUser.id]
// }, leadToken);
// const interviewId3 = intv3Response.data.id;
const intv3Response = await apiRequest('/assessment/interviews', 'POST', {
applicationId: applicationUUID,
level: 3,
scheduledAt: new Date(Date.now() + 259200000).toISOString(),
type: 'In-Person',
location: 'HO',
participants: [headUser.id, nbhUser.id]
}, leadToken);
const interviewId3 = intv3Response.data.id;
// log(6.1, 'NBH Giving Feedback...');
// const nbhToken = await login(EMAILS.NBH);
// await apiRequest('/assessment/level2-feedback', 'POST', {
// interviewId: interviewId3,
// overallScore: 10,
// feedbackItems: [
// { type: 'Business Vision & Strategy', comments: 'Highly recommended for this market.' },
// { type: 'Leadership & Decision Making', comments: 'Shows great potential.' }
// ],
// recommendation: 'Selected'
// }, nbhToken);
log(6.1, 'NBH Giving Feedback...');
const nbhToken = await login(EMAILS.NBH);
await apiRequest('/assessment/level2-feedback', 'POST', {
interviewId: interviewId3,
overallScore: 10,
feedbackItems: [
{ type: 'Business Vision & Strategy', comments: 'Highly recommended for this market.' },
{ type: 'Leadership & Decision Making', comments: 'Shows great potential.' }
],
recommendation: 'Selected'
}, nbhToken);
// log(6.15, 'DD-Head Giving Feedback...');
// const headToken = await login(EMAILS.DD_HEAD);
// await apiRequest('/assessment/level2-feedback', 'POST', {
// interviewId: interviewId3,
// overallScore: 9.5,
// feedbackItems: [
// { type: 'Operational & Financial Readiness', comments: 'Financially sound.' },
// { type: 'Brand Alignment', comments: 'Understands Royal Enfield ethos perfectly.' }
// ],
// recommendation: 'Selected'
// }, headToken);
log(6.15, 'DD-Head Giving Feedback...');
const headToken = await login(EMAILS.DD_HEAD);
await apiRequest('/assessment/level2-feedback', 'POST', {
interviewId: interviewId3,
overallScore: 9.5,
feedbackItems: [
{ type: 'Operational & Financial Readiness', comments: 'Financially sound.' },
{ type: 'Brand Alignment', comments: 'Understands Royal Enfield ethos perfectly.' }
],
recommendation: 'Selected'
}, headToken);
// log(6.2, 'Head Finalizing Level 3 Decision...');
// await apiRequest('/assessment/decision', 'POST', {
// interviewId: interviewId3,
// decision: 'Approved',
// remarks: 'Cleared Level 3. Moving to FDD.'
// }, headToken);
// log(6, 'Level 3 Complete. Stage is now FDD Verification.');
// await delay();
log(6.2, 'Head Finalizing Level 3 Decision...');
await apiRequest('/assessment/decision', 'POST', {
interviewId: interviewId3,
decision: 'Approved',
remarks: 'Cleared Level 3. Moving to FDD.'
}, headToken);
log(6, 'Level 3 Complete. Stage is now FDD Verification.');
await delay();
// // 6.3 FDD ASSIGNMENT
// log(6.3, 'Admin Assigning Application to FDD Agency...');
// const fddUser = users.data.find(u => u.email === EMAILS.FDD);
// await apiRequest('/fdd/assign', 'POST', {
// applicationId: applicationUUID,
// assignedToAgency: fddUser.id
// }, adminToken);
// log(6.3, 'FDD Agency assigned successfully.');
// await delay();
// 6.3 FDD ASSIGNMENT
log(6.3, 'Admin Assigning Application to FDD Agency...');
const fddUser = users.data.find(u => u.email === EMAILS.FDD);
await apiRequest('/fdd/assign', 'POST', {
applicationId: applicationUUID,
assignedToAgency: fddUser.id
}, adminToken);
log(6.3, 'FDD Agency assigned successfully.');
await delay();
// // 7. FDD MILESTONE
// log(7, 'FDD Agency Discovery & Report Upload...');
// const fddToken = await login(EMAILS.FDD);
// 7. FDD MILESTONE
log(7, 'FDD Agency Discovery & Report Upload...');
const fddToken = await login(EMAILS.FDD);
// // FETCH ASSIGNMENT ID
// const assignmentRes = await apiRequest(`/fdd/${applicationUUID}`, 'GET', null, fddToken);
// const assignmentId = assignmentRes.data.id;
// log(7, `Found Assignment ID: ${assignmentId}`);
// FETCH ASSIGNMENT ID
const assignmentRes = await apiRequest(`/fdd/${applicationUUID}`, 'GET', null, fddToken);
const assignmentId = assignmentRes.data.id;
log(7, `Found Assignment ID: ${assignmentId}`);
// await apiRequest('/fdd/report', 'POST', {
// assignmentId,
// findings: 'Finance records clean.',
// recommendation: 'Approved'
// }, fddToken);
await apiRequest('/fdd/report', 'POST', {
assignmentId,
findings: 'Finance records clean.',
recommendation: 'Approved'
}, fddToken);
// log(7.1, 'Admin Approving FDD Final Stage...');
// await apiRequest('/assessment/stage-decision', 'POST', {
// applicationId: applicationUUID,
// stageCode: 'FDD_VERIFICATION',
// decision: 'Approved',
// remarks: 'FDD documents verified.'
// }, adminToken);
// log(7, 'FDD Milestone Complete.');
// await delay();
log(7.1, 'Admin Approving FDD Final Stage...');
await apiRequest('/assessment/stage-decision', 'POST', {
applicationId: applicationUUID,
stageCode: 'FDD_VERIFICATION',
decision: 'Approved',
remarks: 'FDD documents verified.'
}, adminToken);
log(7, 'FDD Milestone Complete.');
await delay();
// log(7.4, 'Uploading mandatory documents prior to LOI generation...');
// const requiredDocs = ['CIBIL Report', 'Proposed Site City Map', 'Bank Statement', 'GST Certificate', 'PAN Card'];
// for (const doc of requiredDocs) {
// await mockUploadDocument(applicationUUID, adminToken, doc);
// }
// await delay(1000);
log(7.4, 'Uploading mandatory documents prior to LOI generation...');
const requiredDocs = ['CIBIL Report', 'Proposed Site City Map', 'Bank Statement', 'GST Certificate', 'PAN Card'];
for (const doc of requiredDocs) {
await mockUploadDocument(applicationUUID, adminToken, doc);
}
await delay(1000);
// // 7.5 LOI APPROVAL
// log(7.5, 'LOI Generation & Approval...');