email templates enhanced handlebars added tfor better comaptibility and interview evaluation updated with new keys to store approve/reject status

This commit is contained in:
laxmanhalaki 2026-02-19 20:46:48 +05:30
parent 31109d6109
commit 113e87b66d
17 changed files with 606 additions and 27 deletions

64
package-lock.json generated
View File

@ -16,11 +16,12 @@
"express": "^5.2.1",
"express-rate-limit": "^8.2.1",
"express-validator": "^7.3.1",
"handlebars": "^4.7.8",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.3",
"multer": "^2.0.2",
"nodemailer": "^7.0.12",
"pg": "^8.17.2",
"pg": "^8.18.0",
"pg-hstore": "^2.3.4",
"sequelize": "^6.37.7",
"uuid": "^13.0.0",
@ -5578,6 +5579,27 @@
"dev": true,
"license": "ISC"
},
"node_modules/handlebars": {
"version": "4.7.8",
"resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz",
"integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==",
"license": "MIT",
"dependencies": {
"minimist": "^1.2.5",
"neo-async": "^2.6.2",
"source-map": "^0.6.1",
"wordwrap": "^1.0.0"
},
"bin": {
"handlebars": "bin/handlebars"
},
"engines": {
"node": ">=0.4.7"
},
"optionalDependencies": {
"uglify-js": "^3.1.4"
}
},
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
@ -7114,6 +7136,12 @@
"node": ">= 0.6"
}
},
"node_modules/neo-async": {
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz",
"integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==",
"license": "MIT"
},
"node_modules/node-int64": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
@ -7481,12 +7509,12 @@
}
},
"node_modules/pg": {
"version": "8.17.2",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.17.2.tgz",
"integrity": "sha512-vjbKdiBJRqzcYw1fNU5KuHyYvdJ1qpcQg1CeBrHFqV1pWgHeVR6j/+kX0E1AAXfyuLUGY1ICrN2ELKA/z2HWzw==",
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.18.0.tgz",
"integrity": "sha512-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ==",
"license": "MIT",
"dependencies": {
"pg-connection-string": "^2.10.1",
"pg-connection-string": "^2.11.0",
"pg-pool": "^3.11.0",
"pg-protocol": "^1.11.0",
"pg-types": "2.2.0",
@ -7515,9 +7543,9 @@
"optional": true
},
"node_modules/pg-connection-string": {
"version": "2.10.1",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.10.1.tgz",
"integrity": "sha512-iNzslsoeSH2/gmDDKiyMqF64DATUCWj3YJ0wP14kqcsf2TUklwimd+66yYojKwZCA7h2yRNLGug71hCBA2a4sw==",
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.11.0.tgz",
"integrity": "sha512-kecgoJwhOpxYU21rZjULrmrBJ698U2RxXofKVzOn5UDj61BPj/qMb7diYUR1nLScCDbrztQFl1TaQZT0t1EtzQ==",
"license": "MIT"
},
"node_modules/pg-hstore": {
@ -8287,7 +8315,6 @@
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
@ -8875,6 +8902,19 @@
"node": ">=14.17"
}
},
"node_modules/uglify-js": {
"version": "3.19.3",
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz",
"integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==",
"license": "BSD-2-Clause",
"optional": true,
"bin": {
"uglifyjs": "bin/uglifyjs"
},
"engines": {
"node": ">=0.8.0"
}
},
"node_modules/undefsafe": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",
@ -9099,6 +9139,12 @@
"@types/node": "*"
}
},
"node_modules/wordwrap": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz",
"integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==",
"license": "MIT"
},
"node_modules/wrap-ansi": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",

View File

@ -31,11 +31,12 @@
"express": "^5.2.1",
"express-rate-limit": "^8.2.1",
"express-validator": "^7.3.1",
"handlebars": "^4.7.8",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.3",
"multer": "^2.0.2",
"nodemailer": "^7.0.12",
"pg": "^8.17.2",
"pg": "^8.18.0",
"pg-hstore": "^2.3.4",
"sequelize": "^6.37.7",
"uuid": "^13.0.0",
@ -66,4 +67,4 @@
"node": ">=18.0.0",
"npm": ">=9.0.0"
}
}
}

View File

@ -0,0 +1,26 @@
import { Sequelize } from 'sequelize';
const sequelize = new Sequelize('royal_enfield_onboarding', 'laxman', '<.efvP1D0^80Z)r5', {
host: 'localhost',
dialect: 'postgres',
logging: console.log
});
const run = async () => {
try {
await sequelize.authenticate();
console.log('Connected to database.');
console.log('Adding decision column to interview_evaluations table...');
await sequelize.query('ALTER TABLE "interview_evaluations" ADD COLUMN IF NOT EXISTS "decision" VARCHAR(255);');
console.log('Column added successfully.');
process.exit(0);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
};
run();

View File

@ -0,0 +1,34 @@
import { Sequelize } from 'sequelize';
const sequelize = new Sequelize('royal_enfield_onboarding', 'laxman', '<.efvP1D0^80Z)r5', {
host: 'localhost',
dialect: 'postgres',
logging: false
});
const run = async () => {
try {
await sequelize.authenticate();
console.log('Connected to database.');
const [results] = await sequelize.query(`
SELECT column_name, data_type
FROM information_schema.columns
WHERE table_name = 'interview_evaluations';
`);
console.log('Columns in interview_evaluations:');
console.table(results);
const [evals] = await sequelize.query('SELECT * FROM "interview_evaluations" ORDER BY "createdAt" DESC LIMIT 1;');
console.log('Latest evaluation:');
console.log(evals[0]);
process.exit(0);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
};
run();

View File

@ -0,0 +1,26 @@
import { Sequelize } from 'sequelize';
const sequelize = new Sequelize('royal_enfield_onboarding', 'laxman', '<.efvP1D0^80Z)r5', {
host: 'localhost',
dialect: 'postgres',
logging: console.log
});
const run = async () => {
try {
await sequelize.authenticate();
console.log('Connected to database.');
console.log('Adding remarks column to interview_evaluations table...');
await sequelize.query('ALTER TABLE "interview_evaluations" ADD COLUMN IF NOT EXISTS "remarks" TEXT;');
console.log('Column added successfully.');
process.exit(0);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
};
run();

View File

@ -0,0 +1,47 @@
import 'dotenv/config';
import { Sequelize } from 'sequelize';
const sequelize = new Sequelize('royal_enfield_onboarding', 'laxman', '<.efvP1D0^80Z)r5', {
host: 'localhost',
dialect: 'postgres',
logging: console.log
});
const run = async () => {
try {
await sequelize.authenticate();
console.log('Connected to database.');
const stagesToAdd = [
'Level 1 Approved',
'Level 2 Approved',
'Level 2 Recommended',
'Level 3 Approved'
];
for (const stage of stagesToAdd) {
try {
await sequelize.query(`ALTER TYPE "enum_applications_currentStage" ADD VALUE IF NOT EXISTS '${stage}';`);
console.log(`Added '${stage}' to enum_applications_currentStage`);
} catch (e: any) {
console.log(`'${stage}' might already exist in enum_applications_currentStage or error:`, e.message);
}
try {
await sequelize.query(`ALTER TYPE "enum_applications_overallStatus" ADD VALUE IF NOT EXISTS '${stage}';`);
console.log(`Added '${stage}' to enum_applications_overallStatus`);
} catch (e: any) {
console.log(`'${stage}' might already exist in enum_applications_overallStatus or error:`, e.message);
}
}
console.log('Successfully updated enums.');
process.exit(0);
} catch (error) {
console.error('Error:', error);
process.exit(1);
}
};
run();

View File

@ -0,0 +1,29 @@
import { Sequelize } from 'sequelize';
const sequelize = new Sequelize('royal_enfield_onboarding', 'laxman', '<.efvP1D0^80Z)r5', {
host: 'localhost',
dialect: 'postgres',
logging: console.log
});
const run = async () => {
try {
await sequelize.authenticate();
console.log('Connected to database.');
console.log('Renaming recommendation to decision and remarks to decisionRemarks in interview_evaluations...');
// Use a transaction for safety
await sequelize.query('ALTER TABLE "interview_evaluations" RENAME COLUMN "recommendation" TO "decision";');
await sequelize.query('ALTER TABLE "interview_evaluations" RENAME COLUMN "remarks" TO "decisionRemarks";');
console.log('Columns renamed successfully.');
process.exit(0);
} catch (error) {
console.error('Error during migration:', error);
process.exit(1);
}
};
run();

View File

@ -35,6 +35,10 @@ export const APPLICATION_STAGES = {
NBH: 'NBH',
LEGAL: 'Legal',
FINANCE: 'Finance',
LEVEL_1_APPROVED: 'Level 1 Approved',
LEVEL_2_APPROVED: 'Level 2 Approved',
LEVEL_2_RECOMMENDED: 'Level 2 Recommended',
LEVEL_3_APPROVED: 'Level 3 Approved',
APPROVED: 'Approved',
REJECTED: 'Rejected'
} as const;

View File

@ -3,6 +3,7 @@ import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import db from '../../database/models/index.js';
import handlebars from 'handlebars';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@ -43,22 +44,22 @@ export const sendEmail = async (to: string, subject: string, templateCode: strin
const dbTemplate = await EmailTemplate.findOne({ where: { templateCode, isActive: true } });
if (dbTemplate) {
finalHtml = dbTemplate.body;
finalSubject = dbTemplate.subject;
// Replace placeholders in DB template
// Prepare replacements with extra global vars
const allReplacements = {
...replacements,
year: new Date().getFullYear().toString()
};
for (const key in allReplacements) {
const regex = new RegExp(`{{${key}}}`, 'g');
finalHtml = finalHtml.replace(regex, (allReplacements as any)[key]);
finalSubject = finalSubject.replace(regex, (allReplacements as any)[key]);
}
// Compile subject and body with data using Handlebars
const subjectTemplate = handlebars.compile(dbTemplate.subject);
finalSubject = subjectTemplate(allReplacements);
const bodyTemplate = handlebars.compile(dbTemplate.body);
finalHtml = bodyTemplate(allReplacements);
} else {
// Fallback to local file
// Note: Local files are simple replacements for now, or could also be updated to handlebars if needed
// For now keeping readTemplate but we should ideally migrate local templates too if they get complex
const localHtml = readTemplate(templateCode, {
...replacements,
year: new Date().getFullYear().toString()
@ -83,6 +84,9 @@ export const sendEmail = async (to: string, subject: string, templateCode: strin
html: finalHtml
});
console.log(`[Email Service] Email sent to ${to}. MessageId: ${info.messageId}`);
console.log(`[Email Service] Preview URL: ${nodemailer.getTestMessageUrl(info)}`);
return info;
} catch (error) {
console.error(`Failed to send email (${templateCode}):`, error);
@ -107,13 +111,19 @@ export const sendNonOpportunityEmail = async (to: string, applicantName: string,
};
export const sendInterviewScheduledEmail = async (to: string, name: string, applicationId: string, interview: any) => {
const date = new Date(interview.scheduleDate);
const time = date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' });
const formattedDate = date.toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
await sendEmail(to, `Interview Scheduled: ${applicationId}`, 'INTERVIEW_SCHEDULED', {
name,
applicationId,
applicant_name: name,
application_id: applicationId,
level: interview.level,
dateTime: new Date(interview.scheduledAt).toLocaleString(),
type: interview.type,
location: interview.location
interview_date: formattedDate,
interview_time: time,
type: interview.interviewType,
location: interview.linkOrLocation,
status: interview.status
});
};

View File

@ -0,0 +1,132 @@
import { Request, Response } from 'express';
import db from '../../database/models/index.js';
import handlebars from 'handlebars';
const { EmailTemplate } = db;
export const EmailTemplateController = {
// Get all templates
getAllTemplates: async (req: Request, res: Response) => {
try {
const templates = await EmailTemplate.findAll({
order: [['templateCode', 'ASC']]
});
res.json({ success: true, data: templates });
} catch (error) {
console.error('Error fetching templates:', error);
res.status(500).json({ success: false, message: 'Failed to fetch templates' });
}
},
// Get single template
getTemplate: async (req: Request, res: Response) => {
try {
const { id } = req.params;
const template = await EmailTemplate.findByPk(id);
if (!template) {
return res.status(404).json({ success: false, message: 'Template not found' });
}
res.json({ success: true, data: template });
} catch (error) {
console.error('Error fetching template:', error);
res.status(500).json({ success: false, message: 'Failed to fetch template' });
}
},
// Create template
createTemplate: async (req: Request, res: Response) => {
try {
const template = await EmailTemplate.create(req.body);
res.status(201).json({ success: true, data: template, message: 'Template created successfully' });
} catch (error) {
console.error('Error creating template:', error);
res.status(500).json({ success: false, message: 'Failed to create template' });
}
},
// Update template
updateTemplate: async (req: Request, res: Response) => {
try {
const { id } = req.params;
const [updated] = await EmailTemplate.update(req.body, {
where: { id }
});
if (updated) {
const updatedTemplate = await EmailTemplate.findByPk(id);
res.json({ success: true, data: updatedTemplate, message: 'Template updated successfully' });
} else {
res.status(404).json({ success: false, message: 'Template not found' });
}
} catch (error) {
console.error('Error updating template:', error);
res.status(500).json({ success: false, message: 'Failed to update template' });
}
},
// Delete template
deleteTemplate: async (req: Request, res: Response) => {
try {
const { id } = req.params;
const deleted = await EmailTemplate.destroy({
where: { id }
});
if (deleted) {
res.json({ success: true, message: 'Template deleted successfully' });
} else {
res.status(404).json({ success: false, message: 'Template not found' });
}
} catch (error) {
console.error('Error deleting template:', error);
res.status(500).json({ success: false, message: 'Failed to delete template' });
}
},
// Preview template
previewTemplate: async (req: Request, res: Response) => {
try {
const { subject, body, data } = req.body;
if (!body) {
return res.status(400).json({ success: false, message: 'Template body is required' });
}
// Compile content
let compiledSubject = subject;
let compiledBody = body;
const safeData = data || {};
try {
if (subject) {
const subjectTemplate = handlebars.compile(subject);
compiledSubject = subjectTemplate(safeData);
}
const bodyTemplate = handlebars.compile(body);
compiledBody = bodyTemplate(safeData);
res.json({
success: true,
data: {
subject: compiledSubject,
html: compiledBody
}
});
} catch (compileError: any) {
res.status(400).json({
success: false,
message: 'Template compilation failed',
error: compileError.message
});
}
} catch (error) {
console.error('Error previewing template:', error);
res.status(500).json({ success: false, message: 'Failed to preview template' });
}
}
};

View File

@ -12,6 +12,7 @@ export interface DocumentAttributes {
filePath: string;
fileSize: number | null;
mimeType: string | null;
stage: string | null;
status: string;
uploadedBy: string | null;
}
@ -69,6 +70,10 @@ export default (sequelize: Sequelize) => {
type: DataTypes.STRING,
allowNull: true
},
stage: {
type: DataTypes.STRING,
allowNull: true
},
status: {
type: DataTypes.STRING,
defaultValue: 'active'

View File

@ -7,6 +7,8 @@ export interface InterviewEvaluationAttributes {
ktMatrixScore: number | null;
qualitativeFeedback: string | null;
recommendation: string | null;
remarks: string | null;
decision: string | null;
}
export interface InterviewEvaluationInstance extends Model<InterviewEvaluationAttributes>, InterviewEvaluationAttributes { }
@ -45,6 +47,14 @@ export default (sequelize: Sequelize) => {
recommendation: {
type: DataTypes.STRING,
allowNull: true
},
remarks: {
type: DataTypes.TEXT,
allowNull: true
},
decision: {
type: DataTypes.STRING,
allowNull: true
}
}, {
tableName: 'interview_evaluations',

View File

@ -2,6 +2,7 @@ import express from 'express';
const router = express.Router();
import * as adminController from './admin.controller.js';
import { authenticate } from '../../common/middleware/auth.js';
import { EmailTemplateController } from '../../controllers/admin/EmailTemplateController.js';
import { checkRole } from '../../common/middleware/roleCheck.js';
import { ROLES } from '../../common/config/constants.js';
@ -23,6 +24,14 @@ router.get('/users', checkRole([ROLES.SUPER_ADMIN, ROLES.DD_ADMIN]) as any, admi
router.patch('/users/:id/status', checkRole([ROLES.SUPER_ADMIN]) as any, adminController.updateUserStatus);
router.put('/users/:id', checkRole([ROLES.SUPER_ADMIN]) as any, adminController.updateUser);
// Email Templates
router.get('/email-templates', checkRole([ROLES.SUPER_ADMIN, ROLES.DD_ADMIN]) as any, EmailTemplateController.getAllTemplates);
router.get('/email-templates/:id', checkRole([ROLES.SUPER_ADMIN, ROLES.DD_ADMIN]) as any, EmailTemplateController.getTemplate);
router.post('/email-templates', checkRole([ROLES.SUPER_ADMIN]) as any, EmailTemplateController.createTemplate);
router.put('/email-templates/:id', checkRole([ROLES.SUPER_ADMIN]) as any, EmailTemplateController.updateTemplate);
router.delete('/email-templates/:id', checkRole([ROLES.SUPER_ADMIN]) as any, EmailTemplateController.deleteTemplate);
router.post('/email-templates/preview', checkRole([ROLES.SUPER_ADMIN, ROLES.DD_ADMIN]) as any, EmailTemplateController.previewTemplate);
// Dealer Codes
router.post('/dealer-codes/generate', checkRole([ROLES.SUPER_ADMIN, ROLES.DD_ADMIN]) as any, adminController.generateDealerCode);

View File

@ -371,3 +371,180 @@ export const getInterviews = async (req: Request, res: Response) => {
res.status(500).json({ success: false, message: 'Error fetching interviews' });
}
};
export const updateRecommendation = async (req: AuthRequest, res: Response) => {
try {
const { interviewId, recommendation } = req.body; // recommendation: 'Recommended' | 'Not Recommended'
const interview = await Interview.findByPk(interviewId, {
include: [
{ model: InterviewParticipant, as: 'participants' },
{ model: InterviewEvaluation, as: 'evaluations' }
]
});
if (!interview) return res.status(404).json({ success: false, message: 'Interview not found' });
// 1. Update or Create Evaluation for Current User
let evaluation = await InterviewEvaluation.findOne({
where: { interviewId, evaluatorId: req.user?.id }
});
if (evaluation) {
await evaluation.update({ recommendation });
} else {
evaluation = await InterviewEvaluation.create({
interviewId,
evaluatorId: req.user?.id,
recommendation
});
}
// 2. Check for Consensus
// Refresh interview evaluations to include the one just updated/created
const updatedInterview = await Interview.findByPk(interviewId, {
include: [
{ model: InterviewParticipant, as: 'participants' },
{ model: InterviewEvaluation, as: 'evaluations' }
]
});
const participants = updatedInterview?.participants || [];
const evaluations = updatedInterview?.evaluations || [];
// Filter valid panelists (exclude observers if any role logic exists, assuming all participants differ from scheduler are panelists)
const panelistIds = participants.map((p: any) => p.userId);
// Check if all panelists have evaluated with 'Selected' or equivalent positive recommendation
// Adjust logic based on exact recommendation string values used in frontend ('Selected', 'Rejected', etc.)
const allApproved = panelistIds.every((userId: string) => {
const userEval = evaluations.find((e: any) => e.evaluatorId === userId);
return userEval && (userEval.recommendation === 'Selected' || userEval.recommendation === 'Recommended');
});
const anyRejected = evaluations.some((e: any) => panelistIds.includes(e.evaluatorId) && (e.recommendation === 'Rejected' || e.recommendation === 'Not Recommended'));
if (anyRejected) {
await db.Application.update({
overallStatus: 'Rejected',
currentStage: 'Rejected'
}, { where: { id: interview.applicationId } });
await interview.update({ status: 'Completed', outcome: 'Rejected' });
} else if (allApproved) {
// Determine next status based on current level
const nextStatusMap: any = {
1: 'Level 1 Approved',
2: 'Level 2 Approved',
3: 'Level 3 Approved'
};
const newStatus = nextStatusMap[interview.level] || 'Approved';
await db.Application.update({
overallStatus: newStatus,
// Optionally update currentStage if it maps 1:1
}, { where: { id: interview.applicationId } });
await interview.update({ status: 'Completed', outcome: 'Selected' });
}
res.json({ success: true, message: 'Recommendation updated successfully', data: evaluation });
} 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 {
const { interviewId, decision, remarks } = req.body; // decision: 'Approved' | 'Rejected'
const recommendation = decision === 'Approved' ? 'Approved' : 'Rejected';
const interview = await Interview.findByPk(interviewId);
if (!interview) return res.status(404).json({ success: false, message: 'Interview not found' });
// Update or Create Evaluation for the current user
let evaluation = await db.InterviewEvaluation.findOne({
where: {
interviewId,
evaluatorId: req.user?.id
}
});
if (evaluation) {
await evaluation.update({ recommendation, decision, remarks });
} else {
evaluation = await db.InterviewEvaluation.create({
interviewId,
evaluatorId: req.user?.id,
recommendation,
decision,
remarks
});
}
// Always mark interview as completed when a decision is made
// This ensures action buttons hide for the user
await interview.update({ status: 'Completed' });
// Update Application Status
if (decision === 'Rejected') {
await db.Application.update({
overallStatus: 'Rejected',
currentStage: 'Rejected'
}, { where: { id: interview.applicationId } });
// Log Status History
await db.ApplicationStatusHistory.create({
applicationId: interview.applicationId,
previousStatus: 'Interview Pending',
newStatus: 'Rejected',
changedBy: req.user?.id,
reason: remarks || 'Interview Rejected'
});
} else {
// Determine next status based on current level
const nextStatusMap: any = {
1: 'Level 1 Approved',
2: 'Level 2 Approved',
3: 'Level 3 Approved'
};
const newStatus = nextStatusMap[interview.level] || 'Approved';
// Also update currentStage for better tracking
const stageMapping: any = {
1: 'Level 1 Approved',
2: 'Level 2 Approved',
3: 'Level 3 Approved'
};
await db.Application.update({
overallStatus: newStatus,
currentStage: stageMapping[interview.level] || newStatus
}, { where: { id: interview.applicationId } });
// Log Status History
await db.ApplicationStatusHistory.create({
applicationId: interview.applicationId,
previousStatus: 'Interview Pending',
newStatus: newStatus,
changedBy: req.user?.id,
reason: remarks || 'Interview Approved'
});
}
await db.AuditLog.create({
userId: req.user?.id,
action: 'UPDATED',
entityType: 'interview',
entityId: interviewId,
newData: { decision, remarks }
});
res.json({ success: true, message: `Recommendation ${decision.toLowerCase()} successfully` });
} catch (error) {
console.error('Update interview decision error:', error);
res.status(500).json({ success: false, message: 'Error updating interview decision' });
}
};

View File

@ -10,12 +10,14 @@ router.get('/questionnaire', assessmentController.getQuestionnaire);
router.post('/questionnaire/response', assessmentController.submitQuestionnaireResponse);
// Interviews
router.get('/interviews/:applicationId', assessmentController.getInterviews);
router.post('/interviews', assessmentController.scheduleInterview);
router.put('/interviews/:id', assessmentController.updateInterview);
router.post('/interviews/:id/evaluation', assessmentController.submitEvaluation);
router.get('/interviews/:applicationId', assessmentController.getInterviews);
router.post('/kt-matrix', assessmentController.submitKTMatrix);
router.post('/level2-feedback', assessmentController.submitLevel2Feedback);
router.post('/recommendation', assessmentController.updateRecommendation);
router.post('/decision', assessmentController.updateInterviewDecision);
// AI Summary
router.get('/ai-summary/:applicationId', assessmentController.getAiSummary);

View File

@ -257,7 +257,7 @@ export const updateApplicationStatus = async (req: AuthRequest, res: Response) =
export const uploadDocuments = async (req: any, res: Response) => {
try {
const { id } = req.params;
const { documentType } = req.body;
const { documentType, stage } = req.body;
const file = req.file;
if (!file) {
@ -287,6 +287,7 @@ export const uploadDocuments = async (req: any, res: Response) => {
requestId: application.id,
requestType: 'application',
documentType,
stage: stage || null,
fileName: file.originalname,
filePath: file.path, // Store relative path or full path as needed by your storage strategy
fileSize: file.size,

20
src/scripts/test-email.ts Normal file
View File

@ -0,0 +1,20 @@
import { sendOpportunityEmail } from '../common/utils/email.service.js';
const test = async () => {
console.log('Starting email test...');
try {
// Wait for transporter to initialize (it's async in the module)
// In a real app, the server startup time usually covers this, but for a script we might race.
// The current implementation of email.service.ts doesn't export the promise, so we might need to hack a delay.
console.log('Waiting for transporter initialization...');
await new Promise(resolve => setTimeout(resolve, 3000));
await sendOpportunityEmail('test@example.com', 'Test User', 'Test City', 'APP-TEST-123');
console.log('Email send function called.');
} catch (error) {
console.error('Test failed:', error);
}
};
test();