diff --git a/package.json b/package.json index 66d93a4..b6dd8d6 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/seed-sla-configs.ts b/scripts/seed-sla-configs.ts new file mode 100644 index 0000000..31d622b --- /dev/null +++ b/scripts/seed-sla-configs.ts @@ -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); +}); diff --git a/src/common/middleware/checkRevocation.ts b/src/common/middleware/checkRevocation.ts index 1b866d9..bd88c6b 100644 --- a/src/common/middleware/checkRevocation.ts +++ b/src/common/middleware/checkRevocation.ts @@ -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(); diff --git a/src/common/utils/email.service.ts b/src/common/utils/email.service.ts index 9812376..1d951a8 100644 --- a/src/common/utils/email.service.ts +++ b/src/common/utils/email.service.ts @@ -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(); diff --git a/src/common/utils/progress.ts b/src/common/utils/progress.ts index 1bdb304..b83be80 100644 --- a/src/common/utils/progress.ts +++ b/src/common/utils/progress.ts @@ -163,37 +163,46 @@ export const syncApplicationProgress = async (applicationId: string, overallStat // Fetch application to check model-driven parallel status const application = await db.Application.findByPk(applicationId); - // Robust Sync: Iterate through ALL stages and align with logic - for (const stage of ONBOARDING_STAGES) { - let status: 'pending' | 'active' | 'completed' = 'pending'; - let percentage = 0; + // Robust Sync: Prepare ALL stages for batch processing + const upsertData = []; + for (const stage of ONBOARDING_STAGES) { + let status: 'pending' | 'active' | 'completed' = 'pending'; + let percentage = 0; - if (stage.order < currentStage.order) { - status = 'completed'; - percentage = 100; - } else if (stage.order === currentStage.order) { - // Logic for the current status order (could contain parallel stages) - status = isCurrentStageFinished ? 'completed' : 'active'; - percentage = isCurrentStageFinished ? 100 : 50; - - // OVERRIDE for Parallel Tracks (Architecture/Statutory) - if (stage.name === 'Architecture Work' && application) { - status = application.architectureStatus === 'COMPLETED' ? 'completed' : - (application.architectureStatus === 'IN_PROGRESS' || currentStage.name === 'Architecture Work' || isCurrentStageFinished) ? 'active' : 'pending'; - percentage = status === 'completed' ? 100 : status === 'active' ? 50 : 0; - } - if (stage.name === 'Statutory Work' && application) { - status = application.statutoryStatus === 'COMPLETED' ? 'completed' : - (application.statutoryStatus === 'IN_PROGRESS' || currentStage.name === 'Statutory Work' || isCurrentStageFinished) ? 'active' : 'pending'; - percentage = status === 'completed' ? 100 : status === 'active' ? 50 : 0; - } - } else { - status = 'pending'; - percentage = 0; - } - - await updateApplicationProgress(applicationId, stage.name, status, percentage); + if (stage.order < currentStage.order) { + status = 'completed'; + percentage = 100; + } else if (stage.order === currentStage.order) { + status = isCurrentStageFinished ? 'completed' : 'active'; + percentage = isCurrentStageFinished ? 100 : 50; + + if (stage.name === 'Architecture Work' && application) { + status = application.architectureStatus === 'COMPLETED' ? 'completed' : + (application.architectureStatus === 'IN_PROGRESS' || currentStage.name === 'Architecture Work' || isCurrentStageFinished) ? 'active' : 'pending'; + percentage = status === 'completed' ? 100 : status === 'active' ? 50 : 0; + } + if (stage.name === 'Statutory Work' && application) { + status = application.statutoryStatus === 'COMPLETED' ? 'completed' : + (application.statutoryStatus === 'IN_PROGRESS' || currentStage.name === 'Statutory Work' || isCurrentStageFinished) ? 'active' : 'pending'; + percentage = status === 'completed' ? 100 : status === 'active' ? 50 : 0; } } + + upsertData.push({ + applicationId, + stageName: stage.name, + stageOrder: stage.order, + status, + completionPercentage: percentage, + stageStartedAt: (status === 'active' || status === 'completed') ? new Date() : null, + stageCompletedAt: status === 'completed' ? new Date() : null + }); + } + + // Use bulkCreate with updateOnDuplicate to perform an efficient batch upsert + await ApplicationProgress.bulkCreate(upsertData, { + updateOnDuplicate: ['status', 'completionPercentage', 'stageStartedAt', 'stageCompletedAt'] + }); + } } }; diff --git a/src/controllers/admin/EmailTemplateController.ts b/src/controllers/admin/EmailTemplateController.ts index e83ef4c..29c7b96 100644 --- a/src/controllers/admin/EmailTemplateController.ts +++ b/src/controllers/admin/EmailTemplateController.ts @@ -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, diff --git a/src/database/models/core/User.ts b/src/database/models/core/User.ts index 5414047..6dbb7b4 100644 --- a/src/database/models/core/User.ts +++ b/src/database/models/core/User.ts @@ -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' }); }; diff --git a/src/database/models/dealer/Dealer.ts b/src/database/models/dealer/Dealer.ts index e2e2090..fc53802 100644 --- a/src/database/models/dealer/Dealer.ts +++ b/src/database/models/dealer/Dealer.ts @@ -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 { } @@ -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' }); diff --git a/src/modules/admin/admin.controller.ts b/src/modules/admin/admin.controller.ts index b285d20..53f19a9 100644 --- a/src/modules/admin/admin.controller.ts +++ b/src/modules/admin/admin.controller.ts @@ -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, diff --git a/src/modules/assessment/assessment.controller.ts b/src/modules/assessment/assessment.controller.ts index 37efb33..dda231c 100644 --- a/src/modules/assessment/assessment.controller.ts +++ b/src/modules/assessment/assessment.controller.ts @@ -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 => { 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 = { 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(); + + // 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) { diff --git a/src/modules/audit/audit.controller.ts b/src/modules/audit/audit.controller.ts index a63cd6f..8b827ba 100644 --- a/src/modules/audit/audit.controller.ts +++ b/src/modules/audit/audit.controller.ts @@ -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}`; diff --git a/src/modules/dealer/dealer.controller.ts b/src/modules/dealer/dealer.controller.ts index 9166e28..f0998a1 100644 --- a/src/modules/dealer/dealer.controller.ts +++ b/src/modules/dealer/dealer.controller.ts @@ -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', diff --git a/src/modules/master/master.controller.ts b/src/modules/master/master.controller.ts index 59a65ff..6ca6052 100644 --- a/src/modules/master/master.controller.ts +++ b/src/modules/master/master.controller.ts @@ -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 { diff --git a/src/modules/master/master.routes.ts b/src/modules/master/master.routes.ts index 36a9f44..23796a0 100644 --- a/src/modules/master/master.routes.ts +++ b/src/modules/master/master.routes.ts @@ -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); diff --git a/src/modules/onboarding/onboarding.controller.ts b/src/modules/onboarding/onboarding.controller.ts index e4cbb66..b6ff165 100644 --- a/src/modules/onboarding/onboarding.controller.ts +++ b/src/modules/onboarding/onboarding.controller.ts @@ -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({ diff --git a/src/modules/onboarding/onboarding.routes.ts b/src/modules/onboarding/onboarding.routes.ts index 98cd6d2..3405306 100644 --- a/src/modules/onboarding/onboarding.routes.ts +++ b/src/modules/onboarding/onboarding.routes.ts @@ -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 diff --git a/src/modules/self-service/resignation.controller.ts b/src/modules/self-service/resignation.controller.ts index 89b22a2..81e1cfc 100644 --- a/src/modules/self-service/resignation.controller.ts +++ b/src/modules/self-service/resignation.controller.ts @@ -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; } diff --git a/src/server.ts b/src/server.ts index 0cf5065..14fd3d1 100644 --- a/src/server.ts +++ b/src/server.ts @@ -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); diff --git a/src/services/ParticipantService.ts b/src/services/ParticipantService.ts index 383e21d..ff1097e 100644 --- a/src/services/ParticipantService.ts +++ b/src/services/ParticipantService.ts @@ -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); diff --git a/src/services/WorkflowService.ts b/src/services/WorkflowService.ts index 386b3f0..1af24b8 100644 --- a/src/services/WorkflowService.ts +++ b/src/services/WorkflowService.ts @@ -101,94 +101,94 @@ export class WorkflowService { }, }); - // 4. SLA Tracking — non-fatal - try { - if (previousStage) { - await SLAService.stopTrack(application.id, previousStage); - } - if (stageForDbColumn) { - await SLAService.startTrack(application.id, stageForDbColumn); - } - } catch (slaErr) { - console.error('[WorkflowService] SLA track transition failed (non-fatal):', slaErr); - } - - // 5. Progress sync — non-fatal (DB state is already committed) - try { - await syncApplicationProgress(application.id, targetStatus); - } catch (syncErr) { - console.error('[WorkflowService] syncApplicationProgress failed (non-fatal):', syncErr); - } - - // 5. Notifications — non-fatal - if (application.email && !metadata.skipNotification) { + // 4-6. Run non-fatal side effects in parallel to improve response time + const sideEffects = async () => { try { - const user = await User.findOne({ - where: { email: application.email }, - attributes: ['id', 'mobileNumber'], - }); - const targetUserId = user ? user.id : null; + const tasks = []; - let templateCode = 'ONBOARDING_STATUS_UPDATE'; - if (targetStatus === 'LOI Issued') templateCode = 'LOI_ISSUED'; - if (targetStatus === 'LOA Issued') templateCode = 'LOA_ISSUED'; - if (targetStatus === 'Dealer Code Generated') templateCode = 'DEALER_CODE_READY'; + // SLA Tracking + if (previousStage) tasks.push(SLAService.stopTrack(application.id, previousStage).catch(e => console.error('[WorkflowService] SLA stop failed:', e))); + if (stageForDbColumn) tasks.push(SLAService.startTrack(application.id, stageForDbColumn).catch(e => console.error('[WorkflowService] SLA start failed:', e))); - const portalBase = process.env.FRONTEND_URL || 'http://localhost:5173'; + // Progress Sync + tasks.push(syncApplicationProgress(application.id, targetStatus).catch(e => console.error('[WorkflowService] progress sync failed:', e))); - let ctaLabel = 'View application'; - if (templateCode === 'LOI_ISSUED') ctaLabel = 'View LOI'; - else if (templateCode === 'LOA_ISSUED') ctaLabel = 'View LOA'; + // Notifications + if (application.email && !metadata.skipNotification) { + tasks.push((async () => { + const user = await User.findOne({ + where: { email: application.email }, + attributes: ['id', 'mobileNumber'], + }); + const targetUserId = user ? user.id : null; - await NotificationService.notify(targetUserId, application.email, { - title: `Onboarding Update: ${targetStatus}`, - message: `Hi ${application.applicantName}, your application status has been updated to ${targetStatus}. ${reason || ''}`, - channels: ['email', 'whatsapp', 'system'], - templateCode: templateCode, - placeholders: { - status: targetStatus, - applicantName: application.applicantName, - applicationId: application.applicationId, - reason: reason || 'N/A', - salesCode: application.dealerCode?.salesCode || 'N/A', - serviceCode: application.dealerCode?.serviceCode || 'N/A', - link: `${portalBase}/applications/${application.id}`, - ctaLabel, - phone: user?.mobileNumber || user?.phone || application?.mobileNumber || application?.phone || '' - }, - }); - } catch (notifyErr) { - console.error('[WorkflowService] Notification failed (non-fatal):', notifyErr); - } - } + let templateCode = 'ONBOARDING_STATUS_UPDATE'; + if (targetStatus === 'LOI Issued') templateCode = 'LOI_ISSUED'; + if (targetStatus === 'LOA Issued') templateCode = 'LOA_ISSUED'; + if (targetStatus === 'Dealer Code Generated') templateCode = 'DEALER_CODE_READY'; - try { - const { notifyStakeholdersOnTransition } = await import('../common/utils/workflow-email-notifications.js'); - const portalBase = process.env.FRONTEND_URL || 'http://localhost:5173'; - - let actionUserFullName = 'System'; - if (userId) { - const actionUser = await User.findByPk(userId, { attributes: ['fullName'] }); - if (actionUser) actionUserFullName = actionUser.fullName; - } + const portalBase = process.env.FRONTEND_URL || 'http://localhost:5173'; - await notifyStakeholdersOnTransition( - application.id, - 'application', - targetStatus, - { - code: application.applicationId, - dealerName: application.applicantName || 'Applicant', - dealerId: '', // Applications might not map cleanly to user ID until onboarding finishes - actionUserFullName, - action: reason || `Transitioned to ${targetStatus}`, - remarks: reason || 'N/A', - link: `${portalBase}/applications/${application.id}` + let ctaLabel = 'View application'; + if (templateCode === 'LOI_ISSUED') ctaLabel = 'View LOI'; + else if (templateCode === 'LOA_ISSUED') ctaLabel = 'View LOA'; + + await NotificationService.notify(targetUserId, application.email, { + title: `Onboarding Update: ${targetStatus}`, + message: `Hi ${application.applicantName}, your application status has been updated to ${targetStatus}. ${reason || ''}`, + channels: ['email', 'whatsapp', 'system'], + templateCode: templateCode, + placeholders: { + status: targetStatus, + applicantName: application.applicantName, + applicationId: application.applicationId, + reason: reason || 'N/A', + salesCode: application.dealerCode?.salesCode || 'N/A', + serviceCode: application.dealerCode?.serviceCode || 'N/A', + link: `${portalBase}/applications/${application.id}`, + ctaLabel, + phone: user?.mobileNumber || user?.phone || application?.mobileNumber || application?.phone || '' + }, + }); + })().catch(e => console.error('[WorkflowService] notification failed:', e))); } - ); - } catch (err) { - console.error('[WorkflowService] Failed to notify stakeholders:', err); - } + + // Stakeholder Notifications + tasks.push((async () => { + const { notifyStakeholdersOnTransition } = await import('../common/utils/workflow-email-notifications.js'); + const portalBase = process.env.FRONTEND_URL || 'http://localhost:5173'; + + let actionUserFullName = 'System'; + if (userId) { + const actionUser = await User.findByPk(userId, { attributes: ['fullName'] }); + if (actionUser) actionUserFullName = actionUser.fullName; + } + + await notifyStakeholdersOnTransition( + application.id, + 'application', + targetStatus, + { + code: application.applicationId, + dealerName: application.applicantName || 'Applicant', + dealerId: '', + actionUserFullName, + action: reason || `Transitioned to ${targetStatus}`, + remarks: reason || 'N/A', + link: `${portalBase}/applications/${application.id}` + } + ); + })().catch(e => console.error('[WorkflowService] stakeholder notify failed:', e))); + + await Promise.all(tasks); + } catch (err) { + console.error('[WorkflowService] Side effects runner failed:', err); + } + }; + + // Trigger side effects but don't await them if they are truly non-fatal? + // Actually, let's await them for now to ensure consistency, but in parallel. + await sideEffects(); console.log(`[WorkflowService] Transitioned Application ${application.applicationId} from ${previousStatus} to ${targetStatus}`); @@ -196,8 +196,9 @@ export class WorkflowService { } /** - * Centralized policy evaluation for multi-role stage approvals - * FIXED: Counts unique users instead of unique roles to allow same-role approvals + * Centralized policy evaluation for multi-role stage decisions. + * Policy completion is response-driven (Approved/Rejected/Hold), while + * sentiment is resolved by caller (e.g. proceed if at least one approval). */ static async evaluateStagePolicy(applicationId: string, stageCode: string) { const policy = await db.StageApprovalPolicy.findOne({ where: { stageCode, isActive: true } }); @@ -207,16 +208,19 @@ export class WorkflowService { const mode = policy.approvalMode || 'MIN_N'; const minNeeded = policy.minApprovals || 1; - // Fetch all approved actions for this stage + // Fetch all submitted decisions for this stage. const actions = await db.StageApprovalAction.findAll({ - where: { applicationId, stageCode, decision: 'Approved' } + where: { applicationId, stageCode } }); - const uniqueApprovers = new Set(actions.map((a: any) => a.actorUserId)); - const approvedRoles = new Set(actions.map((a: any) => a.actorRole)); + const uniqueResponders = new Set(actions.map((a: any) => a.actorUserId)); + const respondedRoles = new Set(actions.map((a: any) => a.actorRole)); + const approvedActions = actions.filter((a: any) => String(a.decision || '').toLowerCase() === 'approved'); + const uniqueApprovers = new Set(approvedActions.map((a: any) => a.actorUserId)); + const approvedRoles = new Set(approvedActions.map((a: any) => a.actorRole)); // 1. Initial Gate: Super Admin bypass - if (approvedRoles.has('Super Admin')) { + if (respondedRoles.has('Super Admin')) { return { policyMet: true, policy, overriddenBy: 'Super Admin' }; } @@ -225,27 +229,30 @@ export class WorkflowService { switch (mode) { case 'ALL': case 'ROLE_MANDATORY': - // Every role in the required list MUST be present in the approved roles + // Every required role must have responded (approve/reject/hold). roleConditionMet = requiredRoles.length === 0 || - requiredRoles.every(role => approvedRoles.has(role)); + requiredRoles.every(role => respondedRoles.has(role)); break; case 'MIN_N': default: - // If there are required roles, at least one approval must come from THAT list - // If the list is empty, any approval counts + // For MIN_N, response from at least one required role is sufficient. roleConditionMet = requiredRoles.length === 0 || - requiredRoles.some(role => approvedRoles.has(role)); + requiredRoles.some(role => respondedRoles.has(role)); break; } - const meetsMinCount = uniqueApprovers.size >= minNeeded; + const meetsMinCount = uniqueResponders.size >= minNeeded; return { policyMet: roleConditionMet && meetsMinCount, policy, - uniqueApprovers: Array.from(uniqueApprovers), + uniqueApprovers: Array.from(uniqueResponders), + uniqueApprovedUsers: Array.from(uniqueApprovers), + totalResponses: uniqueResponders.size, + approvedCount: uniqueApprovers.size, approvedRoles: Array.from(approvedRoles), + respondedRoles: Array.from(respondedRoles), roleConditionMet, meetsMinCount, mode diff --git a/trigger-constitutional.js b/trigger-constitutional.js index 3665789..0640d71 100644 --- a/trigger-constitutional.js +++ b/trigger-constitutional.js @@ -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']; diff --git a/trigger-relocation.js b/trigger-relocation.js index 69681e9..b199260 100644 --- a/trigger-relocation.js +++ b/trigger-relocation.js @@ -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) { diff --git a/trigger-resignation.js b/trigger-resignation.js index 54a2f2b..1e335aa 100644 --- a/trigger-resignation.js +++ b/trigger-resignation.js @@ -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