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:
parent
5b501ede6c
commit
4037f68745
@ -18,7 +18,9 @@
|
|||||||
"seed:email-templates": "tsx src/scripts/seed-master-emails.ts",
|
"seed:email-templates": "tsx src/scripts/seed-master-emails.ts",
|
||||||
"seed:configs": "tsx scripts/seed-system-configs.ts",
|
"seed:configs": "tsx scripts/seed-system-configs.ts",
|
||||||
"seed:document-configs": "tsx scripts/seed-document-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",
|
"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:real-geo": "tsx scripts/seed_real_locations.ts && npm run sync:hierarchy",
|
||||||
"seed:state-district": "tsx scripts/seed-state-district-only.ts",
|
"seed:state-district": "tsx scripts/seed-state-district-only.ts",
|
||||||
|
|||||||
120
scripts/seed-sla-configs.ts
Normal file
120
scripts/seed-sla-configs.ts
Normal 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);
|
||||||
|
});
|
||||||
@ -12,9 +12,15 @@ export const checkRevocation = async (req: AuthRequest, res: Response, next: Nex
|
|||||||
const userId = req.user?.id;
|
const userId = req.user?.id;
|
||||||
if (!userId) return next();
|
if (!userId) return next();
|
||||||
|
|
||||||
// Try to identify requestId and requestType from various sources
|
// BYPASS: Super Admin and other National administrative roles should always have access
|
||||||
const requestId = req.params.requestId || req.body.requestId || req.query.requestId;
|
const nationalRoles = ['NBH', 'DD Head', 'DD Lead', 'Finance', 'DD Admin', 'Legal Admin', 'Super Admin', 'Admin', 'CEO', 'CCO'];
|
||||||
const requestType = req.params.requestType || req.body.requestType || req.query.requestType;
|
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 we can't identify the request context, we can't check revocation here
|
||||||
if (!requestId) return next();
|
if (!requestId) return next();
|
||||||
|
|||||||
@ -43,6 +43,17 @@ export const sendEmail = async (to: string, subject: string, templateCode: strin
|
|||||||
try {
|
try {
|
||||||
let finalHtml = '';
|
let finalHtml = '';
|
||||||
let finalSubject = subject;
|
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)
|
// Try fetching from DB first (Master Configuration)
|
||||||
const dbTemplate = await EmailTemplate.findOne({ where: { templateCode, isActive: true } });
|
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);
|
finalSubject = subjectTemplate(allReplacements);
|
||||||
|
|
||||||
const bodyTemplate = handlebars.compile(dbTemplate.body);
|
const bodyTemplate = handlebars.compile(dbTemplate.body);
|
||||||
finalHtml = bodyTemplate(allReplacements);
|
finalHtml = ensureHeaderFooter(bodyTemplate(allReplacements));
|
||||||
} else {
|
} else {
|
||||||
registerEmailPartials(handlebars);
|
registerEmailPartials(handlebars);
|
||||||
const allReplacements = normalizeCtaPlaceholders({
|
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 source = fs.readFileSync(templatePath, 'utf-8');
|
||||||
const bodyTemplate = handlebars.compile(source);
|
const bodyTemplate = handlebars.compile(source);
|
||||||
finalHtml = bodyTemplate(allReplacements);
|
finalHtml = ensureHeaderFooter(bodyTemplate(allReplacements));
|
||||||
}
|
}
|
||||||
|
|
||||||
const readyTransporter = await initTransporter();
|
const readyTransporter = await initTransporter();
|
||||||
|
|||||||
@ -163,37 +163,46 @@ export const syncApplicationProgress = async (applicationId: string, overallStat
|
|||||||
// Fetch application to check model-driven parallel status
|
// Fetch application to check model-driven parallel status
|
||||||
const application = await db.Application.findByPk(applicationId);
|
const application = await db.Application.findByPk(applicationId);
|
||||||
|
|
||||||
// Robust Sync: Iterate through ALL stages and align with logic
|
// Robust Sync: Prepare ALL stages for batch processing
|
||||||
for (const stage of ONBOARDING_STAGES) {
|
const upsertData = [];
|
||||||
let status: 'pending' | 'active' | 'completed' = 'pending';
|
for (const stage of ONBOARDING_STAGES) {
|
||||||
let percentage = 0;
|
let status: 'pending' | 'active' | 'completed' = 'pending';
|
||||||
|
let percentage = 0;
|
||||||
|
|
||||||
if (stage.order < currentStage.order) {
|
if (stage.order < currentStage.order) {
|
||||||
status = 'completed';
|
status = 'completed';
|
||||||
percentage = 100;
|
percentage = 100;
|
||||||
} else if (stage.order === currentStage.order) {
|
} else if (stage.order === currentStage.order) {
|
||||||
// Logic for the current status order (could contain parallel stages)
|
status = isCurrentStageFinished ? 'completed' : 'active';
|
||||||
status = isCurrentStageFinished ? 'completed' : 'active';
|
percentage = isCurrentStageFinished ? 100 : 50;
|
||||||
percentage = isCurrentStageFinished ? 100 : 50;
|
|
||||||
|
|
||||||
// OVERRIDE for Parallel Tracks (Architecture/Statutory)
|
if (stage.name === 'Architecture Work' && application) {
|
||||||
if (stage.name === 'Architecture Work' && application) {
|
status = application.architectureStatus === 'COMPLETED' ? 'completed' :
|
||||||
status = application.architectureStatus === 'COMPLETED' ? 'completed' :
|
(application.architectureStatus === 'IN_PROGRESS' || currentStage.name === 'Architecture Work' || isCurrentStageFinished) ? 'active' : 'pending';
|
||||||
(application.architectureStatus === 'IN_PROGRESS' || currentStage.name === 'Architecture Work' || isCurrentStageFinished) ? 'active' : 'pending';
|
percentage = status === 'completed' ? 100 : status === 'active' ? 50 : 0;
|
||||||
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.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']
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@ -160,13 +160,24 @@ export const EmailTemplateController = {
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
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) {
|
if (subject) {
|
||||||
const subjectTemplate = handlebars.compile(safeSubject);
|
const subjectTemplate = handlebars.compile(safeSubject);
|
||||||
compiledSubject = subjectTemplate(safeData);
|
compiledSubject = subjectTemplate(safeData);
|
||||||
}
|
}
|
||||||
|
|
||||||
const bodyTemplate = handlebars.compile(safeBody);
|
const bodyTemplate = handlebars.compile(safeBody);
|
||||||
compiledBody = bodyTemplate(safeData);
|
compiledBody = ensureHeaderFooter(bodyTemplate(safeData));
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@ -146,6 +146,7 @@ export default (sequelize: Sequelize) => {
|
|||||||
|
|
||||||
User.hasMany(models.AuditLog, { foreignKey: 'userId', as: 'auditLogs' });
|
User.hasMany(models.AuditLog, { foreignKey: 'userId', as: 'auditLogs' });
|
||||||
User.belongsTo(models.Dealer, { foreignKey: 'dealerId', as: 'dealerProfile' });
|
User.belongsTo(models.Dealer, { foreignKey: 'dealerId', as: 'dealerProfile' });
|
||||||
|
User.hasMany(models.Dealer, { foreignKey: 'asmId', as: 'assignedDealers' });
|
||||||
User.hasMany(models.Outlet, { foreignKey: 'dealerId', as: 'outlets' });
|
User.hasMany(models.Outlet, { foreignKey: 'dealerId', as: 'outlets' });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -19,6 +19,7 @@ export interface DealerAttributes {
|
|||||||
securityDepositDate: Date | null;
|
securityDepositDate: Date | null;
|
||||||
lastWorkingDay: Date | null;
|
lastWorkingDay: Date | null;
|
||||||
exitReason: string | null;
|
exitReason: string | null;
|
||||||
|
asmId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DealerInstance extends Model<DealerAttributes>, DealerAttributes { }
|
export interface DealerInstance extends Model<DealerAttributes>, DealerAttributes { }
|
||||||
@ -105,6 +106,14 @@ export default (sequelize: Sequelize) => {
|
|||||||
exitReason: {
|
exitReason: {
|
||||||
type: DataTypes.TEXT,
|
type: DataTypes.TEXT,
|
||||||
allowNull: true
|
allowNull: true
|
||||||
|
},
|
||||||
|
asmId: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: true,
|
||||||
|
references: {
|
||||||
|
model: 'users',
|
||||||
|
key: 'id'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, {
|
}, {
|
||||||
tableName: 'dealers',
|
tableName: 'dealers',
|
||||||
@ -131,6 +140,7 @@ export default (sequelize: Sequelize) => {
|
|||||||
|
|
||||||
if (User) {
|
if (User) {
|
||||||
Dealer.hasOne(User, { foreignKey: 'dealerId', as: 'user' });
|
Dealer.hasOne(User, { foreignKey: 'dealerId', as: 'user' });
|
||||||
|
Dealer.belongsTo(User, { foreignKey: 'asmId', as: 'asmManager' });
|
||||||
}
|
}
|
||||||
|
|
||||||
Dealer.hasMany(models.DealerBankDetail, { foreignKey: 'dealerId', as: 'bankDetails' });
|
Dealer.hasMany(models.DealerBankDetail, { foreignKey: 'dealerId', as: 'bankDetails' });
|
||||||
|
|||||||
@ -177,6 +177,7 @@ export const getAllUsers = async (req: Request, res: Response) => {
|
|||||||
try {
|
try {
|
||||||
const { roleCode, locationId, search, page = 1, limit = 100, isExternal } = req.query as any;
|
const { roleCode, locationId, search, page = 1, limit = 100, isExternal } = req.query as any;
|
||||||
const whereClause: any = {};
|
const whereClause: any = {};
|
||||||
|
const andConditions: any[] = [];
|
||||||
|
|
||||||
// 0. External filter
|
// 0. External filter
|
||||||
if (isExternal !== undefined) {
|
if (isExternal !== undefined) {
|
||||||
@ -185,11 +186,13 @@ export const getAllUsers = async (req: Request, res: Response) => {
|
|||||||
|
|
||||||
// 1. Search filter
|
// 1. Search filter
|
||||||
if (search) {
|
if (search) {
|
||||||
whereClause[Op.or] = [
|
andConditions.push({
|
||||||
|
[Op.or]: [
|
||||||
{ fullName: { [Op.iLike]: `%${search}%` } },
|
{ fullName: { [Op.iLike]: `%${search}%` } },
|
||||||
{ email: { [Op.iLike]: `%${search}%` } },
|
{ email: { [Op.iLike]: `%${search}%` } },
|
||||||
{ employeeId: { [Op.iLike]: `%${search}%` } }
|
{ employeeId: { [Op.iLike]: `%${search}%` } }
|
||||||
];
|
]
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Role filter
|
// 2. Role filter
|
||||||
@ -228,7 +231,8 @@ export const getAllUsers = async (req: Request, res: Response) => {
|
|||||||
|
|
||||||
if (district) {
|
if (district) {
|
||||||
const relevantIds = [district.id, district.zoneId, district.regionId, district.stateId].filter(Boolean);
|
const relevantIds = [district.id, district.zoneId, district.regionId, district.stateId].filter(Boolean);
|
||||||
whereClause[Op.or] = [
|
andConditions.push({
|
||||||
|
[Op.or]: [
|
||||||
{ districtId: { [Op.in]: relevantIds } },
|
{ districtId: { [Op.in]: relevantIds } },
|
||||||
{ zoneId: { [Op.in]: relevantIds } },
|
{ zoneId: { [Op.in]: relevantIds } },
|
||||||
{ regionId: { [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.districtId$': { [Op.in]: relevantIds } },
|
||||||
{ '$userRoles.zoneId$': { [Op.in]: relevantIds } },
|
{ '$userRoles.zoneId$': { [Op.in]: relevantIds } },
|
||||||
{ '$userRoles.regionId$': { [Op.in]: relevantIds } }
|
{ '$userRoles.regionId$': { [Op.in]: relevantIds } }
|
||||||
];
|
]
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (andConditions.length > 0) {
|
||||||
|
whereClause[Op.and] = andConditions;
|
||||||
|
}
|
||||||
|
|
||||||
const { count, rows: users } = await User.findAndCountAll({
|
const { count, rows: users } = await User.findAndCountAll({
|
||||||
where: whereClause,
|
where: whereClause,
|
||||||
attributes: { exclude: ['password'] },
|
attributes: { exclude: ['password'] },
|
||||||
limit: Number(limit),
|
limit: Number(limit),
|
||||||
offset: (Number(page) - 1) * Number(limit),
|
offset: (Number(page) - 1) * Number(limit),
|
||||||
|
subQuery: false,
|
||||||
include: [
|
include: [
|
||||||
{
|
{
|
||||||
model: Role,
|
model: Role,
|
||||||
|
|||||||
@ -7,7 +7,7 @@ const {
|
|||||||
import { AuthRequest } from '../../types/express.types.js';
|
import { AuthRequest } from '../../types/express.types.js';
|
||||||
import { Op } from 'sequelize';
|
import { Op } from 'sequelize';
|
||||||
import * as EmailService from '../../common/utils/email.service.js';
|
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 { WorkflowService } from '../../services/WorkflowService.js';
|
||||||
import { syncApplicationProgress } from '../../common/utils/progress.js';
|
import { syncApplicationProgress } from '../../common/utils/progress.js';
|
||||||
import { NotificationService } from '../../services/NotificationService.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 interviewStageCode = (level: number) => `INTERVIEW_LEVEL_${level}`;
|
||||||
|
|
||||||
|
const normalizeRoleCode = (value: unknown) =>
|
||||||
|
String(value || '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[_\s-]+/g, ' ');
|
||||||
|
|
||||||
const getDefaultInterviewPolicy = (level: number) => {
|
const getDefaultInterviewPolicy = (level: number) => {
|
||||||
const defaults: Record<number, { requiredRoles: string[]; minApprovals: number }> = {
|
const defaults: Record<number, { requiredRoles: string[]; minApprovals: number }> = {
|
||||||
1: { requiredRoles: ['DD-ZM', 'RBM'], minApprovals: 2 },
|
1: { requiredRoles: ['DD-ZM', 'RBM'], minApprovals: 2 },
|
||||||
@ -45,6 +51,11 @@ const ensureInterviewPolicy = async (level: number) => {
|
|||||||
return policy;
|
return policy;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getRequiredRolesForInterviewLevel = (level: number): string[] => {
|
||||||
|
const defaults = getDefaultInterviewPolicy(level);
|
||||||
|
return Array.isArray(defaults.requiredRoles) ? defaults.requiredRoles : [];
|
||||||
|
};
|
||||||
|
|
||||||
const processStageDecision = async (params: {
|
const processStageDecision = async (params: {
|
||||||
applicationId: string;
|
applicationId: string;
|
||||||
stageCode: string;
|
stageCode: string;
|
||||||
@ -74,6 +85,63 @@ const processStageDecision = async (params: {
|
|||||||
|
|
||||||
const requiredRoles: string[] = Array.isArray(policy.requiredRoles) ? policy.requiredRoles : [];
|
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
|
// Check if user is an assigned participant
|
||||||
const userAssignments = await db.RequestParticipant.findAll({
|
const userAssignments = await db.RequestParticipant.findAll({
|
||||||
where: { requestId: resolvedId, requestType: 'application', userId }
|
where: { requestId: resolvedId, requestType: 'application', userId }
|
||||||
@ -198,10 +266,13 @@ const processStageDecision = async (params: {
|
|||||||
// Evaluate Policy via Centralized Service (FIXED unique user count)
|
// Evaluate Policy via Centralized Service (FIXED unique user count)
|
||||||
const evaluation = await WorkflowService.evaluateStagePolicy(resolvedId, stageCode);
|
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;
|
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);
|
const application = await db.Application.findByPk(resolvedId);
|
||||||
if (application) {
|
if (application) {
|
||||||
await WorkflowService.transitionApplication(application, APPLICATION_STATUS.REJECTED, userId, {
|
await WorkflowService.transitionApplication(application, APPLICATION_STATUS.REJECTED, userId, {
|
||||||
@ -210,7 +281,7 @@ const processStageDecision = async (params: {
|
|||||||
});
|
});
|
||||||
statusUpdated = true;
|
statusUpdated = true;
|
||||||
}
|
}
|
||||||
} else if (evaluation.policyMet) {
|
} else if (evaluation.policyMet && hasAnyApproval) {
|
||||||
const application = await db.Application.findByPk(resolvedId);
|
const application = await db.Application.findByPk(resolvedId);
|
||||||
if (application) {
|
if (application) {
|
||||||
let targetStatus = nextStatus;
|
let targetStatus = nextStatus;
|
||||||
@ -277,12 +348,14 @@ const processStageDecision = async (params: {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
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,
|
policy,
|
||||||
requiredRoles: evaluation.policy.requiredRoles,
|
requiredRoles: evaluation.policy.requiredRoles,
|
||||||
uniqueApprovalsByRole: evaluation.approvedRoles,
|
uniqueApprovalsByRole: evaluation.approvedRoles,
|
||||||
hasAllRequiredRoleApprovals: evaluation.hasAllRequiredRoleApprovals,
|
hasAllRequiredRoleApprovals: evaluation.roleConditionMet,
|
||||||
meetsMinApprovals: evaluation.meetsMinApprovals,
|
meetsMinApprovals: evaluation.meetsMinCount,
|
||||||
statusUpdated
|
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' });
|
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
|
// Prevent duplicate interviews for the same level
|
||||||
const existingInterview = await Interview.findOne({
|
const existingInterview = await Interview.findOne({
|
||||||
where: {
|
where: {
|
||||||
@ -455,7 +534,7 @@ export const scheduleInterview = async (req: AuthRequest, res: Response) => {
|
|||||||
const interview = await Interview.create({
|
const interview = await Interview.create({
|
||||||
applicationId: application.id,
|
applicationId: application.id,
|
||||||
level: levelNum || 1, // Default to 1 if parsing fails
|
level: levelNum || 1, // Default to 1 if parsing fails
|
||||||
scheduleDate: new Date(scheduledAt),
|
scheduleDate: scheduledDateObj,
|
||||||
interviewType: type,
|
interviewType: type,
|
||||||
linkOrLocation: location,
|
linkOrLocation: location,
|
||||||
status: 'Scheduled',
|
status: 'Scheduled',
|
||||||
@ -470,7 +549,7 @@ export const scheduleInterview = async (req: AuthRequest, res: Response) => {
|
|||||||
// 1. Google Calendar Mock
|
// 1. Google Calendar Mock
|
||||||
const { meetLink } = await ExternalMocksService.mockScheduleMeeting({
|
const { meetLink } = await ExternalMocksService.mockScheduleMeeting({
|
||||||
type,
|
type,
|
||||||
scheduledAt,
|
scheduledAt: scheduledAtIso,
|
||||||
applicationId
|
applicationId
|
||||||
});
|
});
|
||||||
await interview.update({ linkOrLocation: meetLink });
|
await interview.update({ linkOrLocation: meetLink });
|
||||||
@ -488,7 +567,7 @@ export const scheduleInterview = async (req: AuthRequest, res: Response) => {
|
|||||||
applicantName: application.applicantName,
|
applicantName: application.applicantName,
|
||||||
applicationId: application.applicationId,
|
applicationId: application.applicationId,
|
||||||
type,
|
type,
|
||||||
scheduledAt,
|
scheduledAt: scheduledAtIso,
|
||||||
link: meetLink,
|
link: meetLink,
|
||||||
phone: applicantPhone,
|
phone: applicantPhone,
|
||||||
ctaLabel: 'View Schedule'
|
ctaLabel: 'View Schedule'
|
||||||
@ -512,6 +591,41 @@ export const scheduleInterview = async (req: AuthRequest, res: Response) => {
|
|||||||
}
|
}
|
||||||
participantIds = [...new Set(participantIds)];
|
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) {
|
if (participantIds.length > 0) {
|
||||||
console.log(`Processing ${participantIds.length} participants...`);
|
console.log(`Processing ${participantIds.length} participants...`);
|
||||||
// Processing participants concurrently
|
// Processing participants concurrently
|
||||||
@ -520,14 +634,14 @@ export const scheduleInterview = async (req: AuthRequest, res: Response) => {
|
|||||||
await InterviewParticipant.create({
|
await InterviewParticipant.create({
|
||||||
interviewId: interview.id,
|
interviewId: interview.id,
|
||||||
userId,
|
userId,
|
||||||
role: 'Panelist'
|
roleInPanel: evaluatorIds.has(userId) ? 'Evaluator' : 'Panelist'
|
||||||
});
|
});
|
||||||
|
|
||||||
// 2. Add as Request Participant for Collaboration
|
// 2. Add as Request Participant for Collaboration
|
||||||
console.log(`Adding user ${userId} to RequestParticipant...`);
|
console.log(`Adding user ${userId} to RequestParticipant...`);
|
||||||
await RequestParticipant.findOrCreate({
|
await RequestParticipant.findOrCreate({
|
||||||
where: {
|
where: {
|
||||||
requestId: applicationId,
|
requestId: application.id,
|
||||||
requestType: 'application',
|
requestType: 'application',
|
||||||
userId
|
userId
|
||||||
},
|
},
|
||||||
@ -550,18 +664,7 @@ export const scheduleInterview = async (req: AuthRequest, res: Response) => {
|
|||||||
reason: `Interview Level ${levelNum} Scheduled`
|
reason: `Interview Level ${levelNum} Scheduled`
|
||||||
});
|
});
|
||||||
|
|
||||||
// 3. User & Stakeholder Notifications (SRS §6.14.3)
|
// 3. Panelist notifications (Applicant is already notified above with INTERVIEW_SCHEDULED_APPLICANT)
|
||||||
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))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (participantIds.length > 0) {
|
if (participantIds.length > 0) {
|
||||||
for (const userId of participantIds) {
|
for (const userId of participantIds) {
|
||||||
notificationPromises.push(
|
notificationPromises.push(
|
||||||
@ -579,7 +682,7 @@ export const scheduleInterview = async (req: AuthRequest, res: Response) => {
|
|||||||
applicantName: application?.applicantName || 'Applicant',
|
applicantName: application?.applicantName || 'Applicant',
|
||||||
applicationId: application?.applicationId || '',
|
applicationId: application?.applicationId || '',
|
||||||
type,
|
type,
|
||||||
scheduledAt,
|
scheduledAt: scheduledAtIso,
|
||||||
link: meetLink,
|
link: meetLink,
|
||||||
phone: pPhone || '',
|
phone: pPhone || '',
|
||||||
ctaLabel: 'Open Assessment'
|
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.
|
// However, Promise.all already makes it much faster than sequential.
|
||||||
await Promise.all(notificationPromises);
|
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.');
|
console.log('Interview scheduling completed successfully.');
|
||||||
res.status(201).json({ success: true, message: 'Interview scheduled successfully', data: interview });
|
res.status(201).json({ success: true, message: 'Interview scheduled successfully', data: interview });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -615,7 +733,48 @@ export const updateInterview = async (req: AuthRequest, res: Response) => {
|
|||||||
const interview = await Interview.findByPk(id);
|
const interview = await Interview.findByPk(id);
|
||||||
if (!interview) return res.status(404).json({ success: false, message: 'Interview not found' });
|
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' });
|
res.json({ success: true, message: 'Interview updated successfully' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@ -139,6 +139,30 @@ function buildFriendlyApplicationUpdatedDescription(logData: any, payload: any):
|
|||||||
return parts.join(' · ');
|
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 getNormalizedAuditPayload = (logData: any, entityType: string, entityId: string) => {
|
||||||
const payload = logData.details || logData.newData || {};
|
const payload = logData.details || logData.newData || {};
|
||||||
const actorName = logData.user?.fullName || logData.userName || 'System';
|
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') {
|
if (et === 'application' && action === 'UPDATED') {
|
||||||
description = buildFriendlyApplicationUpdatedDescription(logData, payload);
|
description = buildFriendlyApplicationUpdatedDescription(logData, payload);
|
||||||
|
} else if (et === 'application' && action === 'INTERVIEW_UPDATED') {
|
||||||
|
description = buildFriendlyInterviewUpdatedDescription(logData, payload);
|
||||||
} else {
|
} else {
|
||||||
if (payload?.stage) description += ` - Stage: ${payload.stage}`;
|
if (payload?.stage) description += ` - Stage: ${payload.stage}`;
|
||||||
else if (payload?.department) description += ` - ${payload.department}`;
|
else if (payload?.department) description += ` - ${payload.department}`;
|
||||||
|
|||||||
@ -26,6 +26,7 @@ export const getDealers = async (req: Request, res: Response) => {
|
|||||||
include: [
|
include: [
|
||||||
{ model: DealerCode, as: 'dealerCode' },
|
{ model: DealerCode, as: 'dealerCode' },
|
||||||
{ model: Application, as: 'application', attributes: ['city', 'state', 'preferredLocation'] },
|
{ model: Application, as: 'application', attributes: ['city', 'state', 'preferredLocation'] },
|
||||||
|
{ model: User, as: 'asmManager', attributes: ['id', 'fullName', 'email', 'employeeId'], required: false },
|
||||||
{
|
{
|
||||||
model: User,
|
model: User,
|
||||||
as: 'user',
|
as: 'user',
|
||||||
|
|||||||
@ -888,7 +888,7 @@ export const getASMs = async (req: Request, res: Response) => {
|
|||||||
try {
|
try {
|
||||||
const asms = await User.findAll({
|
const asms = await User.findAll({
|
||||||
where: {
|
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
|
isActive: true
|
||||||
},
|
},
|
||||||
include: [
|
include: [
|
||||||
@ -896,15 +896,7 @@ export const getASMs = async (req: Request, res: Response) => {
|
|||||||
association: 'userRoles',
|
association: 'userRoles',
|
||||||
where: { isActive: true },
|
where: { isActive: true },
|
||||||
required: false,
|
required: false,
|
||||||
include: [{ association: 'role', where: { roleCode: { [db.Sequelize.Op.in]: ['ASM', 'DD-AM', ROLES.DD_AM] } } }]
|
include: [{ association: 'role', where: { roleCode: { [db.Sequelize.Op.in]: ['DD-AM', ROLES.DD_AM] } } }]
|
||||||
},
|
|
||||||
{
|
|
||||||
association: 'managedAsmDistricts',
|
|
||||||
include: [
|
|
||||||
{ association: 'state', attributes: ['id', 'name'] },
|
|
||||||
{ association: 'region', attributes: ['id', 'name'] },
|
|
||||||
{ association: 'zone', attributes: ['id', 'name'] }
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
association: 'managedAreaDistricts',
|
association: 'managedAreaDistricts',
|
||||||
@ -919,12 +911,11 @@ export const getASMs = async (req: Request, res: Response) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const result = (asms || []).map((u: any) => {
|
const result = (asms || []).map((u: any) => {
|
||||||
const asmDistricts = u.managedAsmDistricts || [];
|
|
||||||
const ddAmDistricts = u.managedAreaDistricts || [];
|
const ddAmDistricts = u.managedAreaDistricts || [];
|
||||||
const districts = [...asmDistricts, ...ddAmDistricts];
|
const districts = [...ddAmDistricts];
|
||||||
|
|
||||||
const roleAssignment = (u.userRoles || []).find((r: any) =>
|
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;
|
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;
|
const { id, key, value, category, description, isActive } = req.body;
|
||||||
|
|
||||||
let config;
|
let config;
|
||||||
|
|
||||||
|
// Use key as the unique identifier if id isn't present
|
||||||
if (id) {
|
if (id) {
|
||||||
config = await db.SystemConfiguration.findByPk(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 });
|
await config.update({ key, value, category, description, isActive });
|
||||||
} else {
|
} 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 });
|
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 ---
|
// --- SLA Configuration ---
|
||||||
export const getSlaConfigs = async (req: Request, res: Response) => {
|
export const getSlaConfigs = async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -27,6 +27,8 @@ import {
|
|||||||
saveDDLead,
|
saveDDLead,
|
||||||
getSystemConfigs,
|
getSystemConfigs,
|
||||||
saveSystemConfig,
|
saveSystemConfig,
|
||||||
|
getDealerAsmMappings,
|
||||||
|
saveDealerAsmMapping,
|
||||||
getSlaConfigs,
|
getSlaConfigs,
|
||||||
saveSlaConfig,
|
saveSlaConfig,
|
||||||
initializeDefaultSlas
|
initializeDefaultSlas
|
||||||
@ -81,6 +83,8 @@ router.get('/dd-leads', getDDLeads);
|
|||||||
router.post('/dd-leads', saveDDLead);
|
router.post('/dd-leads', saveDDLead);
|
||||||
router.get('/system-configs', getSystemConfigs);
|
router.get('/system-configs', getSystemConfigs);
|
||||||
router.post('/system-configs', saveSystemConfig);
|
router.post('/system-configs', saveSystemConfig);
|
||||||
|
router.get('/dealer-asm-mappings', getDealerAsmMappings);
|
||||||
|
router.post('/dealer-asm-mappings', saveDealerAsmMapping);
|
||||||
|
|
||||||
// --- SLA Configuration ---
|
// --- SLA Configuration ---
|
||||||
router.get('/sla-configs', getSlaConfigs);
|
router.get('/sla-configs', getSlaConfigs);
|
||||||
|
|||||||
@ -223,19 +223,34 @@ export const getApplications = async (req: AuthRequest, res: Response) => {
|
|||||||
ROLES.CEO,
|
ROLES.CEO,
|
||||||
'Admin' // Keep legacy support if any
|
'Admin' // Keep legacy support if any
|
||||||
];
|
];
|
||||||
const isNationalUser = nationalRoles.includes(req.user?.roleCode || '');
|
const userRole = String(req.user?.roleCode || '').trim();
|
||||||
const isProspectiveDealer = req.user?.roleCode === 'Prospective Dealer';
|
const isNationalUser = nationalRoles.some(r => String(r).trim().toLowerCase() === userRole.toLowerCase());
|
||||||
|
const isProspectiveDealer = userRole.toLowerCase() === 'prospective dealer';
|
||||||
|
|
||||||
if (isProspectiveDealer) {
|
if (isProspectiveDealer) {
|
||||||
whereClause.phone = (req.user as any).phone || req.user?.email;
|
whereClause.phone = (req.user as any).phone || req.user?.email;
|
||||||
} else if (!isNationalUser) {
|
} else {
|
||||||
// Restriction: Only show applications where the user is a participant
|
// REVOCATION CHECK: Fetch specific participant status for this user
|
||||||
const participantApps = await db.RequestParticipant.findAll({
|
const participantRecords = await db.RequestParticipant.findAll({
|
||||||
where: { userId: req.user?.id, requestType: 'application' },
|
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
|
// Apply Filters
|
||||||
@ -286,13 +301,18 @@ export const getApplications = async (req: AuthRequest, res: Response) => {
|
|||||||
whereClause.ddLeadShortlisted = { [Op.ne]: true };
|
whereClause.ddLeadShortlisted = { [Op.ne]: true };
|
||||||
whereClause.opportunityId = null; // Strictly lead-gen records only
|
whereClause.opportunityId = null; // Strictly lead-gen records only
|
||||||
} else if (isShortlistedStr === 'true' && ddLeadShortlistedStr !== 'true') {
|
} else if (isShortlistedStr === 'true' && ddLeadShortlistedStr !== 'true') {
|
||||||
// Opportunities (Prospects) MUST be shortlisted but NOT yet in workflow
|
// Opportunities (Prospects): include anything explicitly shortlisted OR in an opportunity status
|
||||||
whereClause.isShortlisted = true;
|
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 };
|
whereClause.ddLeadShortlisted = { [Op.ne]: true };
|
||||||
|
|
||||||
if (status && status !== 'all') {
|
if (status && status !== 'all') {
|
||||||
applyStatusFilter(status);
|
applyStatusFilter(status);
|
||||||
} else {
|
} else if (!whereClause.overallStatus) {
|
||||||
whereClause.overallStatus = { [Op.in]: ['Questionnaire Pending', 'Questionnaire Completed'] };
|
whereClause.overallStatus = { [Op.in]: ['Questionnaire Pending', 'Questionnaire Completed', 'Shortlisted'] };
|
||||||
}
|
}
|
||||||
} else if (ddLeadShortlistedStr === 'true') {
|
} else if (ddLeadShortlistedStr === 'true') {
|
||||||
// Workflow strictly shows shortlisted by DD Lead
|
// Workflow strictly shows shortlisted by DD Lead
|
||||||
@ -331,20 +351,25 @@ export const getApplications = async (req: AuthRequest, res: Response) => {
|
|||||||
col: 'id'
|
col: 'id'
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get additional stats for the header
|
// Get additional stats for the header in parallel to reduce response time
|
||||||
const stats = {
|
const [uniqueLocationsCount, withExperienceCount] = await Promise.all([
|
||||||
total: count,
|
Application.count({
|
||||||
uniqueLocations: await Application.count({
|
|
||||||
where: whereClause,
|
where: whereClause,
|
||||||
distinct: true,
|
distinct: true,
|
||||||
col: 'preferredLocation'
|
col: 'preferredLocation'
|
||||||
}),
|
}),
|
||||||
withExperience: await Application.count({
|
Application.count({
|
||||||
where: {
|
where: {
|
||||||
...whereClause,
|
...whereClause,
|
||||||
experienceYears: { [Op.gt]: 0 }
|
experienceYears: { [Op.gt]: 0 }
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
|
const stats = {
|
||||||
|
total: count,
|
||||||
|
uniqueLocations: uniqueLocationsCount,
|
||||||
|
withExperience: withExperienceCount
|
||||||
};
|
};
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import {
|
|||||||
exportApplicationResponses, convertToOpportunity, bulkConvertToOpportunity
|
exportApplicationResponses, convertToOpportunity, bulkConvertToOpportunity
|
||||||
} from './onboarding.controller.js';
|
} from './onboarding.controller.js';
|
||||||
import { authenticate } from '../../common/middleware/auth.js';
|
import { authenticate } from '../../common/middleware/auth.js';
|
||||||
|
import { checkRevocation } from '../../common/middleware/checkRevocation.js';
|
||||||
|
|
||||||
import { uploadSingle } from '../../common/middleware/upload.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/metadata', getDocumentConfigMetadata);
|
||||||
router.get('/document-configs', getDocumentConfigs);
|
router.get('/document-configs', getDocumentConfigs);
|
||||||
router.post('/applications/shortlist', bulkShortlist); // Existing route, updated to named import
|
router.post('/applications/shortlist', bulkShortlist); // Existing route, updated to named import
|
||||||
router.get('/applications/:id', getApplicationById);
|
router.get('/applications/:id', checkRevocation as any, getApplicationById);
|
||||||
router.put('/applications/:id', updateApplication);
|
router.put('/applications/:id', checkRevocation as any, updateApplication);
|
||||||
router.put('/applications/:id/status', updateApplicationStatus);
|
router.put('/applications/:id/status', checkRevocation as any, updateApplicationStatus);
|
||||||
router.post('/applications/:id/documents', uploadSingle, uploadDocuments);
|
router.post('/applications/:id/documents', uploadSingle, checkRevocation as any, uploadDocuments);
|
||||||
router.get('/applications/:id/documents', getApplicationDocuments); // Existing route, updated to named import
|
router.get('/applications/:id/documents', checkRevocation as any, getApplicationDocuments);
|
||||||
router.post('/applications/bulk-convert-to-opportunity', bulkConvertToOpportunity);
|
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
|
// Architecture-related routes
|
||||||
router.post('/applications/:id/assign-architecture', assignArchitectureTeam);
|
router.post('/applications/:id/assign-architecture', checkRevocation as any, assignArchitectureTeam);
|
||||||
router.put('/applications/:id/architecture-status', updateArchitectureStatus);
|
router.put('/applications/:id/architecture-status', checkRevocation as any, updateArchitectureStatus);
|
||||||
router.post('/applications/:id/generate-codes', generateDealerCodes);
|
router.post('/applications/:id/generate-codes', checkRevocation as any, generateDealerCodes);
|
||||||
router.post('/applications/:id/retrigger-evaluators', retriggerEvaluators);
|
router.post('/applications/:id/retrigger-evaluators', checkRevocation as any, retriggerEvaluators);
|
||||||
|
|
||||||
|
|
||||||
// Questionnaire Routes
|
// Questionnaire Routes
|
||||||
|
|||||||
@ -687,7 +687,7 @@ export const assignResignation = async (req: AuthRequest, res: Response, next: N
|
|||||||
|
|
||||||
if (dealer?.application?.district) {
|
if (dealer?.application?.district) {
|
||||||
const d = 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 === 'rbm') targetUserId = d.region?.rbmId;
|
||||||
else if (assignTo === 'zbh') targetUserId = d.zone?.zbhId;
|
else if (assignTo === 'zbh') targetUserId = d.zone?.zbhId;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -73,7 +73,7 @@ app.use(cors({
|
|||||||
// Rate limiting
|
// Rate limiting
|
||||||
const limiter = rateLimit({
|
const limiter = rateLimit({
|
||||||
windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS || '900000'), // 15 minutes
|
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.'
|
message: 'Too many requests from this IP, please try again later.'
|
||||||
});
|
});
|
||||||
app.use('/api/', limiter);
|
app.use('/api/', limiter);
|
||||||
|
|||||||
@ -82,7 +82,7 @@ export class ParticipantService {
|
|||||||
|
|
||||||
const district = dealer.application.district;
|
const district = dealer.application.district;
|
||||||
return {
|
return {
|
||||||
asmId: district.asmId,
|
asmId: dealer.asmId || null,
|
||||||
zmId: district.zmId,
|
zmId: district.zmId,
|
||||||
rbmId: district.region?.rbmId,
|
rbmId: district.region?.rbmId,
|
||||||
zbhId: district.zone?.zbhId
|
zbhId: district.zone?.zbhId
|
||||||
@ -347,7 +347,13 @@ export class ParticipantService {
|
|||||||
const outlet = (relocation as any).outlet;
|
const outlet = (relocation as any).outlet;
|
||||||
if (outlet && outlet.district) {
|
if (outlet && outlet.district) {
|
||||||
const district = 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.zmId) participantIds.add(district.zmId);
|
||||||
if (district.region?.rbmId) participantIds.add(district.region.rbmId);
|
if (district.region?.rbmId) participantIds.add(district.region.rbmId);
|
||||||
if (district.zone?.zbhId) participantIds.add(district.zone.zbhId);
|
if (district.zone?.zbhId) participantIds.add(district.zone.zbhId);
|
||||||
|
|||||||
@ -101,94 +101,94 @@ export class WorkflowService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// 4. SLA Tracking — non-fatal
|
// 4-6. Run non-fatal side effects in parallel to improve response time
|
||||||
try {
|
const sideEffects = async () => {
|
||||||
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) {
|
|
||||||
try {
|
try {
|
||||||
const user = await User.findOne({
|
const tasks = [];
|
||||||
where: { email: application.email },
|
|
||||||
attributes: ['id', 'mobileNumber'],
|
|
||||||
});
|
|
||||||
const targetUserId = user ? user.id : null;
|
|
||||||
|
|
||||||
let templateCode = 'ONBOARDING_STATUS_UPDATE';
|
// SLA Tracking
|
||||||
if (targetStatus === 'LOI Issued') templateCode = 'LOI_ISSUED';
|
if (previousStage) tasks.push(SLAService.stopTrack(application.id, previousStage).catch(e => console.error('[WorkflowService] SLA stop failed:', e)));
|
||||||
if (targetStatus === 'LOA Issued') templateCode = 'LOA_ISSUED';
|
if (stageForDbColumn) tasks.push(SLAService.startTrack(application.id, stageForDbColumn).catch(e => console.error('[WorkflowService] SLA start failed:', e)));
|
||||||
if (targetStatus === 'Dealer Code Generated') templateCode = 'DEALER_CODE_READY';
|
|
||||||
|
|
||||||
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';
|
// Notifications
|
||||||
if (templateCode === 'LOI_ISSUED') ctaLabel = 'View LOI';
|
if (application.email && !metadata.skipNotification) {
|
||||||
else if (templateCode === 'LOA_ISSUED') ctaLabel = 'View LOA';
|
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, {
|
let templateCode = 'ONBOARDING_STATUS_UPDATE';
|
||||||
title: `Onboarding Update: ${targetStatus}`,
|
if (targetStatus === 'LOI Issued') templateCode = 'LOI_ISSUED';
|
||||||
message: `Hi ${application.applicantName}, your application status has been updated to ${targetStatus}. ${reason || ''}`,
|
if (targetStatus === 'LOA Issued') templateCode = 'LOA_ISSUED';
|
||||||
channels: ['email', 'whatsapp', 'system'],
|
if (targetStatus === 'Dealer Code Generated') templateCode = 'DEALER_CODE_READY';
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
const portalBase = process.env.FRONTEND_URL || 'http://localhost:5173';
|
||||||
const { notifyStakeholdersOnTransition } = await import('../common/utils/workflow-email-notifications.js');
|
|
||||||
const portalBase = process.env.FRONTEND_URL || 'http://localhost:5173';
|
|
||||||
|
|
||||||
let actionUserFullName = 'System';
|
let ctaLabel = 'View application';
|
||||||
if (userId) {
|
if (templateCode === 'LOI_ISSUED') ctaLabel = 'View LOI';
|
||||||
const actionUser = await User.findByPk(userId, { attributes: ['fullName'] });
|
else if (templateCode === 'LOA_ISSUED') ctaLabel = 'View LOA';
|
||||||
if (actionUser) actionUserFullName = actionUser.fullName;
|
|
||||||
}
|
|
||||||
|
|
||||||
await notifyStakeholdersOnTransition(
|
await NotificationService.notify(targetUserId, application.email, {
|
||||||
application.id,
|
title: `Onboarding Update: ${targetStatus}`,
|
||||||
'application',
|
message: `Hi ${application.applicantName}, your application status has been updated to ${targetStatus}. ${reason || ''}`,
|
||||||
targetStatus,
|
channels: ['email', 'whatsapp', 'system'],
|
||||||
{
|
templateCode: templateCode,
|
||||||
code: application.applicationId,
|
placeholders: {
|
||||||
dealerName: application.applicantName || 'Applicant',
|
status: targetStatus,
|
||||||
dealerId: '', // Applications might not map cleanly to user ID until onboarding finishes
|
applicantName: application.applicantName,
|
||||||
actionUserFullName,
|
applicationId: application.applicationId,
|
||||||
action: reason || `Transitioned to ${targetStatus}`,
|
reason: reason || 'N/A',
|
||||||
remarks: reason || 'N/A',
|
salesCode: application.dealerCode?.salesCode || 'N/A',
|
||||||
link: `${portalBase}/applications/${application.id}`
|
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) {
|
// Stakeholder Notifications
|
||||||
console.error('[WorkflowService] Failed to notify stakeholders:', err);
|
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}`);
|
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
|
* Centralized policy evaluation for multi-role stage decisions.
|
||||||
* FIXED: Counts unique users instead of unique roles to allow same-role approvals
|
* 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) {
|
static async evaluateStagePolicy(applicationId: string, stageCode: string) {
|
||||||
const policy = await db.StageApprovalPolicy.findOne({ where: { stageCode, isActive: true } });
|
const policy = await db.StageApprovalPolicy.findOne({ where: { stageCode, isActive: true } });
|
||||||
@ -207,16 +208,19 @@ export class WorkflowService {
|
|||||||
const mode = policy.approvalMode || 'MIN_N';
|
const mode = policy.approvalMode || 'MIN_N';
|
||||||
const minNeeded = policy.minApprovals || 1;
|
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({
|
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 uniqueResponders = new Set(actions.map((a: any) => a.actorUserId));
|
||||||
const approvedRoles = new Set(actions.map((a: any) => a.actorRole));
|
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
|
// 1. Initial Gate: Super Admin bypass
|
||||||
if (approvedRoles.has('Super Admin')) {
|
if (respondedRoles.has('Super Admin')) {
|
||||||
return { policyMet: true, policy, overriddenBy: 'Super Admin' };
|
return { policyMet: true, policy, overriddenBy: 'Super Admin' };
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -225,27 +229,30 @@ export class WorkflowService {
|
|||||||
switch (mode) {
|
switch (mode) {
|
||||||
case 'ALL':
|
case 'ALL':
|
||||||
case 'ROLE_MANDATORY':
|
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 ||
|
roleConditionMet = requiredRoles.length === 0 ||
|
||||||
requiredRoles.every(role => approvedRoles.has(role));
|
requiredRoles.every(role => respondedRoles.has(role));
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'MIN_N':
|
case 'MIN_N':
|
||||||
default:
|
default:
|
||||||
// If there are required roles, at least one approval must come from THAT list
|
// For MIN_N, response from at least one required role is sufficient.
|
||||||
// If the list is empty, any approval counts
|
|
||||||
roleConditionMet = requiredRoles.length === 0 ||
|
roleConditionMet = requiredRoles.length === 0 ||
|
||||||
requiredRoles.some(role => approvedRoles.has(role));
|
requiredRoles.some(role => respondedRoles.has(role));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
const meetsMinCount = uniqueApprovers.size >= minNeeded;
|
const meetsMinCount = uniqueResponders.size >= minNeeded;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
policyMet: roleConditionMet && meetsMinCount,
|
policyMet: roleConditionMet && meetsMinCount,
|
||||||
policy,
|
policy,
|
||||||
uniqueApprovers: Array.from(uniqueApprovers),
|
uniqueApprovers: Array.from(uniqueResponders),
|
||||||
|
uniqueApprovedUsers: Array.from(uniqueApprovers),
|
||||||
|
totalResponses: uniqueResponders.size,
|
||||||
|
approvedCount: uniqueApprovers.size,
|
||||||
approvedRoles: Array.from(approvedRoles),
|
approvedRoles: Array.from(approvedRoles),
|
||||||
|
respondedRoles: Array.from(respondedRoles),
|
||||||
roleConditionMet,
|
roleConditionMet,
|
||||||
meetsMinCount,
|
meetsMinCount,
|
||||||
mode
|
mode
|
||||||
|
|||||||
@ -10,7 +10,7 @@ const STEP_DELAY_MS = Number(args.delayMs || 500);
|
|||||||
const EMAILS = {
|
const EMAILS = {
|
||||||
DD_ADMIN: 'lince@royalenfield.com',
|
DD_ADMIN: 'lince@royalenfield.com',
|
||||||
DEALER: args.dealerEmail,
|
DEALER: args.dealerEmail,
|
||||||
ASM: 'abhishek@royalenfield.com',
|
ASM: args.asmEmail || 'abhishek@royalenfield.com',
|
||||||
RBM_L1: 'manish@royalenfield.com',
|
RBM_L1: 'manish@royalenfield.com',
|
||||||
ZBH: 'manav@royalenfield.com',
|
ZBH: 'manav@royalenfield.com',
|
||||||
DD_LEAD: 'jaya@royalenfield.com',
|
DD_LEAD: 'jaya@royalenfield.com',
|
||||||
@ -32,6 +32,17 @@ const EMAILS = {
|
|||||||
DMS: 'dms@royalenfield.com'
|
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) {
|
async function apiRequest(endpoint, method = 'GET', body = null, token = null) {
|
||||||
const headers = { 'Content-Type': 'application/json' };
|
const headers = { 'Content-Type': 'application/json' };
|
||||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||||
@ -97,6 +108,13 @@ async function run() {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const adminToken = await login(EMAILS.DD_ADMIN);
|
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 current = await apiRequest(`/self-service/constitutional/${requestId}`, 'GET', null, adminToken);
|
||||||
const currentStage = current?.request?.currentStage;
|
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'];
|
const stageOrder = ['Submitted', 'ASM Review', 'ZM/RBM Review', 'ZBH Review', 'DD Lead Review', 'DD Head Review', 'NBH Approval', 'Legal Review', 'Completed'];
|
||||||
|
|||||||
@ -21,6 +21,19 @@ const EMAILS = {
|
|||||||
LEGAL: args.legalEmail || "legal@royalenfield.com",
|
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 = {
|
const ROLE_BY_STAGE = {
|
||||||
"ASM Review": ["ASM"],
|
"ASM Review": ["ASM"],
|
||||||
"RBM Review": ["RBM"],
|
"RBM Review": ["RBM"],
|
||||||
@ -139,6 +152,13 @@ async function run() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
EMAILS.DEALER = dealerEmail;
|
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;
|
let requestId = args.requestId;
|
||||||
if (!requestId) {
|
if (!requestId) {
|
||||||
|
|||||||
@ -33,6 +33,17 @@ const EMAILS = {
|
|||||||
DMS: 'dms@royalenfield.com'
|
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) {
|
async function apiRequest(endpoint, method = 'GET', body = null, token = null) {
|
||||||
const headers = { 'Content-Type': 'application/json' };
|
const headers = { 'Content-Type': 'application/json' };
|
||||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
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.');
|
if (!targetApp) throw new Error('All onboarded applications are deactivated. Run onboarding first.');
|
||||||
|
|
||||||
console.log(`Targeting Application: ${targetApp.applicantName} (${targetApp.id}) - Email: ${targetApp.email}`);
|
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();
|
await delay();
|
||||||
|
|
||||||
// 1.1 Discover Dealer's Outlet
|
// 1.1 Discover Dealer's Outlet
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user