after the 3rd demo fe points covered like templates improvd ui enahanced to match original theme check revokemiddlware added stagwe transistion even after one rejection flow added

This commit is contained in:
laxman h 2026-04-27 19:10:04 +05:30
parent 5b501ede6c
commit 4037f68745
23 changed files with 768 additions and 207 deletions

View File

@ -18,7 +18,9 @@
"seed:email-templates": "tsx src/scripts/seed-master-emails.ts",
"seed:configs": "tsx scripts/seed-system-configs.ts",
"seed:document-configs": "tsx scripts/seed-document-configs.ts",
"seed:all": "npm run seed:permissions && npm run seed && npm run seed:approval-policies && npm run seed:questionnaire && npm run seed:email-templates && npm run seed:configs && npm run seed:document-configs",
"seed:interview-configs": "tsx scripts/seed-interview-configs.ts",
"seed:sla-configs": "tsx scripts/seed-sla-configs.ts",
"seed:all": "npm run seed:permissions && npm run seed && npm run seed:approval-policies && npm run seed:questionnaire && npm run seed:email-templates && npm run seed:configs && npm run seed:document-configs && npm run seed:interview-configs && npm run seed:sla-configs",
"setup:fresh": "npm run migrate && npm run seed:real-geo && npm run seed:all && npm run sync:hierarchy",
"seed:real-geo": "tsx scripts/seed_real_locations.ts && npm run sync:hierarchy",
"seed:state-district": "tsx scripts/seed-state-district-only.ts",

120
scripts/seed-sla-configs.ts Normal file
View File

@ -0,0 +1,120 @@
import 'dotenv/config';
import db from '../src/database/models/index.js';
type SlaDefault = {
stage: string;
role: string;
tat: number;
unit: 'hours' | 'days';
};
const defaults: SlaDefault[] = [
// ONBOARDING
{ stage: 'ASM Review', role: 'ASM', tat: 2, unit: 'days' },
{ stage: 'ZM Review', role: 'DD-ZM', tat: 3, unit: 'days' },
{ stage: 'Level 1 Interview', role: 'RBM, DD-ZM', tat: 2, unit: 'days' },
{ stage: 'Level 2 Interview', role: 'DD-Lead, ZBH', tat: 3, unit: 'days' },
{ stage: 'Level 3 Interview', role: 'NBH, DD-Head', tat: 5, unit: 'days' },
{ stage: 'FDD Verification', role: 'FDD', tat: 10, unit: 'days' },
{ stage: 'Finance Verification', role: 'Finance', tat: 7, unit: 'days' },
{ stage: 'LOI Approval', role: 'NBH', tat: 5, unit: 'days' },
{ stage: 'LOA Approval', role: 'NBH', tat: 5, unit: 'days' },
// RESIGNATION
{ stage: 'Resignation ASM Review', role: 'ASM', tat: 2, unit: 'days' },
{ stage: 'Resignation Evaluation', role: 'RBM, DD-ZM', tat: 3, unit: 'days' },
{ stage: 'Resignation ZBH Review', role: 'ZBH', tat: 3, unit: 'days' },
{ stage: 'Resignation Lead Review', role: 'DD-Lead', tat: 5, unit: 'days' },
{ stage: 'Resignation NBH Approval', role: 'NBH', tat: 5, unit: 'days' },
{ stage: 'Resignation Legal Letter', role: 'Legal', tat: 7, unit: 'days' },
// TERMINATION
{ stage: 'Termination ASM Review', role: 'ASM', tat: 2, unit: 'days' },
{ stage: 'Termination Evaluation', role: 'RBM, DD-ZM', tat: 3, unit: 'days' },
{ stage: 'Termination ZBH Review', role: 'ZBH', tat: 3, unit: 'days' },
{ stage: 'Termination Lead Review', role: 'DD-Lead', tat: 5, unit: 'days' },
{ stage: 'Termination Legal Verification', role: 'Legal', tat: 7, unit: 'days' },
{ stage: 'Termination NBH Evaluation', role: 'NBH', tat: 5, unit: 'days' },
{ stage: 'Termination CEO Approval', role: 'CEO', tat: 7, unit: 'days' },
// RELOCATION
{ stage: 'Relocation ASM Review', role: 'ASM', tat: 2, unit: 'days' },
{ stage: 'Relocation ZM Review', role: 'DD-ZM', tat: 3, unit: 'days' },
{ stage: 'Relocation RBM Review', role: 'RBM', tat: 3, unit: 'days' },
{ stage: 'Relocation ZBH Review', role: 'ZBH', tat: 3, unit: 'days' },
{ stage: 'Relocation Lead Review', role: 'DD-Lead', tat: 5, unit: 'days' },
// CONSTITUTIONAL CHANGE
{ stage: 'Constitution Legal Review', role: 'Legal', tat: 7, unit: 'days' },
{ stage: 'Constitution Lead Review', role: 'DD-Lead', tat: 5, unit: 'days' },
{ stage: 'Constitution NBH Approval', role: 'NBH', tat: 5, unit: 'days' },
];
async function seedSlaConfigs() {
const { sequelize, SLAConfiguration, SLAReminder, SLAEscalationConfig } = db as any;
await sequelize.authenticate();
console.log('Database connected.');
const transaction = await sequelize.transaction();
try {
for (const item of defaults) {
const [config, created] = await SLAConfiguration.findOrCreate({
where: { activityName: item.stage },
defaults: {
activityName: item.stage,
ownerRole: item.role,
tatHours: item.tat,
tatUnit: item.unit,
isActive: true,
},
transaction,
});
if (!created) {
await config.update(
{
ownerRole: item.role,
tatHours: item.tat,
tatUnit: item.unit,
},
{ transaction }
);
}
await SLAReminder.destroy({ where: { slaConfigId: config.id }, transaction });
await SLAEscalationConfig.destroy({ where: { slaConfigId: config.id }, transaction });
await SLAReminder.bulkCreate(
[
{ slaConfigId: config.id, timeValue: 1, timeUnit: 'days', isEnabled: true },
{ slaConfigId: config.id, timeValue: 4, timeUnit: 'hours', isEnabled: true },
],
{ transaction }
);
await SLAEscalationConfig.bulkCreate(
[
{ slaConfigId: config.id, level: 1, timeValue: 4, timeUnit: 'hours', notifyRole: 'ZBH' },
{ slaConfigId: config.id, level: 2, timeValue: 12, timeUnit: 'hours', notifyRole: 'DD Lead' },
{ slaConfigId: config.id, level: 3, timeValue: 24, timeUnit: 'hours', notifyRole: 'NBH' },
],
{ transaction }
);
}
await transaction.commit();
console.log(`SLA configurations seeded successfully. Total stages: ${defaults.length}`);
} catch (error) {
await transaction.rollback();
console.error('SLA seed failed:', error);
throw error;
} finally {
await sequelize.close();
}
}
seedSlaConfigs().catch((err) => {
console.error(err);
process.exit(1);
});

View File

@ -12,9 +12,15 @@ export const checkRevocation = async (req: AuthRequest, res: Response, next: Nex
const userId = req.user?.id;
if (!userId) return next();
// Try to identify requestId and requestType from various sources
const requestId = req.params.requestId || req.body.requestId || req.query.requestId;
const requestType = req.params.requestType || req.body.requestType || req.query.requestType;
// BYPASS: Super Admin and other National administrative roles should always have access
const nationalRoles = ['NBH', 'DD Head', 'DD Lead', 'Finance', 'DD Admin', 'Legal Admin', 'Super Admin', 'Admin', 'CEO', 'CCO'];
if (nationalRoles.includes(req.user?.roleCode || '')) {
return next();
}
// Try to identify requestId and requestType from various sources with safe navigation
const requestId = req.params?.requestId || req.params?.id || req.body?.requestId || req.query?.requestId;
const requestType = req.params?.requestType || req.body?.requestType || req.query?.requestType || 'application';
// If we can't identify the request context, we can't check revocation here
if (!requestId) return next();

View File

@ -43,6 +43,17 @@ export const sendEmail = async (to: string, subject: string, templateCode: strin
try {
let finalHtml = '';
let finalSubject = subject;
const ensureHeaderFooter = (html: string) => {
const hasHeader = /class=["']header["']/i.test(html);
const hasFooter = /class=["']footer["']/i.test(html);
if (hasHeader && hasFooter) return html;
const wrapper = handlebars.compile(`{{> email_header}}\n{{{body}}}\n{{> email_footer}}`);
return wrapper({
...replacements,
year: new Date().getFullYear().toString(),
body: html
});
};
// Try fetching from DB first (Master Configuration)
const dbTemplate = await EmailTemplate.findOne({ where: { templateCode, isActive: true } });
@ -59,7 +70,7 @@ export const sendEmail = async (to: string, subject: string, templateCode: strin
finalSubject = subjectTemplate(allReplacements);
const bodyTemplate = handlebars.compile(dbTemplate.body);
finalHtml = bodyTemplate(allReplacements);
finalHtml = ensureHeaderFooter(bodyTemplate(allReplacements));
} else {
registerEmailPartials(handlebars);
const allReplacements = normalizeCtaPlaceholders({
@ -81,7 +92,7 @@ export const sendEmail = async (to: string, subject: string, templateCode: strin
const source = fs.readFileSync(templatePath, 'utf-8');
const bodyTemplate = handlebars.compile(source);
finalHtml = bodyTemplate(allReplacements);
finalHtml = ensureHeaderFooter(bodyTemplate(allReplacements));
}
const readyTransporter = await initTransporter();

View File

@ -163,37 +163,46 @@ export const syncApplicationProgress = async (applicationId: string, overallStat
// Fetch application to check model-driven parallel status
const application = await db.Application.findByPk(applicationId);
// Robust Sync: Iterate through ALL stages and align with logic
for (const stage of ONBOARDING_STAGES) {
let status: 'pending' | 'active' | 'completed' = 'pending';
let percentage = 0;
// Robust Sync: Prepare ALL stages for batch processing
const upsertData = [];
for (const stage of ONBOARDING_STAGES) {
let status: 'pending' | 'active' | 'completed' = 'pending';
let percentage = 0;
if (stage.order < currentStage.order) {
status = 'completed';
percentage = 100;
} else if (stage.order === currentStage.order) {
// Logic for the current status order (could contain parallel stages)
status = isCurrentStageFinished ? 'completed' : 'active';
percentage = isCurrentStageFinished ? 100 : 50;
// OVERRIDE for Parallel Tracks (Architecture/Statutory)
if (stage.name === 'Architecture Work' && application) {
status = application.architectureStatus === 'COMPLETED' ? 'completed' :
(application.architectureStatus === 'IN_PROGRESS' || currentStage.name === 'Architecture Work' || isCurrentStageFinished) ? 'active' : 'pending';
percentage = status === 'completed' ? 100 : status === 'active' ? 50 : 0;
}
if (stage.name === 'Statutory Work' && application) {
status = application.statutoryStatus === 'COMPLETED' ? 'completed' :
(application.statutoryStatus === 'IN_PROGRESS' || currentStage.name === 'Statutory Work' || isCurrentStageFinished) ? 'active' : 'pending';
percentage = status === 'completed' ? 100 : status === 'active' ? 50 : 0;
}
} else {
status = 'pending';
percentage = 0;
}
await updateApplicationProgress(applicationId, stage.name, status, percentage);
if (stage.order < currentStage.order) {
status = 'completed';
percentage = 100;
} else if (stage.order === currentStage.order) {
status = isCurrentStageFinished ? 'completed' : 'active';
percentage = isCurrentStageFinished ? 100 : 50;
if (stage.name === 'Architecture Work' && application) {
status = application.architectureStatus === 'COMPLETED' ? 'completed' :
(application.architectureStatus === 'IN_PROGRESS' || currentStage.name === 'Architecture Work' || isCurrentStageFinished) ? 'active' : 'pending';
percentage = status === 'completed' ? 100 : status === 'active' ? 50 : 0;
}
if (stage.name === 'Statutory Work' && application) {
status = application.statutoryStatus === 'COMPLETED' ? 'completed' :
(application.statutoryStatus === 'IN_PROGRESS' || currentStage.name === 'Statutory Work' || isCurrentStageFinished) ? 'active' : 'pending';
percentage = status === 'completed' ? 100 : status === 'active' ? 50 : 0;
}
}
upsertData.push({
applicationId,
stageName: stage.name,
stageOrder: stage.order,
status,
completionPercentage: percentage,
stageStartedAt: (status === 'active' || status === 'completed') ? new Date() : null,
stageCompletedAt: status === 'completed' ? new Date() : null
});
}
// Use bulkCreate with updateOnDuplicate to perform an efficient batch upsert
await ApplicationProgress.bulkCreate(upsertData, {
updateOnDuplicate: ['status', 'completionPercentage', 'stageStartedAt', 'stageCompletedAt']
});
}
}
};

View File

@ -160,13 +160,24 @@ export const EmailTemplateController = {
});
try {
const ensureHeaderFooter = (html: string) => {
const hasHeader = /class=["']header["']/i.test(html);
const hasFooter = /class=["']footer["']/i.test(html);
if (hasHeader && hasFooter) return html;
const wrapper = handlebars.compile(`{{> email_header}}\n{{{body}}}\n{{> email_footer}}`);
return wrapper({
...safeData,
body: html
});
};
if (subject) {
const subjectTemplate = handlebars.compile(safeSubject);
compiledSubject = subjectTemplate(safeData);
}
const bodyTemplate = handlebars.compile(safeBody);
compiledBody = bodyTemplate(safeData);
compiledBody = ensureHeaderFooter(bodyTemplate(safeData));
res.json({
success: true,

View File

@ -146,6 +146,7 @@ export default (sequelize: Sequelize) => {
User.hasMany(models.AuditLog, { foreignKey: 'userId', as: 'auditLogs' });
User.belongsTo(models.Dealer, { foreignKey: 'dealerId', as: 'dealerProfile' });
User.hasMany(models.Dealer, { foreignKey: 'asmId', as: 'assignedDealers' });
User.hasMany(models.Outlet, { foreignKey: 'dealerId', as: 'outlets' });
};

View File

@ -19,6 +19,7 @@ export interface DealerAttributes {
securityDepositDate: Date | null;
lastWorkingDay: Date | null;
exitReason: string | null;
asmId: string | null;
}
export interface DealerInstance extends Model<DealerAttributes>, DealerAttributes { }
@ -105,6 +106,14 @@ export default (sequelize: Sequelize) => {
exitReason: {
type: DataTypes.TEXT,
allowNull: true
},
asmId: {
type: DataTypes.UUID,
allowNull: true,
references: {
model: 'users',
key: 'id'
}
}
}, {
tableName: 'dealers',
@ -131,6 +140,7 @@ export default (sequelize: Sequelize) => {
if (User) {
Dealer.hasOne(User, { foreignKey: 'dealerId', as: 'user' });
Dealer.belongsTo(User, { foreignKey: 'asmId', as: 'asmManager' });
}
Dealer.hasMany(models.DealerBankDetail, { foreignKey: 'dealerId', as: 'bankDetails' });

View File

@ -177,6 +177,7 @@ export const getAllUsers = async (req: Request, res: Response) => {
try {
const { roleCode, locationId, search, page = 1, limit = 100, isExternal } = req.query as any;
const whereClause: any = {};
const andConditions: any[] = [];
// 0. External filter
if (isExternal !== undefined) {
@ -185,11 +186,13 @@ export const getAllUsers = async (req: Request, res: Response) => {
// 1. Search filter
if (search) {
whereClause[Op.or] = [
andConditions.push({
[Op.or]: [
{ fullName: { [Op.iLike]: `%${search}%` } },
{ email: { [Op.iLike]: `%${search}%` } },
{ employeeId: { [Op.iLike]: `%${search}%` } }
];
]
});
}
// 2. Role filter
@ -228,7 +231,8 @@ export const getAllUsers = async (req: Request, res: Response) => {
if (district) {
const relevantIds = [district.id, district.zoneId, district.regionId, district.stateId].filter(Boolean);
whereClause[Op.or] = [
andConditions.push({
[Op.or]: [
{ districtId: { [Op.in]: relevantIds } },
{ zoneId: { [Op.in]: relevantIds } },
{ regionId: { [Op.in]: relevantIds } },
@ -236,15 +240,21 @@ export const getAllUsers = async (req: Request, res: Response) => {
{ '$userRoles.districtId$': { [Op.in]: relevantIds } },
{ '$userRoles.zoneId$': { [Op.in]: relevantIds } },
{ '$userRoles.regionId$': { [Op.in]: relevantIds } }
];
]
});
}
}
if (andConditions.length > 0) {
whereClause[Op.and] = andConditions;
}
const { count, rows: users } = await User.findAndCountAll({
where: whereClause,
attributes: { exclude: ['password'] },
limit: Number(limit),
offset: (Number(page) - 1) * Number(limit),
subQuery: false,
include: [
{
model: Role,

View File

@ -7,7 +7,7 @@ const {
import { AuthRequest } from '../../types/express.types.js';
import { Op } from 'sequelize';
import * as EmailService from '../../common/utils/email.service.js';
import { APPLICATION_STAGES, APPLICATION_STATUS } from '../../common/config/constants.js';
import { APPLICATION_STAGES, APPLICATION_STATUS, AUDIT_ACTIONS } from '../../common/config/constants.js';
import { WorkflowService } from '../../services/WorkflowService.js';
import { syncApplicationProgress } from '../../common/utils/progress.js';
import { NotificationService } from '../../services/NotificationService.js';
@ -20,6 +20,12 @@ const getLocationAncestors = async (locationId: string): Promise<string[]> => {
const interviewStageCode = (level: number) => `INTERVIEW_LEVEL_${level}`;
const normalizeRoleCode = (value: unknown) =>
String(value || '')
.trim()
.toLowerCase()
.replace(/[_\s-]+/g, ' ');
const getDefaultInterviewPolicy = (level: number) => {
const defaults: Record<number, { requiredRoles: string[]; minApprovals: number }> = {
1: { requiredRoles: ['DD-ZM', 'RBM'], minApprovals: 2 },
@ -45,6 +51,11 @@ const ensureInterviewPolicy = async (level: number) => {
return policy;
};
const getRequiredRolesForInterviewLevel = (level: number): string[] => {
const defaults = getDefaultInterviewPolicy(level);
return Array.isArray(defaults.requiredRoles) ? defaults.requiredRoles : [];
};
const processStageDecision = async (params: {
applicationId: string;
stageCode: string;
@ -74,6 +85,63 @@ const processStageDecision = async (params: {
const requiredRoles: string[] = Array.isArray(policy.requiredRoles) ? policy.requiredRoles : [];
// Strict interview gating: only designated evaluators for this interview can submit decision.
if (interviewId && roleCode !== 'Super Admin') {
const normalizedRequiredRoles = requiredRoles.map(normalizeRoleCode);
const normalizedActorRole = normalizeRoleCode(roleCode);
const evaluatorParticipant = await InterviewParticipant.findOne({
where: {
interviewId,
userId,
roleInPanel: 'Evaluator'
}
});
const panelParticipant = await InterviewParticipant.findOne({
where: {
interviewId,
userId
}
});
const interviewRow: any = await Interview.findByPk(interviewId, { attributes: ['applicationId', 'level'] });
const mappedApprover = interviewRow
? await RequestParticipant.findOne({
where: {
requestId: interviewRow.applicationId,
requestType: 'application',
userId
}
})
: null;
const mappedAsStageApprover = Boolean(
mappedApprover && (
mappedApprover.metadata?.interviewLevel === interviewRow?.level ||
mappedApprover.metadata?.interviewLevel === String(interviewRow?.level) ||
mappedApprover.metadata?.allAssignments?.includes(interviewRow?.level) ||
mappedApprover.metadata?.allAssignments?.includes(String(interviewRow?.level))
)
);
if (!evaluatorParticipant) {
const roleEligiblePanelist = Boolean(
panelParticipant && normalizedRequiredRoles.includes(normalizedActorRole)
);
if (mappedAsStageApprover || roleEligiblePanelist) {
// Auto-heal historic/scheduling mismatch: elevate mapped stage approver to evaluator.
await InterviewParticipant.update(
{ roleInPanel: 'Evaluator' },
{ where: { interviewId, userId } }
);
} else {
return {
forbidden: true,
policy,
requiredRoles,
currentRole: roleCode,
message: 'Only designated stage evaluators can submit interview feedback/decision. Additional panelists are view participants.'
};
}
}
}
// Check if user is an assigned participant
const userAssignments = await db.RequestParticipant.findAll({
where: { requestId: resolvedId, requestType: 'application', userId }
@ -198,10 +266,13 @@ const processStageDecision = async (params: {
// Evaluate Policy via Centralized Service (FIXED unique user count)
const evaluation = await WorkflowService.evaluateStagePolicy(resolvedId, stageCode);
const hasRejection = decision === 'Rejected'; // Immediate rejection if ANY required actor rejects (business rule)
const hasRejection = decision === 'Rejected';
const hasAnyApproval = (evaluation.approvedCount || 0) > 0;
let statusUpdated = false;
if (hasRejection) {
// Do NOT immediately reject on a single rejection.
// Move forward when policy is met and at least one approver approved.
if (evaluation.policyMet && !hasAnyApproval) {
const application = await db.Application.findByPk(resolvedId);
if (application) {
await WorkflowService.transitionApplication(application, APPLICATION_STATUS.REJECTED, userId, {
@ -210,7 +281,7 @@ const processStageDecision = async (params: {
});
statusUpdated = true;
}
} else if (evaluation.policyMet) {
} else if (evaluation.policyMet && hasAnyApproval) {
const application = await db.Application.findByPk(resolvedId);
if (application) {
let targetStatus = nextStatus;
@ -277,12 +348,14 @@ const processStageDecision = async (params: {
return {
success: true,
message: hasRejection ? 'Rejected' : statusUpdated ? 'Policy satisfied. Stage complete.' : 'Approval recorded.',
message: statusUpdated
? (evaluation.policyMet && hasAnyApproval ? 'Policy satisfied. Stage complete.' : 'Rejected')
: (hasRejection ? 'Rejection recorded. Waiting for remaining approvers.' : 'Approval recorded.'),
policy,
requiredRoles: evaluation.policy.requiredRoles,
uniqueApprovalsByRole: evaluation.approvedRoles,
hasAllRequiredRoleApprovals: evaluation.hasAllRequiredRoleApprovals,
meetsMinApprovals: evaluation.meetsMinApprovals,
hasAllRequiredRoleApprovals: evaluation.roleConditionMet,
meetsMinApprovals: evaluation.meetsMinCount,
statusUpdated
};
};
@ -435,6 +508,12 @@ export const scheduleInterview = async (req: AuthRequest, res: Response) => {
if (!application) return res.status(404).json({ success: false, message: 'Application not found' });
const scheduledDateObj = new Date(scheduledAt);
if (Number.isNaN(scheduledDateObj.getTime())) {
return res.status(400).json({ success: false, message: 'Invalid scheduledAt value. Please provide a valid ISO datetime.' });
}
const scheduledAtIso = scheduledDateObj.toISOString();
// Prevent duplicate interviews for the same level
const existingInterview = await Interview.findOne({
where: {
@ -455,7 +534,7 @@ export const scheduleInterview = async (req: AuthRequest, res: Response) => {
const interview = await Interview.create({
applicationId: application.id,
level: levelNum || 1, // Default to 1 if parsing fails
scheduleDate: new Date(scheduledAt),
scheduleDate: scheduledDateObj,
interviewType: type,
linkOrLocation: location,
status: 'Scheduled',
@ -470,7 +549,7 @@ export const scheduleInterview = async (req: AuthRequest, res: Response) => {
// 1. Google Calendar Mock
const { meetLink } = await ExternalMocksService.mockScheduleMeeting({
type,
scheduledAt,
scheduledAt: scheduledAtIso,
applicationId
});
await interview.update({ linkOrLocation: meetLink });
@ -488,7 +567,7 @@ export const scheduleInterview = async (req: AuthRequest, res: Response) => {
applicantName: application.applicantName,
applicationId: application.applicationId,
type,
scheduledAt,
scheduledAt: scheduledAtIso,
link: meetLink,
phone: applicantPhone,
ctaLabel: 'View Schedule'
@ -512,6 +591,41 @@ export const scheduleInterview = async (req: AuthRequest, res: Response) => {
}
participantIds = [...new Set(participantIds)];
// Determine designated evaluators for this interview level.
// Rule: exactly one user per required role is treated as evaluator; others are panelists.
const requiredRolesForLevel = getRequiredRolesForInterviewLevel(levelNum || 1).map(normalizeRoleCode);
const selectedUsers = participantIds.length > 0
? await User.findAll({ where: { id: { [Op.in]: participantIds } }, attributes: ['id', 'roleCode'] })
: [];
const selectedUserRoleMap = new Map(selectedUsers.map((u: any) => [u.id, normalizeRoleCode(u.roleCode)]));
const evaluatorIds = new Set<string>();
// Prefer users that are already stage-mapped for this level on RequestParticipant metadata.
const stageMappedParticipants = await RequestParticipant.findAll({
where: {
requestId: application.id,
requestType: 'application'
},
attributes: ['userId', 'metadata']
});
for (const rp of stageMappedParticipants as any[]) {
const m = rp.metadata || {};
const mappedForLevel =
m.interviewLevel === (levelNum || 1) ||
m.interviewLevel === String(levelNum || 1) ||
m.allAssignments?.includes(levelNum || 1) ||
m.allAssignments?.includes(String(levelNum || 1));
if (mappedForLevel && participantIds.includes(rp.userId)) {
evaluatorIds.add(rp.userId);
}
}
for (const requiredRole of requiredRolesForLevel) {
if ([...evaluatorIds].some((id) => selectedUserRoleMap.get(id) === requiredRole)) continue;
const match = participantIds.find((id) => selectedUserRoleMap.get(id) === requiredRole);
if (match) evaluatorIds.add(match);
}
if (participantIds.length > 0) {
console.log(`Processing ${participantIds.length} participants...`);
// Processing participants concurrently
@ -520,14 +634,14 @@ export const scheduleInterview = async (req: AuthRequest, res: Response) => {
await InterviewParticipant.create({
interviewId: interview.id,
userId,
role: 'Panelist'
roleInPanel: evaluatorIds.has(userId) ? 'Evaluator' : 'Panelist'
});
// 2. Add as Request Participant for Collaboration
console.log(`Adding user ${userId} to RequestParticipant...`);
await RequestParticipant.findOrCreate({
where: {
requestId: applicationId,
requestId: application.id,
requestType: 'application',
userId
},
@ -550,18 +664,7 @@ export const scheduleInterview = async (req: AuthRequest, res: Response) => {
reason: `Interview Level ${levelNum} Scheduled`
});
// 3. User & Stakeholder Notifications (SRS §6.14.3)
if (application) {
notificationPromises.push(
EmailService.sendInterviewScheduledEmail(
application.email,
application.applicantName,
application.applicationId || application.id,
interview
).catch(err => console.error('Failed to send applicant email:', err))
);
}
// 3. Panelist notifications (Applicant is already notified above with INTERVIEW_SCHEDULED_APPLICANT)
if (participantIds.length > 0) {
for (const userId of participantIds) {
notificationPromises.push(
@ -579,7 +682,7 @@ export const scheduleInterview = async (req: AuthRequest, res: Response) => {
applicantName: application?.applicantName || 'Applicant',
applicationId: application?.applicationId || '',
type,
scheduledAt,
scheduledAt: scheduledAtIso,
link: meetLink,
phone: pPhone || '',
ctaLabel: 'Open Assessment'
@ -597,6 +700,21 @@ export const scheduleInterview = async (req: AuthRequest, res: Response) => {
// However, Promise.all already makes it much faster than sequential.
await Promise.all(notificationPromises);
await db.AuditLog.create({
userId: req.user?.id || null,
action: AUDIT_ACTIONS.INTERVIEW_SCHEDULED,
entityType: 'application',
entityId: application.id,
newData: {
interviewId: interview.id,
interviewLevel: levelNum || 1,
interviewType: type,
scheduledAt: scheduledAtIso,
linkOrLocation: meetLink || location || null,
participantCount: participantIds.length
}
});
console.log('Interview scheduling completed successfully.');
res.status(201).json({ success: true, message: 'Interview scheduled successfully', data: interview });
} catch (error) {
@ -615,7 +733,48 @@ export const updateInterview = async (req: AuthRequest, res: Response) => {
const interview = await Interview.findByPk(id);
if (!interview) return res.status(404).json({ success: false, message: 'Interview not found' });
await interview.update({ status, scheduledAt, outcome });
const oldStatus = interview.status;
const oldScheduleDate = interview.scheduleDate;
const oldOutcome = (interview as any).outcome ?? null;
const updatePayload: any = {};
if (typeof status !== 'undefined') updatePayload.status = status;
if (typeof outcome !== 'undefined') updatePayload.outcome = outcome;
if (typeof scheduledAt !== 'undefined') {
const parsed = scheduledAt ? new Date(scheduledAt) : null;
if (scheduledAt && parsed && Number.isNaN(parsed.getTime())) {
return res.status(400).json({ success: false, message: 'Invalid scheduledAt value. Please provide a valid ISO datetime.' });
}
updatePayload.scheduleDate = parsed;
}
await interview.update(updatePayload);
const isCancelled = String(status || '').toLowerCase() === 'cancelled' && String(oldStatus || '').toLowerCase() !== 'cancelled';
const isRescheduled = typeof scheduledAt !== 'undefined' && String(status || '').toLowerCase() !== 'cancelled';
const eventType = isCancelled ? 'interview_cancelled' : (isRescheduled ? 'interview_rescheduled' : 'interview_updated');
await db.AuditLog.create({
userId: req.user?.id || null,
action: AUDIT_ACTIONS.INTERVIEW_UPDATED,
entityType: 'application',
entityId: interview.applicationId,
oldData: {
interviewId: interview.id,
interviewLevel: interview.level,
status: oldStatus,
scheduleDate: oldScheduleDate,
outcome: oldOutcome
},
newData: {
interviewId: interview.id,
interviewLevel: interview.level,
eventType,
status: interview.status,
scheduleDate: interview.scheduleDate,
outcome: (interview as any).outcome ?? null
}
});
res.json({ success: true, message: 'Interview updated successfully' });
} catch (error) {

View File

@ -139,6 +139,30 @@ function buildFriendlyApplicationUpdatedDescription(logData: any, payload: any):
return parts.join(' · ');
}
function buildFriendlyInterviewUpdatedDescription(logData: any, payload: any): string {
const eventType = String(payload?.eventType || '').toLowerCase();
const oldData = logData?.oldData || {};
const oldSchedule = oldData?.scheduleDate ? new Date(oldData.scheduleDate).toLocaleString() : null;
const newSchedule = payload?.scheduleDate ? new Date(payload.scheduleDate).toLocaleString() : null;
const level = payload?.interviewLevel ? `Level ${payload.interviewLevel}` : 'Interview';
if (eventType === 'interview_cancelled') {
return `${level} interview cancelled`;
}
if (eventType === 'interview_rescheduled') {
if (oldSchedule && newSchedule && oldSchedule !== newSchedule) {
return `${level} interview rescheduled from ${oldSchedule} to ${newSchedule}`;
}
if (newSchedule) {
return `${level} interview rescheduled to ${newSchedule}`;
}
return `${level} interview rescheduled`;
}
return `${level} interview updated`;
}
const getNormalizedAuditPayload = (logData: any, entityType: string, entityId: string) => {
const payload = logData.details || logData.newData || {};
const actorName = logData.user?.fullName || logData.userName || 'System';
@ -150,6 +174,8 @@ const getNormalizedAuditPayload = (logData: any, entityType: string, entityId: s
if (et === 'application' && action === 'UPDATED') {
description = buildFriendlyApplicationUpdatedDescription(logData, payload);
} else if (et === 'application' && action === 'INTERVIEW_UPDATED') {
description = buildFriendlyInterviewUpdatedDescription(logData, payload);
} else {
if (payload?.stage) description += ` - Stage: ${payload.stage}`;
else if (payload?.department) description += ` - ${payload.department}`;

View File

@ -26,6 +26,7 @@ export const getDealers = async (req: Request, res: Response) => {
include: [
{ model: DealerCode, as: 'dealerCode' },
{ model: Application, as: 'application', attributes: ['city', 'state', 'preferredLocation'] },
{ model: User, as: 'asmManager', attributes: ['id', 'fullName', 'email', 'employeeId'], required: false },
{
model: User,
as: 'user',

View File

@ -888,7 +888,7 @@ export const getASMs = async (req: Request, res: Response) => {
try {
const asms = await User.findAll({
where: {
roleCode: { [db.Sequelize.Op.in]: ['ASM', 'AREA SALES MANAGER', 'DD-AM', ROLES.DD_AM] },
roleCode: { [db.Sequelize.Op.in]: ['DD-AM', ROLES.DD_AM] },
isActive: true
},
include: [
@ -896,15 +896,7 @@ export const getASMs = async (req: Request, res: Response) => {
association: 'userRoles',
where: { isActive: true },
required: false,
include: [{ association: 'role', where: { roleCode: { [db.Sequelize.Op.in]: ['ASM', 'DD-AM', ROLES.DD_AM] } } }]
},
{
association: 'managedAsmDistricts',
include: [
{ association: 'state', attributes: ['id', 'name'] },
{ association: 'region', attributes: ['id', 'name'] },
{ association: 'zone', attributes: ['id', 'name'] }
]
include: [{ association: 'role', where: { roleCode: { [db.Sequelize.Op.in]: ['DD-AM', ROLES.DD_AM] } } }]
},
{
association: 'managedAreaDistricts',
@ -919,12 +911,11 @@ export const getASMs = async (req: Request, res: Response) => {
});
const result = (asms || []).map((u: any) => {
const asmDistricts = u.managedAsmDistricts || [];
const ddAmDistricts = u.managedAreaDistricts || [];
const districts = [...asmDistricts, ...ddAmDistricts];
const districts = [...ddAmDistricts];
const roleAssignment = (u.userRoles || []).find((r: any) =>
['ASM', 'DD-AM'].includes(r.role?.roleCode)
['DD-AM', ROLES.DD_AM].includes(r.role?.roleCode)
);
const managerCode = roleAssignment?.managerCode || u.employeeId;
@ -1258,12 +1249,24 @@ export const saveSystemConfig = async (req: Request, res: Response) => {
const { id, key, value, category, description, isActive } = req.body;
let config;
// Use key as the unique identifier if id isn't present
if (id) {
config = await db.SystemConfiguration.findByPk(id);
if (!config) return res.status(404).json({ success: false, message: 'Configuration not found' });
} else if (key) {
config = await db.SystemConfiguration.findOne({ where: { key } });
}
if (config) {
await config.update({ key, value, category, description, isActive });
} else {
config = await db.SystemConfiguration.create({ key, value, category, description, isActive: isActive !== undefined ? isActive : true });
config = await db.SystemConfiguration.create({
key,
value,
category,
description,
isActive: isActive !== undefined ? isActive : true
});
}
res.json({ success: true, data: config });
@ -1273,6 +1276,98 @@ export const saveSystemConfig = async (req: Request, res: Response) => {
}
};
export const getDealerAsmMappings = async (req: Request, res: Response) => {
try {
const [dealers, asms] = await Promise.all([
db.Dealer.findAll({
include: [
{ model: db.DealerCode, as: 'dealerCode', attributes: ['dealerCode', 'salesCode'] },
{ model: db.User, as: 'user', attributes: ['id', 'fullName', 'email', 'status', 'isActive'] },
{ model: db.User, as: 'asmManager', attributes: ['id', 'fullName', 'email', 'employeeId'], required: false }
],
order: [['createdAt', 'DESC']]
}),
db.User.findAll({
where: {
roleCode: { [Op.in]: ['ASM', 'AREA SALES MANAGER'] },
isActive: true
},
attributes: ['id', 'fullName', 'email', 'employeeId', 'roleCode']
})
]);
const rows = (dealers || []).map((d: any) => {
return {
dealerId: d.id,
dealerName: d.businessName || d.legalName || 'Dealer',
legalName: d.legalName,
dealerCode: d.dealerCode?.dealerCode || d.dealerCode?.salesCode || '',
status: d.status,
onboardedAt: d.onboardedAt,
dealerUser: d.user ? {
id: d.user.id,
fullName: d.user.fullName,
email: d.user.email
} : null,
assignedAsm: d.asmManager ? {
id: d.asmManager.id,
fullName: d.asmManager.fullName,
email: d.asmManager.email,
employeeId: d.asmManager.employeeId
} : null,
assignedAt: null,
assignedBy: null
};
});
res.json({
success: true,
data: {
dealers: rows,
asmUsers: asms
}
});
} catch (error) {
console.error('Get dealer ASM mappings error:', error);
res.status(500).json({ success: false, message: 'Error fetching dealer ASM mappings' });
}
};
export const saveDealerAsmMapping = async (req: Request, res: Response) => {
try {
const { dealerId, asmUserId } = req.body;
if (!dealerId) {
return res.status(400).json({ success: false, message: 'dealerId is required' });
}
const dealer = await db.Dealer.findByPk(dealerId);
if (!dealer) {
return res.status(404).json({ success: false, message: 'Dealer not found' });
}
let asmUser: any = null;
if (asmUserId) {
asmUser = await db.User.findOne({
where: {
id: asmUserId,
roleCode: { [Op.in]: ['ASM', 'AREA SALES MANAGER'] },
isActive: true
}
});
if (!asmUser) {
return res.status(400).json({ success: false, message: 'Selected ASM user is invalid or inactive' });
}
}
await dealer.update({ asmId: asmUser ? asmUser.id : null });
res.json({ success: true, message: asmUserId ? 'ASM assigned to dealer successfully' : 'ASM mapping removed successfully' });
} catch (error) {
console.error('Save dealer ASM mapping error:', error);
res.status(500).json({ success: false, message: 'Error saving dealer ASM mapping' });
}
};
// --- SLA Configuration ---
export const getSlaConfigs = async (req: Request, res: Response) => {
try {

View File

@ -27,6 +27,8 @@ import {
saveDDLead,
getSystemConfigs,
saveSystemConfig,
getDealerAsmMappings,
saveDealerAsmMapping,
getSlaConfigs,
saveSlaConfig,
initializeDefaultSlas
@ -81,6 +83,8 @@ router.get('/dd-leads', getDDLeads);
router.post('/dd-leads', saveDDLead);
router.get('/system-configs', getSystemConfigs);
router.post('/system-configs', saveSystemConfig);
router.get('/dealer-asm-mappings', getDealerAsmMappings);
router.post('/dealer-asm-mappings', saveDealerAsmMapping);
// --- SLA Configuration ---
router.get('/sla-configs', getSlaConfigs);

View File

@ -223,19 +223,34 @@ export const getApplications = async (req: AuthRequest, res: Response) => {
ROLES.CEO,
'Admin' // Keep legacy support if any
];
const isNationalUser = nationalRoles.includes(req.user?.roleCode || '');
const isProspectiveDealer = req.user?.roleCode === 'Prospective Dealer';
const userRole = String(req.user?.roleCode || '').trim();
const isNationalUser = nationalRoles.some(r => String(r).trim().toLowerCase() === userRole.toLowerCase());
const isProspectiveDealer = userRole.toLowerCase() === 'prospective dealer';
if (isProspectiveDealer) {
whereClause.phone = (req.user as any).phone || req.user?.email;
} else if (!isNationalUser) {
// Restriction: Only show applications where the user is a participant
const participantApps = await db.RequestParticipant.findAll({
} else {
// REVOCATION CHECK: Fetch specific participant status for this user
const participantRecords = await db.RequestParticipant.findAll({
where: { userId: req.user?.id, requestType: 'application' },
attributes: ['requestId']
attributes: ['requestId', 'metadata']
});
const appIds = participantApps.map((p: any) => p.requestId);
whereClause.id = { [Op.in]: appIds };
const activeAppIds = participantRecords
.filter((p: any) => !p.metadata?.revokedAt)
.map((p: any) => p.requestId);
const revokedAppIds = participantRecords
.filter((p: any) => p.metadata?.revokedAt)
.map((p: any) => p.requestId);
if (!isNationalUser) {
// Non-national users: ONLY see applications where they are an ACTIVE participant
whereClause.id = { [Op.in]: activeAppIds };
} else if (revokedAppIds.length > 0 && userRole.toLowerCase() !== 'super admin') {
// National users (except Super Admin): See all applications EXCEPT those where they were explicitly revoked
whereClause.id = { [Op.notIn]: revokedAppIds };
}
}
// Apply Filters
@ -286,13 +301,18 @@ export const getApplications = async (req: AuthRequest, res: Response) => {
whereClause.ddLeadShortlisted = { [Op.ne]: true };
whereClause.opportunityId = null; // Strictly lead-gen records only
} else if (isShortlistedStr === 'true' && ddLeadShortlistedStr !== 'true') {
// Opportunities (Prospects) MUST be shortlisted but NOT yet in workflow
whereClause.isShortlisted = true;
// Opportunities (Prospects): include anything explicitly shortlisted OR in an opportunity status
whereClause[Op.or] = [
{ isShortlisted: true },
{ overallStatus: { [Op.in]: ['Questionnaire Pending', 'Questionnaire Completed', 'Shortlisted'] } }
];
// However, must NOT be shortlisted by DD Lead yet (that moves them to Workflow)
whereClause.ddLeadShortlisted = { [Op.ne]: true };
if (status && status !== 'all') {
applyStatusFilter(status);
} else {
whereClause.overallStatus = { [Op.in]: ['Questionnaire Pending', 'Questionnaire Completed'] };
} else if (!whereClause.overallStatus) {
whereClause.overallStatus = { [Op.in]: ['Questionnaire Pending', 'Questionnaire Completed', 'Shortlisted'] };
}
} else if (ddLeadShortlistedStr === 'true') {
// Workflow strictly shows shortlisted by DD Lead
@ -331,20 +351,25 @@ export const getApplications = async (req: AuthRequest, res: Response) => {
col: 'id'
});
// Get additional stats for the header
const stats = {
total: count,
uniqueLocations: await Application.count({
// Get additional stats for the header in parallel to reduce response time
const [uniqueLocationsCount, withExperienceCount] = await Promise.all([
Application.count({
where: whereClause,
distinct: true,
col: 'preferredLocation'
}),
withExperience: await Application.count({
Application.count({
where: {
...whereClause,
experienceYears: { [Op.gt]: 0 }
}
})
]);
const stats = {
total: count,
uniqueLocations: uniqueLocationsCount,
withExperience: withExperienceCount
};
res.json({

View File

@ -9,6 +9,7 @@ import {
exportApplicationResponses, convertToOpportunity, bulkConvertToOpportunity
} from './onboarding.controller.js';
import { authenticate } from '../../common/middleware/auth.js';
import { checkRevocation } from '../../common/middleware/checkRevocation.js';
import { uploadSingle } from '../../common/middleware/upload.js';
@ -28,19 +29,19 @@ router.get('/applications/export-responses', exportApplicationResponses);
router.get('/document-configs/metadata', getDocumentConfigMetadata);
router.get('/document-configs', getDocumentConfigs);
router.post('/applications/shortlist', bulkShortlist); // Existing route, updated to named import
router.get('/applications/:id', getApplicationById);
router.put('/applications/:id', updateApplication);
router.put('/applications/:id/status', updateApplicationStatus);
router.post('/applications/:id/documents', uploadSingle, uploadDocuments);
router.get('/applications/:id/documents', getApplicationDocuments); // Existing route, updated to named import
router.get('/applications/:id', checkRevocation as any, getApplicationById);
router.put('/applications/:id', checkRevocation as any, updateApplication);
router.put('/applications/:id/status', checkRevocation as any, updateApplicationStatus);
router.post('/applications/:id/documents', uploadSingle, checkRevocation as any, uploadDocuments);
router.get('/applications/:id/documents', checkRevocation as any, getApplicationDocuments);
router.post('/applications/bulk-convert-to-opportunity', bulkConvertToOpportunity);
router.post('/applications/:id/convert-to-opportunity', convertToOpportunity);
router.post('/applications/:id/convert-to-opportunity', checkRevocation as any, convertToOpportunity);
// Architecture-related routes
router.post('/applications/:id/assign-architecture', assignArchitectureTeam);
router.put('/applications/:id/architecture-status', updateArchitectureStatus);
router.post('/applications/:id/generate-codes', generateDealerCodes);
router.post('/applications/:id/retrigger-evaluators', retriggerEvaluators);
router.post('/applications/:id/assign-architecture', checkRevocation as any, assignArchitectureTeam);
router.put('/applications/:id/architecture-status', checkRevocation as any, updateArchitectureStatus);
router.post('/applications/:id/generate-codes', checkRevocation as any, generateDealerCodes);
router.post('/applications/:id/retrigger-evaluators', checkRevocation as any, retriggerEvaluators);
// Questionnaire Routes

View File

@ -687,7 +687,7 @@ export const assignResignation = async (req: AuthRequest, res: Response, next: N
if (dealer?.application?.district) {
const d = dealer.application.district;
if (assignTo === 'asm') targetUserId = d.asmId;
if (assignTo === 'asm') targetUserId = dealer.asmId || null;
else if (assignTo === 'rbm') targetUserId = d.region?.rbmId;
else if (assignTo === 'zbh') targetUserId = d.zone?.zbhId;
}

View File

@ -73,7 +73,7 @@ app.use(cors({
// Rate limiting
const limiter = rateLimit({
windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS || '900000'), // 15 minutes
max: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS || '1000'),
max: process.env.NODE_ENV === 'development' ? 100000 : parseInt(process.env.RATE_LIMIT_MAX_REQUESTS || '1000'),
message: 'Too many requests from this IP, please try again later.'
});
app.use('/api/', limiter);

View File

@ -82,7 +82,7 @@ export class ParticipantService {
const district = dealer.application.district;
return {
asmId: district.asmId,
asmId: dealer.asmId || null,
zmId: district.zmId,
rbmId: district.region?.rbmId,
zbhId: district.zone?.zbhId
@ -347,7 +347,13 @@ export class ParticipantService {
const outlet = (relocation as any).outlet;
if (outlet && outlet.district) {
const district = outlet.district;
if (district.asmId) participantIds.add(district.asmId);
if (relocation.dealerId) {
const dealerUser = await User.findByPk(relocation.dealerId, { attributes: ['dealerId'] });
if (dealerUser?.dealerId) {
const dealerProfile = await Dealer.findByPk(dealerUser.dealerId, { attributes: ['asmId'] });
if (dealerProfile?.asmId) participantIds.add(dealerProfile.asmId);
}
}
if (district.zmId) participantIds.add(district.zmId);
if (district.region?.rbmId) participantIds.add(district.region.rbmId);
if (district.zone?.zbhId) participantIds.add(district.zone.zbhId);

View File

@ -101,94 +101,94 @@ export class WorkflowService {
},
});
// 4. SLA Tracking — non-fatal
try {
if (previousStage) {
await SLAService.stopTrack(application.id, previousStage);
}
if (stageForDbColumn) {
await SLAService.startTrack(application.id, stageForDbColumn);
}
} catch (slaErr) {
console.error('[WorkflowService] SLA track transition failed (non-fatal):', slaErr);
}
// 5. Progress sync — non-fatal (DB state is already committed)
try {
await syncApplicationProgress(application.id, targetStatus);
} catch (syncErr) {
console.error('[WorkflowService] syncApplicationProgress failed (non-fatal):', syncErr);
}
// 5. Notifications — non-fatal
if (application.email && !metadata.skipNotification) {
// 4-6. Run non-fatal side effects in parallel to improve response time
const sideEffects = async () => {
try {
const user = await User.findOne({
where: { email: application.email },
attributes: ['id', 'mobileNumber'],
});
const targetUserId = user ? user.id : null;
const tasks = [];
let templateCode = 'ONBOARDING_STATUS_UPDATE';
if (targetStatus === 'LOI Issued') templateCode = 'LOI_ISSUED';
if (targetStatus === 'LOA Issued') templateCode = 'LOA_ISSUED';
if (targetStatus === 'Dealer Code Generated') templateCode = 'DEALER_CODE_READY';
// SLA Tracking
if (previousStage) tasks.push(SLAService.stopTrack(application.id, previousStage).catch(e => console.error('[WorkflowService] SLA stop failed:', e)));
if (stageForDbColumn) tasks.push(SLAService.startTrack(application.id, stageForDbColumn).catch(e => console.error('[WorkflowService] SLA start failed:', e)));
const portalBase = process.env.FRONTEND_URL || 'http://localhost:5173';
// Progress Sync
tasks.push(syncApplicationProgress(application.id, targetStatus).catch(e => console.error('[WorkflowService] progress sync failed:', e)));
let ctaLabel = 'View application';
if (templateCode === 'LOI_ISSUED') ctaLabel = 'View LOI';
else if (templateCode === 'LOA_ISSUED') ctaLabel = 'View LOA';
// Notifications
if (application.email && !metadata.skipNotification) {
tasks.push((async () => {
const user = await User.findOne({
where: { email: application.email },
attributes: ['id', 'mobileNumber'],
});
const targetUserId = user ? user.id : null;
await NotificationService.notify(targetUserId, application.email, {
title: `Onboarding Update: ${targetStatus}`,
message: `Hi ${application.applicantName}, your application status has been updated to ${targetStatus}. ${reason || ''}`,
channels: ['email', 'whatsapp', 'system'],
templateCode: templateCode,
placeholders: {
status: targetStatus,
applicantName: application.applicantName,
applicationId: application.applicationId,
reason: reason || 'N/A',
salesCode: application.dealerCode?.salesCode || 'N/A',
serviceCode: application.dealerCode?.serviceCode || 'N/A',
link: `${portalBase}/applications/${application.id}`,
ctaLabel,
phone: user?.mobileNumber || user?.phone || application?.mobileNumber || application?.phone || ''
},
});
} catch (notifyErr) {
console.error('[WorkflowService] Notification failed (non-fatal):', notifyErr);
}
}
let templateCode = 'ONBOARDING_STATUS_UPDATE';
if (targetStatus === 'LOI Issued') templateCode = 'LOI_ISSUED';
if (targetStatus === 'LOA Issued') templateCode = 'LOA_ISSUED';
if (targetStatus === 'Dealer Code Generated') templateCode = 'DEALER_CODE_READY';
try {
const { notifyStakeholdersOnTransition } = await import('../common/utils/workflow-email-notifications.js');
const portalBase = process.env.FRONTEND_URL || 'http://localhost:5173';
let actionUserFullName = 'System';
if (userId) {
const actionUser = await User.findByPk(userId, { attributes: ['fullName'] });
if (actionUser) actionUserFullName = actionUser.fullName;
}
const portalBase = process.env.FRONTEND_URL || 'http://localhost:5173';
await notifyStakeholdersOnTransition(
application.id,
'application',
targetStatus,
{
code: application.applicationId,
dealerName: application.applicantName || 'Applicant',
dealerId: '', // Applications might not map cleanly to user ID until onboarding finishes
actionUserFullName,
action: reason || `Transitioned to ${targetStatus}`,
remarks: reason || 'N/A',
link: `${portalBase}/applications/${application.id}`
let ctaLabel = 'View application';
if (templateCode === 'LOI_ISSUED') ctaLabel = 'View LOI';
else if (templateCode === 'LOA_ISSUED') ctaLabel = 'View LOA';
await NotificationService.notify(targetUserId, application.email, {
title: `Onboarding Update: ${targetStatus}`,
message: `Hi ${application.applicantName}, your application status has been updated to ${targetStatus}. ${reason || ''}`,
channels: ['email', 'whatsapp', 'system'],
templateCode: templateCode,
placeholders: {
status: targetStatus,
applicantName: application.applicantName,
applicationId: application.applicationId,
reason: reason || 'N/A',
salesCode: application.dealerCode?.salesCode || 'N/A',
serviceCode: application.dealerCode?.serviceCode || 'N/A',
link: `${portalBase}/applications/${application.id}`,
ctaLabel,
phone: user?.mobileNumber || user?.phone || application?.mobileNumber || application?.phone || ''
},
});
})().catch(e => console.error('[WorkflowService] notification failed:', e)));
}
);
} catch (err) {
console.error('[WorkflowService] Failed to notify stakeholders:', err);
}
// Stakeholder Notifications
tasks.push((async () => {
const { notifyStakeholdersOnTransition } = await import('../common/utils/workflow-email-notifications.js');
const portalBase = process.env.FRONTEND_URL || 'http://localhost:5173';
let actionUserFullName = 'System';
if (userId) {
const actionUser = await User.findByPk(userId, { attributes: ['fullName'] });
if (actionUser) actionUserFullName = actionUser.fullName;
}
await notifyStakeholdersOnTransition(
application.id,
'application',
targetStatus,
{
code: application.applicationId,
dealerName: application.applicantName || 'Applicant',
dealerId: '',
actionUserFullName,
action: reason || `Transitioned to ${targetStatus}`,
remarks: reason || 'N/A',
link: `${portalBase}/applications/${application.id}`
}
);
})().catch(e => console.error('[WorkflowService] stakeholder notify failed:', e)));
await Promise.all(tasks);
} catch (err) {
console.error('[WorkflowService] Side effects runner failed:', err);
}
};
// Trigger side effects but don't await them if they are truly non-fatal?
// Actually, let's await them for now to ensure consistency, but in parallel.
await sideEffects();
console.log(`[WorkflowService] Transitioned Application ${application.applicationId} from ${previousStatus} to ${targetStatus}`);
@ -196,8 +196,9 @@ export class WorkflowService {
}
/**
* Centralized policy evaluation for multi-role stage approvals
* FIXED: Counts unique users instead of unique roles to allow same-role approvals
* Centralized policy evaluation for multi-role stage decisions.
* Policy completion is response-driven (Approved/Rejected/Hold), while
* sentiment is resolved by caller (e.g. proceed if at least one approval).
*/
static async evaluateStagePolicy(applicationId: string, stageCode: string) {
const policy = await db.StageApprovalPolicy.findOne({ where: { stageCode, isActive: true } });
@ -207,16 +208,19 @@ export class WorkflowService {
const mode = policy.approvalMode || 'MIN_N';
const minNeeded = policy.minApprovals || 1;
// Fetch all approved actions for this stage
// Fetch all submitted decisions for this stage.
const actions = await db.StageApprovalAction.findAll({
where: { applicationId, stageCode, decision: 'Approved' }
where: { applicationId, stageCode }
});
const uniqueApprovers = new Set(actions.map((a: any) => a.actorUserId));
const approvedRoles = new Set(actions.map((a: any) => a.actorRole));
const uniqueResponders = new Set(actions.map((a: any) => a.actorUserId));
const respondedRoles = new Set(actions.map((a: any) => a.actorRole));
const approvedActions = actions.filter((a: any) => String(a.decision || '').toLowerCase() === 'approved');
const uniqueApprovers = new Set(approvedActions.map((a: any) => a.actorUserId));
const approvedRoles = new Set(approvedActions.map((a: any) => a.actorRole));
// 1. Initial Gate: Super Admin bypass
if (approvedRoles.has('Super Admin')) {
if (respondedRoles.has('Super Admin')) {
return { policyMet: true, policy, overriddenBy: 'Super Admin' };
}
@ -225,27 +229,30 @@ export class WorkflowService {
switch (mode) {
case 'ALL':
case 'ROLE_MANDATORY':
// Every role in the required list MUST be present in the approved roles
// Every required role must have responded (approve/reject/hold).
roleConditionMet = requiredRoles.length === 0 ||
requiredRoles.every(role => approvedRoles.has(role));
requiredRoles.every(role => respondedRoles.has(role));
break;
case 'MIN_N':
default:
// If there are required roles, at least one approval must come from THAT list
// If the list is empty, any approval counts
// For MIN_N, response from at least one required role is sufficient.
roleConditionMet = requiredRoles.length === 0 ||
requiredRoles.some(role => approvedRoles.has(role));
requiredRoles.some(role => respondedRoles.has(role));
break;
}
const meetsMinCount = uniqueApprovers.size >= minNeeded;
const meetsMinCount = uniqueResponders.size >= minNeeded;
return {
policyMet: roleConditionMet && meetsMinCount,
policy,
uniqueApprovers: Array.from(uniqueApprovers),
uniqueApprovers: Array.from(uniqueResponders),
uniqueApprovedUsers: Array.from(uniqueApprovers),
totalResponses: uniqueResponders.size,
approvedCount: uniqueApprovers.size,
approvedRoles: Array.from(approvedRoles),
respondedRoles: Array.from(respondedRoles),
roleConditionMet,
meetsMinCount,
mode

View File

@ -10,7 +10,7 @@ const STEP_DELAY_MS = Number(args.delayMs || 500);
const EMAILS = {
DD_ADMIN: 'lince@royalenfield.com',
DEALER: args.dealerEmail,
ASM: 'abhishek@royalenfield.com',
ASM: args.asmEmail || 'abhishek@royalenfield.com',
RBM_L1: 'manish@royalenfield.com',
ZBH: 'manav@royalenfield.com',
DD_LEAD: 'jaya@royalenfield.com',
@ -32,6 +32,17 @@ const EMAILS = {
DMS: 'dms@royalenfield.com'
};
async function resolveDealerAsmEmail(adminToken, dealerEmail) {
try {
const res = await apiRequest('/master/dealer-asm-mappings', 'GET', null, adminToken);
const rows = res?.data?.dealers || [];
const match = rows.find((d) => String(d?.dealerUser?.email || '').toLowerCase() === String(dealerEmail || '').toLowerCase());
return match?.assignedAsm?.email || null;
} catch (e) {
return null;
}
}
async function apiRequest(endpoint, method = 'GET', body = null, token = null) {
const headers = { 'Content-Type': 'application/json' };
if (token) headers['Authorization'] = `Bearer ${token}`;
@ -97,6 +108,13 @@ async function run() {
];
const adminToken = await login(EMAILS.DD_ADMIN);
const asmFromMapping = args.asmEmail || await resolveDealerAsmEmail(adminToken, EMAILS.DEALER);
if (asmFromMapping) {
EMAILS.ASM = asmFromMapping;
console.log(`[INFO] Using ASM approver: ${EMAILS.ASM}`);
} else {
console.log(`[WARN] Dealer-level ASM not found. Falling back to default ASM email: ${EMAILS.ASM}`);
}
const current = await apiRequest(`/self-service/constitutional/${requestId}`, 'GET', null, adminToken);
const currentStage = current?.request?.currentStage;
const stageOrder = ['Submitted', 'ASM Review', 'ZM/RBM Review', 'ZBH Review', 'DD Lead Review', 'DD Head Review', 'NBH Approval', 'Legal Review', 'Completed'];

View File

@ -21,6 +21,19 @@ const EMAILS = {
LEGAL: args.legalEmail || "legal@royalenfield.com",
};
async function resolveDealerAsmEmail(adminToken, dealerEmail) {
try {
const res = await apiRequest("/master/dealer-asm-mappings", "GET", null, adminToken);
const rows = res?.data?.dealers || [];
const match = rows.find(
(d) => String(d?.dealerUser?.email || "").toLowerCase() === String(dealerEmail || "").toLowerCase()
);
return match?.assignedAsm?.email || null;
} catch {
return null;
}
}
const ROLE_BY_STAGE = {
"ASM Review": ["ASM"],
"RBM Review": ["RBM"],
@ -139,6 +152,13 @@ async function run() {
}
EMAILS.DEALER = dealerEmail;
const asmFromMapping = args.asmEmail || await resolveDealerAsmEmail(adminToken, dealerEmail);
if (asmFromMapping) {
EMAILS.ASM = asmFromMapping;
console.log(`[INFO] Using ASM approver: ${EMAILS.ASM}`);
} else {
console.log(`[WARN] Dealer-level ASM not found. Falling back to default ASM email: ${EMAILS.ASM}`);
}
let requestId = args.requestId;
if (!requestId) {

View File

@ -33,6 +33,17 @@ const EMAILS = {
DMS: 'dms@royalenfield.com'
};
async function resolveDealerAsmEmail(adminToken, dealerEmail) {
try {
const res = await apiRequest('/master/dealer-asm-mappings', 'GET', null, adminToken);
const rows = res?.data?.dealers || [];
const match = rows.find((d) => String(d?.dealerUser?.email || '').toLowerCase() === String(dealerEmail || '').toLowerCase());
return match?.assignedAsm?.email || null;
} catch (e) {
return null;
}
}
async function apiRequest(endpoint, method = 'GET', body = null, token = null) {
const headers = { 'Content-Type': 'application/json' };
if (token) headers['Authorization'] = `Bearer ${token}`;
@ -96,6 +107,14 @@ async function run() {
if (!targetApp) throw new Error('All onboarded applications are deactivated. Run onboarding first.');
console.log(`Targeting Application: ${targetApp.applicantName} (${targetApp.id}) - Email: ${targetApp.email}`);
const asmFromArg = args.asmEmail;
const asmFromMapping = asmFromArg || await resolveDealerAsmEmail(adminToken, targetApp.email);
if (asmFromMapping) {
EMAILS.ASM = asmFromMapping;
console.log(`[INFO] Using ASM approver: ${EMAILS.ASM}`);
} else {
console.log(`[WARN] Dealer-level ASM not found. Falling back to default ASM email: ${EMAILS.ASM}`);
}
await delay();
// 1.1 Discover Dealer's Outlet