1142 lines
48 KiB
TypeScript
1142 lines
48 KiB
TypeScript
import { Request, Response } from 'express';
|
|
import db from '../../database/models/index.js';
|
|
const {
|
|
Questionnaire, QuestionnaireQuestion, QuestionnaireResponse, QuestionnaireScore,
|
|
Interview, InterviewEvaluation, InterviewParticipant, AiSummary, User, RequestParticipant, Role, District, StageApprovalPolicy, StageApprovalAction
|
|
} = db;
|
|
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 { WorkflowService } from '../../services/WorkflowService.js';
|
|
import { syncApplicationProgress } from '../../common/utils/progress.js';
|
|
import { NotificationService } from '../../services/NotificationService.js';
|
|
|
|
const getLocationAncestors = async (locationId: string): Promise<string[]> => {
|
|
const district: any = await District.findByPk(locationId);
|
|
if (!district) return [locationId];
|
|
return [district.id, district.stateId, district.regionId, district.zoneId].filter(Boolean);
|
|
};
|
|
|
|
const interviewStageCode = (level: number) => `INTERVIEW_LEVEL_${level}`;
|
|
|
|
const getDefaultInterviewPolicy = (level: number) => {
|
|
const defaults: Record<number, { requiredRoles: string[]; minApprovals: number }> = {
|
|
1: { requiredRoles: ['DD-ZM', 'RBM'], minApprovals: 2 },
|
|
2: { requiredRoles: ['ZBH', 'DD Lead'], minApprovals: 2 },
|
|
3: { requiredRoles: ['NBH', 'DD Head'], minApprovals: 2 }
|
|
};
|
|
return defaults[level] || { requiredRoles: [], minApprovals: 1 };
|
|
};
|
|
|
|
const ensureInterviewPolicy = async (level: number) => {
|
|
const stageCode = interviewStageCode(level);
|
|
const defaultPolicy = getDefaultInterviewPolicy(level);
|
|
const [policy] = await StageApprovalPolicy.findOrCreate({
|
|
where: { stageCode },
|
|
defaults: {
|
|
stageCode,
|
|
minApprovals: defaultPolicy.minApprovals,
|
|
approvalMode: 'ROLE_MANDATORY',
|
|
requiredRoles: defaultPolicy.requiredRoles,
|
|
isActive: true
|
|
}
|
|
});
|
|
return policy;
|
|
};
|
|
|
|
const processStageDecision = async (params: {
|
|
applicationId: string;
|
|
stageCode: string;
|
|
decision: 'Approved' | 'Rejected';
|
|
remarks?: string;
|
|
userId: string;
|
|
roleCode: string;
|
|
interviewId?: string;
|
|
nextStatus?: string;
|
|
nextStage?: string;
|
|
nextProgress?: number;
|
|
}) => {
|
|
const { applicationId, stageCode, decision, remarks, userId, roleCode, interviewId, nextStatus, nextStage, nextProgress } = params;
|
|
|
|
const targetId = applicationId as string;
|
|
const isUUID = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(targetId);
|
|
|
|
const application = await db.Application.findOne({
|
|
where: isUUID ? { [Op.or]: [{ id: targetId }, { applicationId: targetId }] } : { applicationId: targetId }
|
|
});
|
|
|
|
if (!application) return { notFound: true };
|
|
const resolvedId = application.id;
|
|
|
|
const policy = await db.StageApprovalPolicy.findOne({ where: { stageCode } });
|
|
if (!policy) return { noPolicy: true };
|
|
|
|
const requiredRoles: string[] = Array.isArray(policy.requiredRoles) ? policy.requiredRoles : [];
|
|
|
|
// Check if user is an assigned participant
|
|
const userAssignments = await db.RequestParticipant.findAll({
|
|
where: { requestId: resolvedId, requestType: 'application', userId }
|
|
});
|
|
|
|
const isAssigned = userAssignments.some((p: any) => {
|
|
if (!p.metadata) return false;
|
|
if (interviewId && p.metadata.interviewLevel) return true;
|
|
if (p.metadata.stageCode === stageCode) return true;
|
|
if (Array.isArray(p.metadata.allAssignments) && p.metadata.allAssignments.includes(stageCode)) return true;
|
|
return false;
|
|
});
|
|
|
|
const assignedRole = userAssignments.find((p: any) => p.metadata?.role)?.metadata?.role;
|
|
|
|
if (roleCode !== 'Super Admin' && requiredRoles.length > 0 && !requiredRoles.includes(roleCode) && !isAssigned) {
|
|
return { forbidden: true, policy, requiredRoles, currentRole: roleCode };
|
|
}
|
|
|
|
// RECORD THE DECISION ACTION
|
|
// Record Action - Robust handle for null interviewId which breaks unique constraint in Postgres
|
|
if (!interviewId) {
|
|
const existing = await db.StageApprovalAction.findOne({
|
|
where: { applicationId: resolvedId, stageCode, actorUserId: userId, interviewId: null }
|
|
});
|
|
if (existing) {
|
|
await existing.update({ decision, remarks: remarks || null, actorRole: assignedRole || roleCode });
|
|
} else {
|
|
await db.StageApprovalAction.create({
|
|
applicationId: resolvedId,
|
|
stageCode,
|
|
actorUserId: userId,
|
|
actorRole: assignedRole || roleCode,
|
|
decision,
|
|
remarks: remarks || null
|
|
});
|
|
}
|
|
} else {
|
|
await db.StageApprovalAction.upsert({
|
|
applicationId: resolvedId,
|
|
interviewId: interviewId,
|
|
stageCode,
|
|
actorUserId: userId,
|
|
actorRole: assignedRole || roleCode,
|
|
decision,
|
|
remarks: remarks || null
|
|
});
|
|
}
|
|
|
|
if (interviewId) {
|
|
await InterviewEvaluation.update(
|
|
{
|
|
decision,
|
|
recommendation: decision,
|
|
remarks: remarks || null,
|
|
qualitativeFeedback: remarks || null
|
|
},
|
|
{ where: { interviewId, evaluatorId: userId } }
|
|
);
|
|
}
|
|
|
|
// --- FDD Integration: Link approval to FddReport table for dashboard mapping ---
|
|
if (stageCode === 'FDD_VERIFICATION' && decision === 'Approved') {
|
|
const assignment = await db.FddAssignment.findOne({ where: { applicationId: resolvedId } });
|
|
if (assignment) {
|
|
// Find latest audit report document
|
|
const lastReportDoc = await db.OnboardingDocument.findOne({
|
|
where: { applicationId: resolvedId, documentType: 'FDD Final Audit Report' },
|
|
order: [['createdAt', 'DESC']]
|
|
});
|
|
|
|
// Parse structured recommendation/findings from remarks
|
|
let recommendation = 'Recommended';
|
|
let findings = remarks || 'Submission reviewed.';
|
|
if (remarks?.includes('[RECOMMENDATION:')) {
|
|
const parts = remarks.split('[RECOMMENDATION: ');
|
|
if (parts[1]) {
|
|
recommendation = parts[1].split(']')[0];
|
|
findings = remarks.split('\nFindings: ')[1] || remarks.split(']')[1]?.trim() || remarks;
|
|
}
|
|
}
|
|
|
|
let report = await db.FddReport.findOne({ where: { assignmentId: assignment.id } });
|
|
|
|
if (report) {
|
|
await report.update({
|
|
verifiedAt: new Date(),
|
|
verifiedBy: userId
|
|
});
|
|
} else {
|
|
await db.FddReport.create({
|
|
assignmentId: assignment.id,
|
|
reportDocumentId: lastReportDoc?.id || null,
|
|
findings,
|
|
recommendation,
|
|
verifiedAt: new Date(),
|
|
verifiedBy: userId,
|
|
submittedBy: userId // Admin submitted it if no existing report
|
|
});
|
|
}
|
|
|
|
await assignment.update({ status: 'Report Submitted' });
|
|
|
|
// Bridge: Initialize LOI Records for the next stage (Moved from fdd.controller.ts for Admin Review flow)
|
|
console.log(`[DEBUG] FDD Approved by Admin. Initializing LOI Records for Application: ${resolvedId}`);
|
|
const [loiReq] = await db.LoiRequest.findOrCreate({
|
|
where: { applicationId: resolvedId },
|
|
defaults: { status: 'Pending Approval', requestedBy: userId }
|
|
});
|
|
|
|
const nextRoles = ['DD Head', 'NBH'];
|
|
await Promise.all(nextRoles.map(async (role) => {
|
|
await db.LoiApproval.findOrCreate({
|
|
where: { requestId: loiReq.id, approverRole: role },
|
|
defaults: { action: 'Pending', level: 1 }
|
|
});
|
|
}));
|
|
console.log(`[DEBUG] LOI Records initialized for ${nextRoles.join(', ')}`);
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
let statusUpdated = false;
|
|
|
|
if (hasRejection) {
|
|
const application = await db.Application.findByPk(resolvedId);
|
|
if (application) {
|
|
await WorkflowService.transitionApplication(application, APPLICATION_STATUS.REJECTED, userId, {
|
|
reason: remarks || `Rejected during ${stageCode} stage.`,
|
|
stage: APPLICATION_STAGES.REJECTED
|
|
});
|
|
statusUpdated = true;
|
|
}
|
|
} else if (evaluation.policyMet) {
|
|
const application = await db.Application.findByPk(resolvedId);
|
|
if (application) {
|
|
let targetStatus = nextStatus;
|
|
let targetStage = nextStage;
|
|
let targetProgress = nextProgress;
|
|
|
|
// Consolidated Parallel Track Logic (SRS v2.0 Section 1.1.2 Alignment)
|
|
if (stageCode === 'ARCHITECTURE_WORK') {
|
|
await application.update({ architectureStatus: 'COMPLETED' });
|
|
targetStatus = undefined;
|
|
targetStage = 'Architecture Work';
|
|
targetProgress = application.progressPercentage || 80;
|
|
statusUpdated = true;
|
|
} else if (stageCode === 'STATUTORY_WORK') {
|
|
await application.update({ statutoryStatus: 'COMPLETED' });
|
|
targetStatus = APPLICATION_STATUS.LOA_PENDING;
|
|
targetStage = 'Statutory Work';
|
|
targetProgress = 85;
|
|
} else if (stageCode === 'LOA_APPROVAL') {
|
|
targetStatus = APPLICATION_STATUS.LOA_ISSUED;
|
|
targetStage = 'LOA';
|
|
targetProgress = 95;
|
|
} else if (stageCode === 'LOI_APPROVAL') {
|
|
targetStatus = APPLICATION_STATUS.SECURITY_DETAILS;
|
|
targetStage = APPLICATION_STAGES.LOI;
|
|
targetProgress = typeof nextProgress === 'number' ? nextProgress : 78;
|
|
}
|
|
|
|
if (targetStatus) {
|
|
await WorkflowService.transitionApplication(application, targetStatus, userId, {
|
|
reason: remarks || `Policy satisfied for ${stageCode}. Moving to next sequential step.`,
|
|
stage: targetStage,
|
|
progressPercentage: targetProgress
|
|
});
|
|
statusUpdated = true;
|
|
}
|
|
}
|
|
} else {
|
|
// --- SEQUENTIAL NOTIFICATION TRIGGER ---
|
|
// If policy is NOT yet met (e.g. DD Head approved, waiting for NBH),
|
|
// we still need to trigger notifications for the NEXT person in the sequence.
|
|
try {
|
|
const { notifyStakeholdersOnTransition } = await import('../../common/utils/workflow-email-notifications.js');
|
|
const portalBase = process.env.FRONTEND_URL || 'http://localhost:5173';
|
|
|
|
await notifyStakeholdersOnTransition(
|
|
application.id,
|
|
'application',
|
|
application.currentStage || stageCode, // Notify for the CURRENT stage (to trigger resolveNextActors logic)
|
|
{
|
|
code: application.applicationId,
|
|
dealerName: application.applicantName || 'Applicant',
|
|
dealerId: '',
|
|
actionUserFullName: 'Stakeholder', // Will be resolved by notifyStakeholders if needed
|
|
action: `Partial Approval: ${roleCode} approved ${stageCode}`,
|
|
remarks: remarks || 'Approval recorded. Waiting for next sequential stakeholder.',
|
|
link: `${portalBase}/applications/${application.id}`
|
|
}
|
|
);
|
|
} catch (err) {
|
|
console.error('[processStageDecision] Sequential notification failed:', err);
|
|
}
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
message: hasRejection ? 'Rejected' : statusUpdated ? 'Policy satisfied. Stage complete.' : 'Approval recorded.',
|
|
policy,
|
|
requiredRoles: evaluation.policy.requiredRoles,
|
|
uniqueApprovalsByRole: evaluation.approvedRoles,
|
|
hasAllRequiredRoleApprovals: evaluation.hasAllRequiredRoleApprovals,
|
|
meetsMinApprovals: evaluation.meetsMinApprovals,
|
|
statusUpdated
|
|
};
|
|
};
|
|
|
|
const processInterviewApprovalDecision = async (params: {
|
|
interviewId: string;
|
|
decision: 'Approved' | 'Rejected';
|
|
remarks?: string;
|
|
userId: string;
|
|
roleCode: string;
|
|
}) => {
|
|
const { interviewId, decision, remarks, userId, roleCode } = params;
|
|
const interview: any = await Interview.findByPk(interviewId);
|
|
if (!interview) return { notFound: true };
|
|
|
|
const stageCode = interviewStageCode(interview.level);
|
|
|
|
// Ensure policy exists for interviews
|
|
await ensureInterviewPolicy(interview.level);
|
|
|
|
const nextStatusMap: any = { 1: 'Level 1 Approved', 2: 'Level 2 Approved', 3: 'FDD Verification' };
|
|
const nextStageMap: any = { 1: APPLICATION_STAGES.LEVEL_1_APPROVED, 2: APPLICATION_STAGES.LEVEL_2_APPROVED, 3: APPLICATION_STAGES.FDD };
|
|
const progressMap: any = { 1: 40, 2: 55, 3: 65 };
|
|
|
|
const result = await processStageDecision({
|
|
applicationId: interview.applicationId,
|
|
stageCode,
|
|
decision,
|
|
remarks,
|
|
userId,
|
|
roleCode,
|
|
interviewId,
|
|
nextStatus: nextStatusMap[interview.level] || 'Approved',
|
|
nextStage: nextStageMap[interview.level] || APPLICATION_STAGES.APPROVED,
|
|
nextProgress: progressMap[interview.level]
|
|
});
|
|
|
|
if (result.statusUpdated) {
|
|
await interview.update({ status: 'Completed', outcome: decision === 'Approved' ? 'Selected' : 'Rejected' });
|
|
}
|
|
|
|
return result;
|
|
};
|
|
|
|
// --- Questionnaires ---
|
|
|
|
export const getQuestionnaire = async (req: Request, res: Response) => {
|
|
try {
|
|
const { version } = req.query;
|
|
const where: any = { isActive: true };
|
|
if (version) where.version = version;
|
|
|
|
const questionnaire = await Questionnaire.findOne({
|
|
where,
|
|
include: [{ model: QuestionnaireQuestion, as: 'questions' }],
|
|
order: [['createdAt', 'DESC']] // GET latest if no version
|
|
});
|
|
|
|
res.json({ success: true, data: questionnaire });
|
|
} catch (error) {
|
|
console.error('Get questionnaire error:', error);
|
|
res.status(500).json({ success: false, message: 'Error fetching questionnaire' });
|
|
}
|
|
};
|
|
|
|
export const submitQuestionnaireResponse = async (req: AuthRequest, res: Response) => {
|
|
try {
|
|
const { applicationId, questionnaireId, responses } = req.body;
|
|
|
|
const application = await db.Application.findOne({
|
|
where: { applicationId }
|
|
});
|
|
|
|
if (!application) return res.status(404).json({ success: false, message: 'Application not found' });
|
|
|
|
let totalWeightedScore = 0;
|
|
|
|
for (const resp of responses) {
|
|
await QuestionnaireResponse.create({
|
|
applicationId,
|
|
questionnaireId,
|
|
questionId: resp.questionId,
|
|
responseValue: resp.responseValue,
|
|
attachmentUrl: resp.attachmentUrl
|
|
});
|
|
|
|
const question = await QuestionnaireQuestion.findByPk(resp.questionId, {
|
|
include: [{ model: db.QuestionnaireOption, as: 'questionOptions' }]
|
|
});
|
|
|
|
if (question) {
|
|
let questionScore = 0;
|
|
if (question.questionOptions && question.questionOptions.length > 0) {
|
|
const selectedOption = question.questionOptions.find((opt: any) => opt.optionText === resp.responseValue);
|
|
if (selectedOption) {
|
|
questionScore = selectedOption.score;
|
|
}
|
|
} else if (!isNaN(Number(resp.responseValue))) {
|
|
questionScore = Number(resp.responseValue);
|
|
}
|
|
|
|
totalWeightedScore += (questionScore * (question.weight || 1));
|
|
}
|
|
}
|
|
|
|
await QuestionnaireScore.upsert({
|
|
applicationId,
|
|
questionnaireId,
|
|
score: totalWeightedScore,
|
|
maxScore: 100,
|
|
status: 'Completed'
|
|
});
|
|
|
|
try {
|
|
const loc =
|
|
(application as any).preferredLocation || (application as any).city || 'your preferred location';
|
|
await EmailService.sendQuestionnaireAckEmail(
|
|
application.email,
|
|
application.applicantName || 'Applicant',
|
|
loc,
|
|
application.applicationId || applicationId
|
|
);
|
|
} catch (mailErr) {
|
|
console.error('[submitQuestionnaireResponse] acknowledgement email:', mailErr);
|
|
}
|
|
|
|
res.status(201).json({ success: true, message: 'Responses submitted successfully', score: totalWeightedScore });
|
|
} catch (error) {
|
|
console.error('Submit response error:', error);
|
|
res.status(500).json({ success: false, message: 'Error submitting responses' });
|
|
}
|
|
};
|
|
|
|
// --- Interviews ---
|
|
|
|
export const scheduleInterview = async (req: AuthRequest, res: Response) => {
|
|
try {
|
|
console.log('---------------------------------------------------');
|
|
console.log('Incoming Schedule Interview Request:', JSON.stringify(req.body, null, 2));
|
|
const { applicationId, level, scheduledAt, type, location, participants } = req.body; // participants: [userId]
|
|
|
|
// Parse level string (e.g., "level1") to integer if necessary
|
|
const levelNum = typeof level === 'string' ? parseInt(level.replace(/\D/g, ''), 10) : level;
|
|
console.log(`Parsed Level: ${level} -> ${levelNum}`);
|
|
|
|
const _isUUID_si = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(applicationId as string);
|
|
const application = await db.Application.findOne({
|
|
where: _isUUID_si ? { [Op.or]: [{ id: applicationId }, { applicationId: applicationId }] } : { applicationId: applicationId }
|
|
});
|
|
|
|
if (!application) return res.status(404).json({ success: false, message: 'Application not found' });
|
|
|
|
// Prevent duplicate interviews for the same level
|
|
const existingInterview = await Interview.findOne({
|
|
where: {
|
|
applicationId: application.id,
|
|
level: levelNum || 1,
|
|
status: { [Op.ne]: 'Cancelled' }
|
|
}
|
|
});
|
|
|
|
if (existingInterview) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
message: `An interview for Level ${levelNum || 1} is already ${existingInterview.status.toLowerCase()}.`
|
|
});
|
|
}
|
|
|
|
console.log('Creating Interview record...');
|
|
const interview = await Interview.create({
|
|
applicationId: application.id,
|
|
level: levelNum || 1, // Default to 1 if parsing fails
|
|
scheduleDate: new Date(scheduledAt),
|
|
interviewType: type,
|
|
linkOrLocation: location,
|
|
status: 'Scheduled',
|
|
scheduledBy: req.user?.id
|
|
});
|
|
console.log('Interview created with ID:', interview.id);
|
|
const notificationPromises: Promise<any>[] = [];
|
|
|
|
// Note: WorkflowTransition relocated below participant insertion.
|
|
|
|
// MOCK INTEGRATIONS
|
|
// 1. Google Calendar Mock
|
|
const { meetLink } = await ExternalMocksService.mockScheduleMeeting({
|
|
type,
|
|
scheduledAt,
|
|
applicationId
|
|
});
|
|
await interview.update({ linkOrLocation: meetLink });
|
|
|
|
// 2. Transmitted via Centralized Notification Service (SRS §6.14.3)
|
|
// Replacing direct mock call with production-ready service trigger
|
|
const applicantPhone = application.mobileNumber || application.phone || '';
|
|
notificationPromises.push(
|
|
NotificationService.notify(null, application.email, {
|
|
title: `Interview Scheduled: ${application.applicationId}`,
|
|
message: `Dear ${application.applicantName}, your ${type} is scheduled.`,
|
|
channels: applicantPhone ? ['email', 'whatsapp', 'system'] : ['email', 'system'],
|
|
templateCode: 'INTERVIEW_SCHEDULED_APPLICANT',
|
|
placeholders: {
|
|
applicantName: application.applicantName,
|
|
applicationId: application.applicationId,
|
|
type,
|
|
scheduledAt,
|
|
link: meetLink,
|
|
phone: applicantPhone,
|
|
ctaLabel: 'View Schedule'
|
|
}
|
|
}).catch(err => console.error('Failed to notify applicant via WhatsApp/Email:', err))
|
|
);
|
|
|
|
let participantIds: string[] = Array.isArray(participants) ? participants : [];
|
|
|
|
// Auto-fill participants from pre-assigned RequestParticipants if not provided
|
|
if (participantIds.length === 0) {
|
|
const preAssigned = await db.RequestParticipant.findAll({
|
|
where: {
|
|
requestId: application.id,
|
|
requestType: 'application',
|
|
'metadata.interviewLevel': levelNum
|
|
},
|
|
attributes: ['userId']
|
|
});
|
|
participantIds = preAssigned.map((p: any) => p.userId);
|
|
}
|
|
participantIds = [...new Set(participantIds)];
|
|
|
|
if (participantIds.length > 0) {
|
|
console.log(`Processing ${participantIds.length} participants...`);
|
|
// Processing participants concurrently
|
|
await Promise.all(participantIds.map(async (userId) => {
|
|
// 1. Add to Panel
|
|
await InterviewParticipant.create({
|
|
interviewId: interview.id,
|
|
userId,
|
|
role: 'Panelist'
|
|
});
|
|
|
|
// 2. Add as Request Participant for Collaboration
|
|
console.log(`Adding user ${userId} to RequestParticipant...`);
|
|
await RequestParticipant.findOrCreate({
|
|
where: {
|
|
requestId: applicationId,
|
|
requestType: 'application',
|
|
userId
|
|
},
|
|
defaults: {
|
|
participantType: 'contributor',
|
|
joinedMethod: 'interview'
|
|
}
|
|
});
|
|
}));
|
|
}
|
|
|
|
// Update Application Status (Moved after participants to ensure notification system can see the new participants)
|
|
const statusMap: any = {
|
|
1: 'Level 1 Interview Pending',
|
|
2: 'Level 2 Interview Pending',
|
|
3: 'Level 3 Interview Pending'
|
|
};
|
|
const newStatus = statusMap[levelNum] || 'Interview Scheduled';
|
|
await WorkflowService.transitionApplication(application, newStatus, req.user?.id || null, {
|
|
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))
|
|
);
|
|
}
|
|
|
|
if (participantIds.length > 0) {
|
|
for (const userId of participantIds) {
|
|
notificationPromises.push(
|
|
(async () => {
|
|
const panelist = await User.findByPk(userId, { attributes: ['id', 'email', 'fullName', 'mobileNumber'] });
|
|
if (panelist) {
|
|
const pPhone = panelist.mobileNumber || null;
|
|
await NotificationService.notify(panelist.id, panelist.email, {
|
|
title: `Interview Assignment: ${application?.applicationId || 'New Case'}`,
|
|
message: `You have been assigned as a panelist for a ${type} with ${application?.applicantName || 'Applicant'}.`,
|
|
channels: pPhone ? ['email', 'system', 'whatsapp'] : ['email', 'system'],
|
|
templateCode: 'INTERVIEW_SCHEDULED_PANELIST',
|
|
placeholders: {
|
|
panelistName: panelist.fullName,
|
|
applicantName: application?.applicantName || 'Applicant',
|
|
applicationId: application?.applicationId || '',
|
|
type,
|
|
scheduledAt,
|
|
link: meetLink,
|
|
phone: pPhone || '',
|
|
ctaLabel: 'Open Assessment'
|
|
}
|
|
});
|
|
}
|
|
})().catch(err => console.error(`Failed to notify panelist (${userId}):`, err))
|
|
);
|
|
}
|
|
}
|
|
|
|
// We don't necessarily need to wait for all emails to finish before returning success to user
|
|
// But for consistency and ensuring they are triggered, we'll wait with a timeout or just proceed
|
|
// Let's use Promise.all but keep it out of the main critical path if we want to be ultra fast.
|
|
// However, Promise.all already makes it much faster than sequential.
|
|
await Promise.all(notificationPromises);
|
|
|
|
console.log('Interview scheduling completed successfully.');
|
|
res.status(201).json({ success: true, message: 'Interview scheduled successfully', data: interview });
|
|
} catch (error) {
|
|
console.error('CRITICAL ERROR in scheduleInterview:', error);
|
|
// Log the full error object for inspection
|
|
console.log(JSON.stringify(error, null, 2));
|
|
res.status(500).json({ success: false, message: 'Error scheduling interview', error: String(error) });
|
|
}
|
|
};
|
|
|
|
export const updateInterview = async (req: AuthRequest, res: Response) => {
|
|
try {
|
|
const { id } = req.params;
|
|
const { status, scheduledAt, outcome } = req.body;
|
|
|
|
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 });
|
|
|
|
res.json({ success: true, message: 'Interview updated successfully' });
|
|
} catch (error) {
|
|
console.error('Update interview error:', error);
|
|
res.status(500).json({ success: false, message: 'Error updating interview' });
|
|
}
|
|
};
|
|
|
|
export const submitEvaluation = async (req: AuthRequest, res: Response) => {
|
|
try {
|
|
const { id } = req.params; // Interview ID
|
|
const { ktScore, feedback, recommendation, status } = req.body;
|
|
|
|
const interview = await Interview.findByPk(id);
|
|
if (!interview) return res.status(404).json({ success: false, message: 'Interview not found' });
|
|
|
|
const evaluation = await InterviewEvaluation.create({
|
|
interviewId: id,
|
|
evaluatorId: req.user?.id,
|
|
ktMatrixScore: ktScore,
|
|
qualitativeFeedback: feedback,
|
|
recommendation
|
|
});
|
|
|
|
// Auto update interview status if completed
|
|
if (status === 'Completed') {
|
|
await interview.update({ status: 'Completed', outcome: recommendation });
|
|
}
|
|
|
|
res.status(201).json({ success: true, message: 'Evaluation submitted successfully', data: evaluation });
|
|
} catch (error) {
|
|
console.error('Submit evaluation error:', error);
|
|
res.status(500).json({ success: false, message: 'Error submitting evaluation' });
|
|
}
|
|
};
|
|
|
|
export const submitKTMatrix = async (req: AuthRequest, res: Response) => {
|
|
try {
|
|
const { interviewId, criteriaScores, feedback, recommendation } = req.body;
|
|
// criteriaScores: [{ criterionName, score, maxScore, weightage }]
|
|
|
|
const interview = await Interview.findByPk(interviewId);
|
|
if (!interview) return res.status(404).json({ success: false, message: 'Interview not found' });
|
|
|
|
// Calculate total weighted score
|
|
let totalWeightedScore = 0;
|
|
const totalWeightage = criteriaScores.reduce((sum: number, item: any) => sum + item.weightage, 0);
|
|
|
|
const ktMatrixScoresData = criteriaScores.map((item: any) => {
|
|
const weightedScore = (item.score / item.maxScore) * item.weightage;
|
|
totalWeightedScore += weightedScore;
|
|
return {
|
|
criterionName: item.criterionName,
|
|
score: item.score,
|
|
maxScore: item.maxScore,
|
|
weightage: item.weightage,
|
|
weightedScore
|
|
};
|
|
});
|
|
|
|
// Check if evaluation exists for this user and interview, if so update, else create
|
|
let evaluation = await InterviewEvaluation.findOne({
|
|
where: { interviewId, evaluatorId: req.user?.id }
|
|
});
|
|
|
|
if (evaluation) {
|
|
await evaluation.update({
|
|
ktMatrixScore: totalWeightedScore,
|
|
qualitativeFeedback: feedback,
|
|
recommendation
|
|
});
|
|
// Remove old details to replace with new
|
|
await db.KTMatrixScore.destroy({ where: { evaluationId: evaluation.id } });
|
|
} else {
|
|
evaluation = await InterviewEvaluation.create({
|
|
interviewId,
|
|
evaluatorId: req.user?.id,
|
|
ktMatrixScore: totalWeightedScore,
|
|
qualitativeFeedback: feedback,
|
|
recommendation
|
|
});
|
|
}
|
|
|
|
// Bulk create detailed scores
|
|
const scoreRecords = ktMatrixScoresData.map((s: any) => ({
|
|
...s,
|
|
evaluationId: evaluation?.id
|
|
}));
|
|
await db.KTMatrixScore.bulkCreate(scoreRecords);
|
|
|
|
// Auto-process approval if recommendation is provided
|
|
if (recommendation && req.user?.id && req.user?.roleCode) {
|
|
const normalizedDecision = (['Recommended', 'Approved', 'Selected', 'Approve'].includes(recommendation))
|
|
? 'Approved' : 'Rejected';
|
|
await processInterviewApprovalDecision({
|
|
interviewId,
|
|
decision: normalizedDecision,
|
|
remarks: feedback,
|
|
userId: req.user.id,
|
|
roleCode: req.user.roleCode
|
|
});
|
|
}
|
|
|
|
res.status(201).json({ success: true, message: 'KT Matrix submitted successfully', data: evaluation });
|
|
} catch (error) {
|
|
console.error('Submit KT Matrix error:', error);
|
|
res.status(500).json({ success: false, message: 'Error submitting KT Matrix' });
|
|
}
|
|
};
|
|
|
|
export const submitLevel2Feedback = async (req: AuthRequest, res: Response) => {
|
|
try {
|
|
const { interviewId, feedbackItems, recommendation, overallScore } = req.body;
|
|
// feedbackItems: [{ type: 'Strategic Vision', comments: '...' }]
|
|
|
|
const interview = await Interview.findByPk(interviewId);
|
|
if (!interview) return res.status(404).json({ success: false, message: 'Interview not found' });
|
|
|
|
// Check if evaluation exists for this user and interview, if so update, else create
|
|
let evaluation = await InterviewEvaluation.findOne({
|
|
where: { interviewId, evaluatorId: req.user?.id }
|
|
});
|
|
|
|
if (evaluation) {
|
|
await evaluation.update({
|
|
ktMatrixScore: overallScore, // Reusing this field for overall score (check if type matches)
|
|
recommendation
|
|
});
|
|
// Remove old details to replace with new
|
|
await db.InterviewFeedback.destroy({ where: { evaluationId: evaluation.id } });
|
|
} else {
|
|
evaluation = await InterviewEvaluation.create({
|
|
interviewId,
|
|
evaluatorId: req.user?.id,
|
|
ktMatrixScore: overallScore,
|
|
recommendation
|
|
});
|
|
}
|
|
|
|
// Bulk create detailed qualitative feedback
|
|
if (feedbackItems && feedbackItems.length > 0) {
|
|
const feedbackRecords = feedbackItems.map((item: any) => ({
|
|
evaluationId: evaluation?.id,
|
|
feedbackType: item.type,
|
|
comments: item.comments
|
|
}));
|
|
await db.InterviewFeedback.bulkCreate(feedbackRecords);
|
|
}
|
|
|
|
// Auto-process approval if recommendation is provided
|
|
if (recommendation && req.user?.id && req.user?.roleCode) {
|
|
const normalizedDecision = (['Recommended', 'Approved', 'Selected', 'Approve'].includes(recommendation))
|
|
? 'Approved' : 'Rejected';
|
|
await processInterviewApprovalDecision({
|
|
interviewId,
|
|
decision: normalizedDecision,
|
|
userId: req.user.id,
|
|
roleCode: req.user.roleCode
|
|
});
|
|
}
|
|
|
|
res.status(201).json({ success: true, message: 'Level 2 Feedback submitted successfully', data: evaluation });
|
|
} catch (error) {
|
|
console.error('Submit Level 2 Feedback error:', error);
|
|
res.status(500).json({ success: false, message: 'Error submitting Level 2 Feedback' });
|
|
}
|
|
};
|
|
|
|
// --- AI Summary ---
|
|
|
|
import { ExternalMocksService } from '../../common/utils/externalMocks.service.js';
|
|
|
|
export const generateAiSummary = async (req: AuthRequest, res: Response) => {
|
|
try {
|
|
const { applicationId } = req.params;
|
|
|
|
// Find application UUID first
|
|
const isUUID = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(applicationId as string);
|
|
const app = await db.Application.findOne({
|
|
where: isUUID ? { [Op.or]: [{ id: applicationId }, { applicationId }] } : { applicationId }
|
|
});
|
|
if (!app) return res.status(404).json({ success: false, message: 'Application not found' });
|
|
|
|
// 1. Fetch all interview evaluations for this application using UUID
|
|
const interviews = await Interview.findAll({
|
|
where: { applicationId: app.id },
|
|
include: [{ model: InterviewEvaluation, as: 'evaluations' }]
|
|
});
|
|
|
|
const allEvaluations = interviews.flatMap((i: any) => i.evaluations || []);
|
|
|
|
if (allEvaluations.length === 0) {
|
|
return res.status(400).json({ success: false, message: 'No interview evaluations found to summarize' });
|
|
}
|
|
|
|
// 2. Map evaluations to a format Gemini (mock) understands
|
|
const feedbackList = allEvaluations.map((e: any) => ({
|
|
recommendation: e.recommendation,
|
|
feedback: e.qualitativeFeedback
|
|
}));
|
|
|
|
// 3. Trigger Mock Gemini call
|
|
const { summary } = await ExternalMocksService.mockGenerateAiSummary(applicationId as string, feedbackList);
|
|
|
|
// 4. Save/Update AI Summary
|
|
const [aiSummary, created] = await AiSummary.upsert({
|
|
applicationId,
|
|
summary,
|
|
status: 'Generated',
|
|
modelUsed: 'Gemini 1.5 Pro (Mock)'
|
|
}, { returning: true });
|
|
|
|
res.json({ success: true, data: aiSummary });
|
|
} catch (error) {
|
|
console.error('Generate AI summary error:', error);
|
|
res.status(500).json({ success: false, message: 'Error generating AI summary' });
|
|
}
|
|
};
|
|
|
|
export const getAiSummary = async (req: Request, res: Response) => {
|
|
try {
|
|
const { applicationId } = req.params;
|
|
|
|
// Find application UUID first
|
|
const isUUID = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(applicationId as string);
|
|
const app = await db.Application.findOne({
|
|
where: isUUID ? { [Op.or]: [{ id: applicationId }, { applicationId }] } : { applicationId }
|
|
});
|
|
if (!app) return res.status(404).json({ success: false, message: 'Application not found' });
|
|
|
|
const summary = await AiSummary.findOne({
|
|
where: { applicationId: app.id },
|
|
order: [['createdAt', 'DESC']]
|
|
});
|
|
|
|
if (!summary) {
|
|
return res.json({ success: false, message: 'No AI Summary generated yet' });
|
|
}
|
|
|
|
res.json({ success: true, data: summary });
|
|
} catch (error) {
|
|
console.error('Get AI summary error:', error);
|
|
res.status(500).json({ success: false, message: 'Error fetching AI summary' });
|
|
}
|
|
};
|
|
|
|
export const getInterviews = async (req: Request, res: Response) => {
|
|
try {
|
|
const { applicationId } = req.params;
|
|
|
|
// Find application UUID first
|
|
const isUUID = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(applicationId as string);
|
|
const app = await db.Application.findOne({
|
|
where: isUUID ? { [Op.or]: [{ id: applicationId }, { applicationId }] } : { applicationId }
|
|
});
|
|
if (!app) return res.status(404).json({ success: false, message: 'Application not found' });
|
|
|
|
const interviews = await Interview.findAll({
|
|
where: { applicationId: app.id },
|
|
include: [
|
|
{
|
|
model: InterviewParticipant,
|
|
as: 'participants',
|
|
separate: true,
|
|
include: [{ model: User, as: 'user' }]
|
|
},
|
|
{
|
|
model: InterviewEvaluation,
|
|
as: 'evaluations',
|
|
separate: true,
|
|
include: [{
|
|
model: User,
|
|
as: 'evaluator',
|
|
attributes: ['id', 'fullName', 'email'],
|
|
include: [{ model: Role, as: 'role', attributes: ['roleName', 'roleCode'] }]
|
|
},
|
|
{
|
|
model: db.InterviewFeedback,
|
|
as: 'feedbackDetails'
|
|
}]
|
|
},
|
|
{
|
|
model: User,
|
|
as: 'scheduler',
|
|
attributes: ['id', 'fullName', 'email', 'designation']
|
|
}
|
|
],
|
|
order: [['createdAt', 'DESC']]
|
|
});
|
|
res.json({ success: true, data: interviews });
|
|
} catch (error) {
|
|
console.error('Get interviews error:', error);
|
|
res.status(500).json({ success: false, message: 'Error fetching interviews' });
|
|
}
|
|
};
|
|
|
|
export const updateRecommendation = async (req: AuthRequest, res: Response) => {
|
|
try {
|
|
if (!req.user?.id || !req.user?.roleCode) {
|
|
return res.status(401).json({ success: false, message: 'Unauthorized' });
|
|
}
|
|
const { interviewId, recommendation } = req.body; // recommendation: 'Recommended' | 'Not Recommended'
|
|
const normalizedDecision = (['Recommended', 'Approved', 'Selected', 'Approve'].includes(recommendation))
|
|
? 'Approved' : 'Rejected';
|
|
|
|
const result: any = await processInterviewApprovalDecision({
|
|
interviewId,
|
|
decision: normalizedDecision,
|
|
remarks: req.body.remarks,
|
|
userId: req.user.id,
|
|
roleCode: req.user.roleCode
|
|
});
|
|
|
|
if (result.notFound) return res.status(404).json({ success: false, message: 'Interview not found' });
|
|
if (result.forbidden) {
|
|
return res.status(403).json({
|
|
success: false,
|
|
message: result.message || `Role ${result.currentRole} is not allowed to approve ${result.policy?.stageCode || 'this stage'}`
|
|
});
|
|
}
|
|
|
|
res.json({
|
|
success: true,
|
|
message: 'Recommendation updated successfully',
|
|
data: {
|
|
evaluation: result.evaluation,
|
|
stageCode: result.policy.stageCode,
|
|
requiredRoles: result.requiredRoles,
|
|
minApprovals: result.policy.minApprovals,
|
|
approvedRoles: Array.from(result.uniqueApprovalsByRole),
|
|
hasAllRequiredRoleApprovals: result.hasAllRequiredRoleApprovals,
|
|
meetsMinApprovals: result.meetsMinApprovals
|
|
}
|
|
});
|
|
} catch (error) {
|
|
console.error('Update recommendation error:', error);
|
|
res.status(500).json({ success: false, message: 'Error updating recommendation' });
|
|
}
|
|
};
|
|
|
|
export const updateInterviewDecision = async (req: AuthRequest, res: Response) => {
|
|
try {
|
|
if (!req.user?.id || !req.user?.roleCode) {
|
|
return res.status(401).json({ success: false, message: 'Unauthorized' });
|
|
}
|
|
const { interviewId, decision, remarks } = req.body; // decision: 'Approved' | 'Rejected'
|
|
const normalizedDecision = (decision === 'Approved' || decision === 'Approve') ? 'Approved' : 'Rejected';
|
|
const result: any = await processInterviewApprovalDecision({
|
|
interviewId,
|
|
decision: normalizedDecision,
|
|
remarks,
|
|
userId: req.user.id,
|
|
roleCode: req.user.roleCode
|
|
});
|
|
|
|
if (result.notFound) return res.status(404).json({ success: false, message: 'Interview not found' });
|
|
if (result.forbidden) {
|
|
return res.status(403).json({
|
|
success: false,
|
|
message: result.message || `Role ${result.currentRole} is not allowed to approve ${result.policy?.stageCode || 'this stage'}`
|
|
});
|
|
}
|
|
|
|
await db.AuditLog.create({
|
|
userId: req.user?.id,
|
|
action: 'UPDATED',
|
|
entityType: 'interview',
|
|
entityId: interviewId,
|
|
newData: { decision, remarks }
|
|
});
|
|
|
|
res.json({
|
|
success: true,
|
|
message: `Recommendation ${normalizedDecision.toLowerCase()} successfully`,
|
|
data: {
|
|
stageCode: result.policy.stageCode,
|
|
requiredRoles: result.requiredRoles,
|
|
minApprovals: result.policy.minApprovals,
|
|
approvedRoles: Array.from(result.uniqueApprovalsByRole),
|
|
hasAllRequiredRoleApprovals: result.hasAllRequiredRoleApprovals,
|
|
meetsMinApprovals: result.meetsMinApprovals
|
|
}
|
|
});
|
|
} catch (error) {
|
|
console.error('Update interview decision error:', error);
|
|
res.status(500).json({ success: false, message: 'Error updating interview decision' });
|
|
}
|
|
};
|
|
|
|
export const getStageApprovalPolicies = async (req: AuthRequest, res: Response) => {
|
|
try {
|
|
const policies = await StageApprovalPolicy.findAll({
|
|
where: { isActive: true },
|
|
order: [['stageCode', 'ASC']]
|
|
});
|
|
res.json({ success: true, data: policies });
|
|
} catch (error) {
|
|
console.error('Get stage approval policies error:', error);
|
|
res.status(500).json({ success: false, message: 'Error fetching stage approval policies' });
|
|
}
|
|
};
|
|
|
|
export const upsertStageApprovalPolicy = async (req: AuthRequest, res: Response) => {
|
|
try {
|
|
const { stageCode } = req.params;
|
|
const { minApprovals, approvalMode, requiredRoles, isActive } = req.body;
|
|
|
|
const [policy, created] = await StageApprovalPolicy.findOrCreate({
|
|
where: { stageCode },
|
|
defaults: {
|
|
stageCode,
|
|
minApprovals: minApprovals ?? 1,
|
|
approvalMode: approvalMode ?? 'MIN_N',
|
|
requiredRoles: requiredRoles ?? [],
|
|
isActive: isActive ?? true
|
|
}
|
|
});
|
|
|
|
if (!created) {
|
|
await policy.update({
|
|
minApprovals: minApprovals ?? policy.minApprovals,
|
|
approvalMode: approvalMode ?? policy.approvalMode,
|
|
requiredRoles: requiredRoles ?? policy.requiredRoles,
|
|
isActive: isActive ?? policy.isActive
|
|
});
|
|
}
|
|
|
|
res.json({ success: true, data: policy });
|
|
} catch (error) {
|
|
console.error('Upsert stage approval policy error:', error);
|
|
res.status(500).json({ success: false, message: 'Error saving stage approval policy' });
|
|
}
|
|
};
|
|
|
|
export const getInterviewApprovalStatus = async (req: AuthRequest, res: Response) => {
|
|
try {
|
|
const { interviewId } = req.params;
|
|
const interview = await Interview.findByPk(interviewId);
|
|
if (!interview) return res.status(404).json({ success: false, message: 'Interview not found' });
|
|
|
|
const policy = await ensureInterviewPolicy(interview.level);
|
|
const actions = await StageApprovalAction.findAll({
|
|
where: {
|
|
interviewId,
|
|
stageCode: policy.stageCode
|
|
},
|
|
include: [{ model: User, as: 'actor', attributes: ['id', 'fullName', 'email', 'roleCode'] }],
|
|
order: [['updatedAt', 'DESC']]
|
|
});
|
|
|
|
res.json({
|
|
success: true,
|
|
data: {
|
|
interviewId,
|
|
stageCode: policy.stageCode,
|
|
minApprovals: policy.minApprovals,
|
|
approvalMode: policy.approvalMode,
|
|
requiredRoles: policy.requiredRoles || [],
|
|
actions
|
|
}
|
|
});
|
|
} catch (error) {
|
|
console.error('Get interview approval status error:', error);
|
|
res.status(500).json({ success: false, message: 'Error fetching interview approval status' });
|
|
}
|
|
};
|
|
export const submitStageDecision = async (req: AuthRequest, res: Response) => {
|
|
try {
|
|
if (!req.user?.id || !req.user?.roleCode) return res.status(401).json({ success: false, message: 'Unauthorized' });
|
|
|
|
const { applicationId, stageCode, decision, remarks, nextStatus, nextProgress } = req.body;
|
|
|
|
const result: any = await processStageDecision({
|
|
applicationId,
|
|
stageCode,
|
|
decision,
|
|
remarks,
|
|
userId: req.user.id,
|
|
roleCode: req.user.roleCode,
|
|
nextStatus,
|
|
nextProgress
|
|
});
|
|
|
|
if (result.noPolicy) {
|
|
// Fallback: If no policy, just update application status directly (legacy behavior)
|
|
if (nextStatus) {
|
|
const _isUUID_fb = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(applicationId as string);
|
|
const application = await db.Application.findOne({
|
|
where: _isUUID_fb ? { [Op.or]: [{ id: applicationId }, { applicationId: applicationId }] } : { applicationId: applicationId }
|
|
});
|
|
if (application) {
|
|
await WorkflowService.transitionApplication(application, nextStatus, req.user?.id || null, {
|
|
reason: 'Fallback Transition (No Policy)',
|
|
progressPercentage: nextProgress
|
|
});
|
|
}
|
|
}
|
|
return res.json({ success: true, message: 'Status updated (No policy found)' });
|
|
}
|
|
|
|
if (result.forbidden) {
|
|
return res.status(403).json({
|
|
success: false,
|
|
message: result.message || `Role ${result.currentRole} is not allowed to approve ${result.policy?.stageCode || stageCode}`
|
|
});
|
|
}
|
|
|
|
res.json({
|
|
success: true,
|
|
message: result.statusUpdated ? `Stage ${stageCode} completed and status moved to ${nextStatus}` : `Decision recorded for ${stageCode}. Waiting for other approvers.`,
|
|
data: {
|
|
statusUpdated: result.statusUpdated,
|
|
requiredRoles: result.requiredRoles,
|
|
approvedRoles: Array.from(result.uniqueApprovalsByRole),
|
|
meetsMinApprovals: result.meetsMinApprovals,
|
|
hasAllRequiredRoleApprovals: result.hasAllRequiredRoleApprovals
|
|
}
|
|
});
|
|
} catch (error) {
|
|
console.error('Submit stage decision error:', error);
|
|
res.status(500).json({ success: false, message: 'Error processing stage decision' });
|
|
}
|
|
};
|