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 => { 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 = { 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[] = []; // 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' }); } };