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: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
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;
|
||||
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();
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -163,7 +163,8 @@ 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
|
||||
// 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;
|
||||
@ -172,11 +173,9 @@ export const syncApplicationProgress = async (applicationId: string, overallStat
|
||||
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';
|
||||
@ -187,13 +186,23 @@ export const syncApplicationProgress = async (applicationId: string, overallStat
|
||||
(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);
|
||||
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 {
|
||||
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,
|
||||
|
||||
@ -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' });
|
||||
};
|
||||
|
||||
|
||||
@ -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' });
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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}`;
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -101,28 +101,21 @@ export class WorkflowService {
|
||||
},
|
||||
});
|
||||
|
||||
// 4. SLA Tracking — non-fatal
|
||||
// 4-6. Run non-fatal side effects in parallel to improve response time
|
||||
const sideEffects = async () => {
|
||||
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);
|
||||
}
|
||||
const tasks = [];
|
||||
|
||||
// 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);
|
||||
}
|
||||
// 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)));
|
||||
|
||||
// 5. Notifications — non-fatal
|
||||
// Progress Sync
|
||||
tasks.push(syncApplicationProgress(application.id, targetStatus).catch(e => console.error('[WorkflowService] progress sync failed:', e)));
|
||||
|
||||
// Notifications
|
||||
if (application.email && !metadata.skipNotification) {
|
||||
try {
|
||||
tasks.push((async () => {
|
||||
const user = await User.findOne({
|
||||
where: { email: application.email },
|
||||
attributes: ['id', 'mobileNumber'],
|
||||
@ -157,12 +150,11 @@ export class WorkflowService {
|
||||
phone: user?.mobileNumber || user?.phone || application?.mobileNumber || application?.phone || ''
|
||||
},
|
||||
});
|
||||
} catch (notifyErr) {
|
||||
console.error('[WorkflowService] Notification failed (non-fatal):', notifyErr);
|
||||
}
|
||||
})().catch(e => console.error('[WorkflowService] notification failed:', e)));
|
||||
}
|
||||
|
||||
try {
|
||||
// Stakeholder Notifications
|
||||
tasks.push((async () => {
|
||||
const { notifyStakeholdersOnTransition } = await import('../common/utils/workflow-email-notifications.js');
|
||||
const portalBase = process.env.FRONTEND_URL || 'http://localhost:5173';
|
||||
|
||||
@ -179,16 +171,24 @@ export class WorkflowService {
|
||||
{
|
||||
code: application.applicationId,
|
||||
dealerName: application.applicantName || 'Applicant',
|
||||
dealerId: '', // Applications might not map cleanly to user ID until onboarding finishes
|
||||
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] Failed to notify stakeholders:', 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
|
||||
|
||||
@ -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'];
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user