From 74202de59b8b57a5ef57bcfe7ae05407b68d555c Mon Sep 17 00:00:00 2001 From: laxmanhalaki Date: Tue, 17 Feb 2026 20:44:26 +0530 Subject: [PATCH] enhanced questionnarie and added participant flow --- restore-full-questionnaire.ts | 245 ++++++++++++++++++ src/common/config/database.ts | 4 +- src/common/utils/email.service.ts | 113 +++++--- src/database/models/Application.ts | 5 + src/database/models/RequestParticipant.ts | 65 +++++ src/database/models/index.ts | 2 + src/emailtemplates/interview_scheduled.html | 29 +++ src/emailtemplates/user_assigned.html | 54 ++++ .../assessment/assessment.controller.ts | 30 ++- .../collaboration/collaboration.controller.ts | 68 ++++- .../collaboration/collaboration.routes.ts | 6 +- .../onboarding/onboarding.controller.ts | 25 +- src/scripts/seed-master-emails.ts | 66 +++++ 13 files changed, 673 insertions(+), 39 deletions(-) create mode 100644 restore-full-questionnaire.ts create mode 100644 src/database/models/RequestParticipant.ts create mode 100644 src/emailtemplates/interview_scheduled.html create mode 100644 src/emailtemplates/user_assigned.html create mode 100644 src/scripts/seed-master-emails.ts diff --git a/restore-full-questionnaire.ts b/restore-full-questionnaire.ts new file mode 100644 index 0000000..0a35b52 --- /dev/null +++ b/restore-full-questionnaire.ts @@ -0,0 +1,245 @@ +import 'dotenv/config'; +import db from './src/database/models/index.js'; +import { v4 as uuidv4 } from 'uuid'; + +async function restoreQuestionnaire() { + console.log('🌱 Restoring Full Questionnaire (24 questions)...'); + try { + await db.sequelize.authenticate(); + + // 1. Deactivate old versions + await db.Questionnaire.update({ isActive: false }, { where: { isActive: true } }); + + // 2. Create the Questionnaire + const questionnaire = await db.Questionnaire.create({ + id: uuidv4(), + version: 'V2.0-Restored', + isActive: true + }); + + const questions = [ + { text: "Email", type: "email", section: "Basic Information", weight: 0, order: 1 }, + { text: "Name", type: "text", section: "Basic Information", weight: 0, order: 2 }, + { text: "Current Location", type: "text", section: "Basic Information", weight: 0, order: 3 }, + { text: "Location Applied For", type: "text", section: "Basic Information", weight: 0, order: 4 }, + { + text: "State (Applied for)", + type: "select", + section: "Basic Information", + weight: 0, + order: 5, + options: ["Andaman & Nicobar", "Andhra Pradesh", "Arunachal Pradesh", "Assam", "Bihar", "Chandigarh", "Chhattisgarh", "Delhi & NCR", "Goa", "Gujarat", "Himachal Pradesh", "Haryana", "Jammu & Kashmir", "Jharkhand", "Karnataka", "Kerala", "Ladakh", "Madhya Pradesh", "Maharashtra", "Mizoram", "Meghalaya", "Manipur", "Nagaland", "Odisha", "Puducherry", "Punjab", "Rajasthan", "Sikkim", "Tamilnadu", "Telangana", "Tripura", "Uttar Pradesh", "Uttarakhand", "West Bengal"].map(s => ({ text: s, score: 0 })) + }, + { text: "Contact Number", type: "text", section: "Basic Information", weight: 0, order: 6 }, + { text: "Age", type: "number", section: "Basic Information", weight: 0, order: 7 }, + { + text: "Educational Qualification", + type: "radio", + section: "Profile & Background", + weight: 15, + order: 8, + options: [ + { text: "Under Graduate", score: 5 }, + { text: "Graduate", score: 10 }, + { text: "Post Graduate", score: 15 } + ] + }, + { + text: "What is your Personal Networth", + type: "select", + section: "Financials", + weight: 25, + order: 9, + options: [ + { text: "Less than 2 Crores", score: 5 }, + { text: "Between 2 - 5 Crores", score: 10 }, + { text: "Between 5 - 10 Crores", score: 15 }, + { text: "Between 10 - 15 Crores", score: 20 }, + { text: "Greater than 15 Crores", score: 25 } + ] + }, + { + text: "Are you a native of the Proposed Location?", + type: "radio", + section: "Location", + weight: 10, + order: 10, + options: [ + { text: "Native", score: 10 }, + { text: "Willing to Relocate", score: 5 }, + { text: "Will manage Remotely", score: 0 } + ] + }, + { text: "Proposed Location Photos (If any)", type: "file", section: "Location", weight: 0, order: 11 }, + { + text: "Why do you want to partner with Royal Enfield?", + type: "radio", + section: "Strategy", + weight: 10, + order: 12, + options: [ + { text: "Absence of Royal Enfield in the particular location and presence of opportunity", score: 5 }, + { text: "Passionate about the brand", score: 5 }, + { text: "Experience in the automobile business and would like to expand with Royal Enfield", score: 10 } + ] + }, + { + text: "Who will be the partners in proposed company?", + type: "radio", + section: "Business Structure", + weight: 5, + order: 13, + options: [ + { text: "Immediate Family", score: 5 }, + { text: "Extended Family", score: 3 }, + { text: "Friends", score: 2 }, + { text: "Proprietorship", score: 5 } + ] + }, + { + text: "Who will be managing the Royal Enfield dealership", + type: "radio", + section: "Business Structure", + weight: 10, + order: 14, + options: [ + { text: "I will be managing full time", score: 10 }, + { text: "I will be managing with my partners", score: 7 }, + { text: "I will hire a manager full time and oversee the operations", score: 5 } + ] + }, + { + text: "Proposed Firm Type", + type: "radio", + section: "Business Structure", + weight: 10, + order: 15, + options: [ + { text: "Proprietorship", score: 5 }, + { text: "Partnership", score: 5 }, + { text: "Limited Liability partnership", score: 5 }, + { text: "Private Limited Company", score: 10 } + ] + }, + { + text: "What are you currently doing?", + type: "radio", + section: "Experience", + weight: 10, + order: 16, + options: [ + { text: "Running automobile dealership", score: 10 }, + { text: "Running another business not in automobile field", score: 5 }, + { text: "Presently working, willing to resign and handle business", score: 5 }, + { text: "Currently not working and looking for business opportunities", score: 2 } + ] + }, + { + text: "Do you own a property in proposed location?", + type: "radio", + section: "Location", + weight: 10, + order: 17, + options: [ + { text: "Yes", score: 10 }, + { text: "No, will rent a location in the desired location", score: 5 } + ] + }, + { + text: "How are you planning to invest in the Royal Enfield business", + type: "radio", + section: "Financials", + weight: 10, + order: 18, + options: [ + { text: "I will be investing my own funds", score: 10 }, + { text: "I will invest partially and get the rest from the bank", score: 7 }, + { text: "I will be requiring complete funds from the bank", score: 3 } + ] + }, + { + text: "What are your plans of expansion with RE?", + type: "radio", + section: "Strategy", + weight: 10, + order: 19, + options: [ + { text: "Willing to expand with the help of partners", score: 5 }, + { text: "Willing to expand by myself", score: 10 }, + { text: "No plans for expansion", score: 0 } + ] + }, + { + text: "Will you be expanding to any other automobile OEM in the future?", + type: "radio", + section: "Strategy", + weight: 5, + order: 20, + options: [ + { text: "Yes", score: 0 }, + { text: "No", score: 5 } + ] + }, + { + text: "Do you own a Royal Enfield ?", + type: "radio", + section: "Brand Loyalty", + weight: 5, + order: 21, + options: [ + { text: "Yes, it is registered in my name", score: 5 }, + { text: "Yes, it is registered to my immediate family member", score: 3 }, + { text: "Not at the moment but owned it earlier", score: 2 }, + { text: "No", score: 0 } + ] + }, + { + text: "Do you go for long leisure rides", + type: "radio", + section: "Brand Loyalty", + weight: 5, + order: 22, + options: [ + { text: "Yes, with the Royal Enfield riders", score: 5 }, + { text: "Yes, with other brands", score: 3 }, + { text: "No", score: 0 } + ] + }, + { text: "What special initiatives do you plan to implement if selected as business partner for Royal Enfield ?", type: "textarea", section: "Strategy", weight: 0, order: 23 }, + { text: "Please elaborate your present business/employment.", type: "textarea", section: "Experience", weight: 0, order: 24 } + ]; + + for (const q of questions) { + const question = await db.QuestionnaireQuestion.create({ + id: uuidv4(), + questionnaireId: questionnaire.id, + sectionName: q.section, + questionText: q.text, + inputType: q.type, + weight: q.weight, + order: q.order, + isMandatory: true + }); + + if (q.options) { + const optionsWithId = q.options.map((opt, idx) => ({ + id: uuidv4(), + questionId: question.id, + optionText: opt.text, + score: opt.score, + order: idx + 1 + })); + const OptionModel = db.QuestionnaireOption; + await OptionModel.bulkCreate(optionsWithId); + } + } + + console.log('✅ Full Questionnaire restored successfully!'); + process.exit(0); + } catch (error) { + console.error('❌ Restoration failed:', error); + process.exit(1); + } +} + +restoreQuestionnaire(); diff --git a/src/common/config/database.ts b/src/common/config/database.ts index 4a6b35c..41e51f0 100644 --- a/src/common/config/database.ts +++ b/src/common/config/database.ts @@ -5,12 +5,12 @@ import { DbConfig } from '../../types/common.types.js'; const config: DbConfig = { development: { username: process.env.DB_USER || 'laxman', - password: process.env.DB_PASSWORD || 'Admin@123', + password: process.env.DB_PASSWORD || '<.efvP1D0^80Z)r5', database: process.env.DB_NAME || 'royal_enfield_onboarding', host: process.env.DB_HOST || 'localhost', port: parseInt(process.env.DB_PORT || '5432'), dialect: 'postgres', - logging: console.log, + logging: false, pool: { max: 5, min: 0, diff --git a/src/common/utils/email.service.ts b/src/common/utils/email.service.ts index 0a16adf..905f7ab 100644 --- a/src/common/utils/email.service.ts +++ b/src/common/utils/email.service.ts @@ -2,6 +2,7 @@ import nodemailer from 'nodemailer'; import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; +import db from '../../database/models/index.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -19,12 +20,13 @@ nodemailer.createTestAccount().then((account) => { pass: account.pass, }, }); - console.log('Email Transporter Ready (Ethereal Test Account)'); - console.log(`Preview URL: https://ethereal.email/messages`); }).catch(err => console.error('Failed to create test account:', err)); +const { EmailTemplate } = db; + const readTemplate = (templateName: string, replacements: Record) => { const templatePath = path.join(__dirname, '../../emailtemplates', `${templateName}.html`); + if (!fs.existsSync(templatePath)) return null; let html = fs.readFileSync(templatePath, 'utf-8'); for (const key in replacements) { html = html.replace(new RegExp(`{{${key}}}`, 'g'), replacements[key]); @@ -32,43 +34,94 @@ const readTemplate = (templateName: string, replacements: Record return html; }; +export const sendEmail = async (to: string, subject: string, templateCode: string, replacements: Record) => { + try { + let finalHtml = ''; + let finalSubject = subject; + + // Try fetching from DB first (Master Configuration) + const dbTemplate = await EmailTemplate.findOne({ where: { templateCode, isActive: true } }); + + if (dbTemplate) { + finalHtml = dbTemplate.body; + finalSubject = dbTemplate.subject; + + // Replace placeholders in DB template + 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]); + } + } else { + // Fallback to local file + const localHtml = readTemplate(templateCode, { + ...replacements, + year: new Date().getFullYear().toString() + }); + + if (localHtml) { + finalHtml = localHtml; + } else { + throw new Error(`Template not found: ${templateCode}`); + } + } + + if (!transporter) { + console.warn('Email transporter not initialized. Using fallback mock.'); + return; + } + + const info = await transporter.sendMail({ + from: '"Royal Enfield Onboarding" ', + to, + subject: finalSubject, + html: finalHtml + }); + + return info; + } catch (error) { + console.error(`Failed to send email (${templateCode}):`, error); + } +}; + export const sendOpportunityEmail = async (to: string, applicantName: string, location: string, applicationId: string) => { const link = `http://localhost:5173/questionnaire/${applicationId}`; - const html = readTemplate('opportunity', { + await sendEmail(to, 'Action Required: Royal Enfield Dealership Opportunity', 'opportunity', { applicantName, location, applicationId, - link, - year: new Date().getFullYear().toString() + link }); - - if (!transporter) return; - - const info = await transporter.sendMail({ - from: '"Royal Enfield Onboarding" ', - to, - subject: 'Action Required: Royal Enfield Dealership Opportunity', - html - }); - - console.log(`[Email Sent] Opportunity: ${nodemailer.getTestMessageUrl(info)}`); }; export const sendNonOpportunityEmail = async (to: string, applicantName: string, location: string) => { - const html = readTemplate('non_opportunity', { + await sendEmail(to, 'Update on your Royal Enfield Dealership Application', 'non_opportunity', { applicantName, - location, - year: new Date().getFullYear().toString() + location + }); +}; + +export const sendInterviewScheduledEmail = async (to: string, name: string, applicationId: string, interview: any) => { + await sendEmail(to, `Interview Scheduled: ${applicationId}`, 'INTERVIEW_SCHEDULED', { + name, + applicationId, + level: interview.level, + dateTime: new Date(interview.scheduledAt).toLocaleString(), + type: interview.type, + location: interview.location + }); +}; + +export const sendUserAssignedEmail = async (to: string, userName: string, applicationId: string, dealerName: string, participantType: string) => { + await sendEmail(to, `New Application Assignment: ${applicationId}`, 'USER_ASSIGNED', { + userName, + applicationId, + dealerName, + participantType }); - - if (!transporter) return; - - const info = await transporter.sendMail({ - from: '"Royal Enfield Onboarding" ', - to, - subject: 'Update on your Royal Enfield Dealership Application', - html - }); - - console.log(`[Email Sent] Non-Opportunity: ${nodemailer.getTestMessageUrl(info)}`); }; diff --git a/src/database/models/Application.ts b/src/database/models/Application.ts index eb90869..e00e7b8 100644 --- a/src/database/models/Application.ts +++ b/src/database/models/Application.ts @@ -246,6 +246,11 @@ export default (sequelize: Sequelize) => { }); Application.hasMany(models.QuestionnaireResponse, { foreignKey: 'applicationId', as: 'questionnaireResponses' }); + Application.hasMany(models.RequestParticipant, { + foreignKey: 'requestId', + as: 'participants', + scope: { requestType: 'application' } + }); }; return Application; diff --git a/src/database/models/RequestParticipant.ts b/src/database/models/RequestParticipant.ts new file mode 100644 index 0000000..0561d04 --- /dev/null +++ b/src/database/models/RequestParticipant.ts @@ -0,0 +1,65 @@ +import { Model, DataTypes, Sequelize } from 'sequelize'; + +export interface RequestParticipantAttributes { + id: string; + requestId: string; + requestType: string; + userId: string; + participantType: string; + joinedMethod: string; + metadata: any | null; +} + +export interface RequestParticipantInstance extends Model, RequestParticipantAttributes { } + +export default (sequelize: Sequelize) => { + const RequestParticipant = sequelize.define('RequestParticipant', { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true + }, + requestId: { + type: DataTypes.UUID, + allowNull: false + }, + requestType: { + type: DataTypes.STRING, + allowNull: false + }, + userId: { + type: DataTypes.UUID, + allowNull: false, + references: { + model: 'users', + key: 'id' + } + }, + participantType: { + type: DataTypes.ENUM('owner', 'assignee', 'reviewer', 'contributor', 'observer'), + defaultValue: 'contributor' + }, + joinedMethod: { + type: DataTypes.ENUM('manual', 'auto', 'worknote', 'interview', 'mention'), + defaultValue: 'auto' + }, + metadata: { + type: DataTypes.JSON, + allowNull: true + } + }, { + tableName: 'request_participants', + timestamps: true, + indexes: [ + { fields: ['requestId', 'requestType'] }, + { fields: ['userId'] }, + { fields: ['participantType'] } + ] + }); + + (RequestParticipant as any).associate = (models: any) => { + RequestParticipant.belongsTo(models.User, { foreignKey: 'userId', as: 'user' }); + }; + + return RequestParticipant; +}; diff --git a/src/database/models/index.ts b/src/database/models/index.ts index a6d0616..1e8e59e 100644 --- a/src/database/models/index.ts +++ b/src/database/models/index.ts @@ -59,6 +59,7 @@ import createDealerCode from './DealerCode.js'; import createDocumentVersion from './DocumentVersion.js'; import createWorkNoteTag from './WorkNoteTag.js'; import createWorkNoteAttachment from './WorkNoteAttachment.js'; +import createRequestParticipant from './RequestParticipant.js'; // Batch 5: FDD, LOI, LOA, EOR & Security Deposit import createFddAssignment from './FddAssignment.js'; @@ -164,6 +165,7 @@ db.DealerCode = createDealerCode(sequelize); db.DocumentVersion = createDocumentVersion(sequelize); db.WorkNoteTag = createWorkNoteTag(sequelize); db.WorkNoteAttachment = createWorkNoteAttachment(sequelize); +db.RequestParticipant = createRequestParticipant(sequelize); // Batch 5: FDD, LOI, LOA, EOR & Security Deposit db.FddAssignment = createFddAssignment(sequelize); diff --git a/src/emailtemplates/interview_scheduled.html b/src/emailtemplates/interview_scheduled.html new file mode 100644 index 0000000..3851d3b --- /dev/null +++ b/src/emailtemplates/interview_scheduled.html @@ -0,0 +1,29 @@ + + + + + + +
+
Interview Scheduled: {{applicationId}}
+

Dear {{name}},

+

An interview has been scheduled for the Royal Enfield Dealership application.

+
+ Level: {{level}}
+ Date & Time: {{dateTime}}
+ Type: {{type}}
+ Location/Link: {{location}} +
+

Please ensure you are available at the scheduled time.

+ +
+ + diff --git a/src/emailtemplates/user_assigned.html b/src/emailtemplates/user_assigned.html new file mode 100644 index 0000000..67cdae1 --- /dev/null +++ b/src/emailtemplates/user_assigned.html @@ -0,0 +1,54 @@ + + + + + + + + +
+
New Application Assignment
+

Hello {{userName}},

+

You have been assigned as a {{participantType}} to the following application:

+
+ Application ID: {{applicationId}}
+ Dealer Name: {{dealerName}} +
+

You can access the application details in the dealership onboarding portal.

+ +
+ + + \ No newline at end of file diff --git a/src/modules/assessment/assessment.controller.ts b/src/modules/assessment/assessment.controller.ts index 5b8121a..f4af7a5 100644 --- a/src/modules/assessment/assessment.controller.ts +++ b/src/modules/assessment/assessment.controller.ts @@ -2,10 +2,11 @@ import { Request, Response } from 'express'; import db from '../../database/models/index.js'; const { Questionnaire, QuestionnaireQuestion, QuestionnaireResponse, QuestionnaireScore, - Interview, InterviewEvaluation, InterviewParticipant, AiSummary + Interview, InterviewEvaluation, InterviewParticipant, AiSummary, User } = db; import { AuthRequest } from '../../types/express.types.js'; import { Op } from 'sequelize'; +import * as EmailService from '../../common/utils/email.service.js'; // --- Questionnaires --- @@ -89,6 +90,33 @@ export const scheduleInterview = async (req: AuthRequest, res: Response) => { } } + // Fetch application and user email for notification + const application = await db.Application.findByPk(applicationId); + + if (application) { + await EmailService.sendInterviewScheduledEmail( + application.email, + application.name, + application.applicationId || application.id, + interview + ); + } + + // Notify panelists if needed + if (participants && participants.length > 0) { + for (const userId of participants) { + const panelist = await User.findByPk(userId); + if (panelist) { + await EmailService.sendInterviewScheduledEmail( + panelist.email, + panelist.fullName, + application?.applicationId || application?.id || applicationId, + interview + ); + } + } + } + res.status(201).json({ success: true, message: 'Interview scheduled successfully', data: interview }); } catch (error) { console.error('Schedule interview error:', error); diff --git a/src/modules/collaboration/collaboration.controller.ts b/src/modules/collaboration/collaboration.controller.ts index 47208a7..696460e 100644 --- a/src/modules/collaboration/collaboration.controller.ts +++ b/src/modules/collaboration/collaboration.controller.ts @@ -1,7 +1,8 @@ import { Response } from 'express'; import db from '../../database/models/index.js'; -const { Worknote, User, WorkNoteTag, WorkNoteAttachment, Document, DocumentVersion } = db; +const { Worknote, User, WorkNoteTag, WorkNoteAttachment, Document, DocumentVersion, RequestParticipant, Application } = db; import { AuthRequest } from '../../types/express.types.js'; +import * as EmailService from '../../common/utils/email.service.js'; // --- Worknotes --- @@ -30,6 +31,21 @@ export const addWorknote = async (req: AuthRequest, res: Response) => { } } + // Add author as participant + if (req.user?.id && requestId && requestType) { + await db.RequestParticipant.findOrCreate({ + where: { + requestId, + requestType, + userId: req.user.id + }, + defaults: { + participantType: 'contributor', + joinedMethod: 'worknote' + } + }); + } + res.status(201).json({ success: true, message: 'Worknote added', data: worknote }); } catch (error) { console.error('Add worknote error:', error); @@ -44,7 +60,7 @@ export const getWorknotes = async (req: AuthRequest, res: Response) => { const worknotes = await Worknote.findAll({ where: { requestId, requestType }, include: [ - { model: User, as: 'author', attributes: ['name', 'role'] }, + { model: User, as: 'author', attributes: [['fullName', 'name'], 'email', ['roleCode', 'role']] }, { model: WorkNoteTag, as: 'tags' }, { model: WorkNoteAttachment, as: 'attachments', include: ['document'] } ], @@ -115,3 +131,51 @@ export const uploadNewVersion = async (req: AuthRequest, res: Response) => { res.status(500).json({ success: false, message: 'Error uploading version' }); } }; +// --- Participants --- + +export const addParticipant = async (req: AuthRequest, res: Response) => { + try { + const { requestId, requestType, userId, participantType } = req.body; + + const [participant, created] = await RequestParticipant.findOrCreate({ + where: { requestId, requestType, userId }, + defaults: { + participantType: participantType || 'contributor', + joinedMethod: 'manual' + } + }); + + if (!created) { + await participant.update({ participantType }); + } + + // Notify the user via email + const user = await User.findByPk(userId); + const application = await Application.findByPk(requestId); + + if (user && application) { + await EmailService.sendUserAssignedEmail( + user.email, + user.fullName, + application.applicationId || application.id, + application.name, // The dealer's name + participantType || 'participant' + ); + } + + res.status(201).json({ success: true, message: 'Participant added', data: participant }); + } catch (error) { + console.error('Add participant error:', error); + res.status(500).json({ success: false, message: 'Error adding participant' }); + } +}; + +export const removeParticipant = async (req: AuthRequest, res: Response) => { + try { + const { id } = req.params; + await RequestParticipant.destroy({ where: { id } }); + res.json({ success: true, message: 'Participant removed' }); + } catch (error) { + res.status(500).json({ success: false, message: 'Error removing participant' }); + } +}; diff --git a/src/modules/collaboration/collaboration.routes.ts b/src/modules/collaboration/collaboration.routes.ts index dce623d..f6389e1 100644 --- a/src/modules/collaboration/collaboration.routes.ts +++ b/src/modules/collaboration/collaboration.routes.ts @@ -9,8 +9,8 @@ router.use(authenticate as any); router.get('/worknotes', collaborationController.getWorknotes); router.post('/worknotes', collaborationController.addWorknote); -// Documents -router.post('/documents', collaborationController.uploadDocument); -router.post('/documents/version', collaborationController.uploadNewVersion); +// Participants +router.post('/participants', collaborationController.addParticipant); +router.delete('/participants/:id', collaborationController.removeParticipant); export default router; diff --git a/src/modules/onboarding/onboarding.controller.ts b/src/modules/onboarding/onboarding.controller.ts index 55fe113..8d18e22 100644 --- a/src/modules/onboarding/onboarding.controller.ts +++ b/src/modules/onboarding/onboarding.controller.ts @@ -67,7 +67,6 @@ export const submitApplication = async (req: AuthRequest, res: Response) => { zoneId = validArea.zoneId; regionId = validArea.regionId; isOpportunityAvailable = true; - console.log(`[Auto-Match] Found Active Area ${validArea.areaName} for District: ${districtName}`); } } } @@ -183,6 +182,11 @@ export const getApplicationById = async (req: Request, res: Response) => { include: [{ model: db.QuestionnaireOption, as: 'questionOptions' }] } ] + }, + { + model: db.RequestParticipant, + as: 'participants', + include: [{ model: db.User, as: 'user', attributes: [['fullName', 'name'], 'email', ['roleCode', 'role']] }] } ] }); @@ -281,6 +285,25 @@ export const bulkShortlist = async (req: AuthRequest, res: Response) => { } }); + // Add participants + if (Array.isArray(assignedTo) && assignedTo.length > 0) { + for (const appId of applicationIds) { + for (const userId of assignedTo) { + await db.RequestParticipant.findOrCreate({ + where: { + requestId: appId, + requestType: 'application', + userId, + participantType: 'assignee' + }, + defaults: { + joinedMethod: 'auto' + } + }); + } + } + } + // Create Status History Entries const historyEntries = applicationIds.map(appId => ({ applicationId: appId, diff --git a/src/scripts/seed-master-emails.ts b/src/scripts/seed-master-emails.ts new file mode 100644 index 0000000..8d6eac5 --- /dev/null +++ b/src/scripts/seed-master-emails.ts @@ -0,0 +1,66 @@ +import db from '../database/models/index.js'; +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const seedTemplates = async () => { + try { + const templatesDir = path.join(__dirname, '../emailtemplates'); + + const templates = [ + { + templateCode: 'opportunity', + description: 'Opportunity link for dealership application assessment', + subject: 'Action Required: Royal Enfield Dealership Opportunity', + fileName: 'opportunity.html', + placeholders: ['applicantName', 'location', 'applicationId', 'link', 'year'] + }, + { + templateCode: 'non_opportunity', + description: 'Rejection/Hold email for non-opportunity applications', + subject: 'Update on your Royal Enfield Dealership Application', + fileName: 'non_opportunity.html', + placeholders: ['applicantName', 'location', 'year'] + }, + { + templateCode: 'INTERVIEW_SCHEDULED', + description: 'Notification for scheduled interview', + subject: 'Interview Scheduled: {{applicationId}}', + fileName: 'interview_scheduled.html', + placeholders: ['name', 'applicationId', 'level', 'dateTime', 'type', 'location', 'year'] + }, + { + templateCode: 'USER_ASSIGNED', + description: 'Notification for user assignment to an application', + subject: 'New Application Assignment: {{applicationId}}', + fileName: 'user_assigned.html', + placeholders: ['userName', 'applicationId', 'dealerName', 'participantType', 'year'] + } + ]; + + for (const t of templates) { + const body = fs.readFileSync(path.join(templatesDir, t.fileName), 'utf-8'); + + await db.EmailTemplate.upsert({ + templateCode: t.templateCode, + description: t.description, + subject: t.subject, + body: body, + placeholders: t.placeholders, + isActive: true + }); + console.log(`Seeded/Updated template: ${t.templateCode}`); + } + + console.log('Email template seeding completed.'); + process.exit(0); + } catch (error) { + console.error('Seeding error:', error); + process.exit(1); + } +}; + +seedTemplates();