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:
parent
31109d6109
commit
113e87b66d
64
package-lock.json
generated
64
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
26
scripts/add-decision-column.ts
Normal file
26
scripts/add-decision-column.ts
Normal 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();
|
||||
34
scripts/debug-evaluations.ts
Normal file
34
scripts/debug-evaluations.ts
Normal 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();
|
||||
26
scripts/fix-remarks-column.ts
Normal file
26
scripts/fix-remarks-column.ts
Normal 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();
|
||||
47
scripts/fix-stages-enum.ts
Normal file
47
scripts/fix-stages-enum.ts
Normal 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();
|
||||
29
scripts/migrate-evaluation-schema.ts
Normal file
29
scripts/migrate-evaluation-schema.ts
Normal 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();
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
132
src/controllers/admin/EmailTemplateController.ts
Normal file
132
src/controllers/admin/EmailTemplateController.ts
Normal 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' });
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -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'
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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' });
|
||||
}
|
||||
};
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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
20
src/scripts/test-email.ts
Normal 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();
|
||||
Loading…
Reference in New Issue
Block a user