Dealer_Onboarding_Backend/src/modules/assessment/assessment.controller.ts

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' });
}
};