From 113e87b66da85f00658ccc7b29c39e2c4e005897 Mon Sep 17 00:00:00 2001 From: laxmanhalaki Date: Thu, 19 Feb 2026 20:46:48 +0530 Subject: [PATCH] email templates enhanced handlebars added tfor better comaptibility and interview evaluation updated with new keys to store approve/reject status --- package-lock.json | 64 ++++++- package.json | 5 +- scripts/add-decision-column.ts | 26 +++ scripts/debug-evaluations.ts | 34 ++++ scripts/fix-remarks-column.ts | 26 +++ scripts/fix-stages-enum.ts | 47 +++++ scripts/migrate-evaluation-schema.ts | 29 +++ src/common/config/constants.ts | 4 + src/common/utils/email.service.ts | 38 ++-- .../admin/EmailTemplateController.ts | 132 +++++++++++++ src/database/models/Document.ts | 5 + src/database/models/InterviewEvaluation.ts | 10 + src/modules/admin/admin.routes.ts | 9 + .../assessment/assessment.controller.ts | 177 ++++++++++++++++++ src/modules/assessment/assessment.routes.ts | 4 +- .../onboarding/onboarding.controller.ts | 3 +- src/scripts/test-email.ts | 20 ++ 17 files changed, 606 insertions(+), 27 deletions(-) create mode 100644 scripts/add-decision-column.ts create mode 100644 scripts/debug-evaluations.ts create mode 100644 scripts/fix-remarks-column.ts create mode 100644 scripts/fix-stages-enum.ts create mode 100644 scripts/migrate-evaluation-schema.ts create mode 100644 src/controllers/admin/EmailTemplateController.ts create mode 100644 src/scripts/test-email.ts diff --git a/package-lock.json b/package-lock.json index 1de4c7f..4158b02 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 233fa94..de1b14d 100644 --- a/package.json +++ b/package.json @@ -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" } -} \ No newline at end of file +} diff --git a/scripts/add-decision-column.ts b/scripts/add-decision-column.ts new file mode 100644 index 0000000..e04068f --- /dev/null +++ b/scripts/add-decision-column.ts @@ -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(); diff --git a/scripts/debug-evaluations.ts b/scripts/debug-evaluations.ts new file mode 100644 index 0000000..560873b --- /dev/null +++ b/scripts/debug-evaluations.ts @@ -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(); diff --git a/scripts/fix-remarks-column.ts b/scripts/fix-remarks-column.ts new file mode 100644 index 0000000..73f093c --- /dev/null +++ b/scripts/fix-remarks-column.ts @@ -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(); diff --git a/scripts/fix-stages-enum.ts b/scripts/fix-stages-enum.ts new file mode 100644 index 0000000..641cc64 --- /dev/null +++ b/scripts/fix-stages-enum.ts @@ -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(); diff --git a/scripts/migrate-evaluation-schema.ts b/scripts/migrate-evaluation-schema.ts new file mode 100644 index 0000000..d24f4ab --- /dev/null +++ b/scripts/migrate-evaluation-schema.ts @@ -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(); diff --git a/src/common/config/constants.ts b/src/common/config/constants.ts index 5d1ec6a..ad560b1 100644 --- a/src/common/config/constants.ts +++ b/src/common/config/constants.ts @@ -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; diff --git a/src/common/utils/email.service.ts b/src/common/utils/email.service.ts index 905f7ab..d7a4c21 100644 --- a/src/common/utils/email.service.ts +++ b/src/common/utils/email.service.ts @@ -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 }); }; diff --git a/src/controllers/admin/EmailTemplateController.ts b/src/controllers/admin/EmailTemplateController.ts new file mode 100644 index 0000000..5a2522a --- /dev/null +++ b/src/controllers/admin/EmailTemplateController.ts @@ -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' }); + } + } +}; diff --git a/src/database/models/Document.ts b/src/database/models/Document.ts index d450882..89c6c01 100644 --- a/src/database/models/Document.ts +++ b/src/database/models/Document.ts @@ -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' diff --git a/src/database/models/InterviewEvaluation.ts b/src/database/models/InterviewEvaluation.ts index 3de45e9..96f3f26 100644 --- a/src/database/models/InterviewEvaluation.ts +++ b/src/database/models/InterviewEvaluation.ts @@ -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 { } @@ -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', diff --git a/src/modules/admin/admin.routes.ts b/src/modules/admin/admin.routes.ts index 36ba09a..71bce9a 100644 --- a/src/modules/admin/admin.routes.ts +++ b/src/modules/admin/admin.routes.ts @@ -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); diff --git a/src/modules/assessment/assessment.controller.ts b/src/modules/assessment/assessment.controller.ts index 8ad5af8..d604534 100644 --- a/src/modules/assessment/assessment.controller.ts +++ b/src/modules/assessment/assessment.controller.ts @@ -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' }); + } +}; diff --git a/src/modules/assessment/assessment.routes.ts b/src/modules/assessment/assessment.routes.ts index 3f7737f..b0eba4c 100644 --- a/src/modules/assessment/assessment.routes.ts +++ b/src/modules/assessment/assessment.routes.ts @@ -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); diff --git a/src/modules/onboarding/onboarding.controller.ts b/src/modules/onboarding/onboarding.controller.ts index 571953f..4d7d7d1 100644 --- a/src/modules/onboarding/onboarding.controller.ts +++ b/src/modules/onboarding/onboarding.controller.ts @@ -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, diff --git a/src/scripts/test-email.ts b/src/scripts/test-email.ts new file mode 100644 index 0000000..9d4e755 --- /dev/null +++ b/src/scripts/test-email.ts @@ -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();