enhanced questionnarie and added participant flow
This commit is contained in:
parent
6277a2dbc3
commit
74202de59b
245
restore-full-questionnaire.ts
Normal file
245
restore-full-questionnaire.ts
Normal file
@ -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();
|
||||||
@ -5,12 +5,12 @@ import { DbConfig } from '../../types/common.types.js';
|
|||||||
const config: DbConfig = {
|
const config: DbConfig = {
|
||||||
development: {
|
development: {
|
||||||
username: process.env.DB_USER || 'laxman',
|
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',
|
database: process.env.DB_NAME || 'royal_enfield_onboarding',
|
||||||
host: process.env.DB_HOST || 'localhost',
|
host: process.env.DB_HOST || 'localhost',
|
||||||
port: parseInt(process.env.DB_PORT || '5432'),
|
port: parseInt(process.env.DB_PORT || '5432'),
|
||||||
dialect: 'postgres',
|
dialect: 'postgres',
|
||||||
logging: console.log,
|
logging: false,
|
||||||
pool: {
|
pool: {
|
||||||
max: 5,
|
max: 5,
|
||||||
min: 0,
|
min: 0,
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import nodemailer from 'nodemailer';
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
|
import db from '../../database/models/index.js';
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = path.dirname(__filename);
|
const __dirname = path.dirname(__filename);
|
||||||
@ -19,12 +20,13 @@ nodemailer.createTestAccount().then((account) => {
|
|||||||
pass: account.pass,
|
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));
|
}).catch(err => console.error('Failed to create test account:', err));
|
||||||
|
|
||||||
|
const { EmailTemplate } = db;
|
||||||
|
|
||||||
const readTemplate = (templateName: string, replacements: Record<string, string>) => {
|
const readTemplate = (templateName: string, replacements: Record<string, string>) => {
|
||||||
const templatePath = path.join(__dirname, '../../emailtemplates', `${templateName}.html`);
|
const templatePath = path.join(__dirname, '../../emailtemplates', `${templateName}.html`);
|
||||||
|
if (!fs.existsSync(templatePath)) return null;
|
||||||
let html = fs.readFileSync(templatePath, 'utf-8');
|
let html = fs.readFileSync(templatePath, 'utf-8');
|
||||||
for (const key in replacements) {
|
for (const key in replacements) {
|
||||||
html = html.replace(new RegExp(`{{${key}}}`, 'g'), replacements[key]);
|
html = html.replace(new RegExp(`{{${key}}}`, 'g'), replacements[key]);
|
||||||
@ -32,43 +34,94 @@ const readTemplate = (templateName: string, replacements: Record<string, string>
|
|||||||
return html;
|
return html;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const sendEmail = async (to: string, subject: string, templateCode: string, replacements: Record<string, string>) => {
|
||||||
|
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" <no-reply@royalenfield.com>',
|
||||||
|
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) => {
|
export const sendOpportunityEmail = async (to: string, applicantName: string, location: string, applicationId: string) => {
|
||||||
const link = `http://localhost:5173/questionnaire/${applicationId}`;
|
const link = `http://localhost:5173/questionnaire/${applicationId}`;
|
||||||
const html = readTemplate('opportunity', {
|
await sendEmail(to, 'Action Required: Royal Enfield Dealership Opportunity', 'opportunity', {
|
||||||
applicantName,
|
applicantName,
|
||||||
location,
|
location,
|
||||||
applicationId,
|
applicationId,
|
||||||
link,
|
link
|
||||||
year: new Date().getFullYear().toString()
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!transporter) return;
|
|
||||||
|
|
||||||
const info = await transporter.sendMail({
|
|
||||||
from: '"Royal Enfield Onboarding" <no-reply@royalenfield.com>',
|
|
||||||
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) => {
|
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,
|
applicantName,
|
||||||
location,
|
location
|
||||||
year: new Date().getFullYear().toString()
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
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" <no-reply@royalenfield.com>',
|
|
||||||
to,
|
|
||||||
subject: 'Update on your Royal Enfield Dealership Application',
|
|
||||||
html
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(`[Email Sent] Non-Opportunity: ${nodemailer.getTestMessageUrl(info)}`);
|
|
||||||
};
|
};
|
||||||
|
|||||||
@ -246,6 +246,11 @@ export default (sequelize: Sequelize) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
Application.hasMany(models.QuestionnaireResponse, { foreignKey: 'applicationId', as: 'questionnaireResponses' });
|
Application.hasMany(models.QuestionnaireResponse, { foreignKey: 'applicationId', as: 'questionnaireResponses' });
|
||||||
|
Application.hasMany(models.RequestParticipant, {
|
||||||
|
foreignKey: 'requestId',
|
||||||
|
as: 'participants',
|
||||||
|
scope: { requestType: 'application' }
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return Application;
|
return Application;
|
||||||
|
|||||||
65
src/database/models/RequestParticipant.ts
Normal file
65
src/database/models/RequestParticipant.ts
Normal file
@ -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>, RequestParticipantAttributes { }
|
||||||
|
|
||||||
|
export default (sequelize: Sequelize) => {
|
||||||
|
const RequestParticipant = sequelize.define<RequestParticipantInstance>('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;
|
||||||
|
};
|
||||||
@ -59,6 +59,7 @@ import createDealerCode from './DealerCode.js';
|
|||||||
import createDocumentVersion from './DocumentVersion.js';
|
import createDocumentVersion from './DocumentVersion.js';
|
||||||
import createWorkNoteTag from './WorkNoteTag.js';
|
import createWorkNoteTag from './WorkNoteTag.js';
|
||||||
import createWorkNoteAttachment from './WorkNoteAttachment.js';
|
import createWorkNoteAttachment from './WorkNoteAttachment.js';
|
||||||
|
import createRequestParticipant from './RequestParticipant.js';
|
||||||
|
|
||||||
// Batch 5: FDD, LOI, LOA, EOR & Security Deposit
|
// Batch 5: FDD, LOI, LOA, EOR & Security Deposit
|
||||||
import createFddAssignment from './FddAssignment.js';
|
import createFddAssignment from './FddAssignment.js';
|
||||||
@ -164,6 +165,7 @@ db.DealerCode = createDealerCode(sequelize);
|
|||||||
db.DocumentVersion = createDocumentVersion(sequelize);
|
db.DocumentVersion = createDocumentVersion(sequelize);
|
||||||
db.WorkNoteTag = createWorkNoteTag(sequelize);
|
db.WorkNoteTag = createWorkNoteTag(sequelize);
|
||||||
db.WorkNoteAttachment = createWorkNoteAttachment(sequelize);
|
db.WorkNoteAttachment = createWorkNoteAttachment(sequelize);
|
||||||
|
db.RequestParticipant = createRequestParticipant(sequelize);
|
||||||
|
|
||||||
// Batch 5: FDD, LOI, LOA, EOR & Security Deposit
|
// Batch 5: FDD, LOI, LOA, EOR & Security Deposit
|
||||||
db.FddAssignment = createFddAssignment(sequelize);
|
db.FddAssignment = createFddAssignment(sequelize);
|
||||||
|
|||||||
29
src/emailtemplates/interview_scheduled.html
Normal file
29
src/emailtemplates/interview_scheduled.html
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<style>
|
||||||
|
body { font-family: sans-serif; line-height: 1.6; color: #333; }
|
||||||
|
.container { padding: 20px; border: 1px solid #ddd; border-radius: 8px; max-width: 600px; }
|
||||||
|
.header { font-size: 20px; font-weight: bold; color: #c2410c; margin-bottom: 20px; }
|
||||||
|
.details { background: #fef3c7; padding: 15px; border-radius: 4px; margin: 20px 0; }
|
||||||
|
.footer { font-size: 12px; color: #666; margin-top: 30px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">Interview Scheduled: {{applicationId}}</div>
|
||||||
|
<p>Dear {{name}},</p>
|
||||||
|
<p>An interview has been scheduled for the Royal Enfield Dealership application.</p>
|
||||||
|
<div class="details">
|
||||||
|
<strong>Level:</strong> {{level}}<br>
|
||||||
|
<strong>Date & Time:</strong> {{dateTime}}<br>
|
||||||
|
<strong>Type:</strong> {{type}}<br>
|
||||||
|
<strong>Location/Link:</strong> {{location}}
|
||||||
|
</div>
|
||||||
|
<p>Please ensure you are available at the scheduled time.</p>
|
||||||
|
<div class="footer">
|
||||||
|
© {{year}} Royal Enfield. All rights reserved.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
54
src/emailtemplates/user_assigned.html
Normal file
54
src/emailtemplates/user_assigned.html
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
padding: 20px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 8px;
|
||||||
|
max-width: 600px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #c2410c;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info {
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
margin-top: 30px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">New Application Assignment</div>
|
||||||
|
<p>Hello {{userName}},</p>
|
||||||
|
<p>You have been assigned as a <strong>{{participantType}}</strong> to the following application:</p>
|
||||||
|
<div class="info">
|
||||||
|
<strong>Application ID:</strong> {{applicationId}}<br>
|
||||||
|
<strong>Dealer Name:</strong> {{dealerName}}
|
||||||
|
</div>
|
||||||
|
<p>You can access the application details in the dealership onboarding portal.</p>
|
||||||
|
<div class="footer">
|
||||||
|
© {{year}} Royal Enfield. All rights reserved.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
@ -2,10 +2,11 @@ import { Request, Response } from 'express';
|
|||||||
import db from '../../database/models/index.js';
|
import db from '../../database/models/index.js';
|
||||||
const {
|
const {
|
||||||
Questionnaire, QuestionnaireQuestion, QuestionnaireResponse, QuestionnaireScore,
|
Questionnaire, QuestionnaireQuestion, QuestionnaireResponse, QuestionnaireScore,
|
||||||
Interview, InterviewEvaluation, InterviewParticipant, AiSummary
|
Interview, InterviewEvaluation, InterviewParticipant, AiSummary, User
|
||||||
} = db;
|
} = db;
|
||||||
import { AuthRequest } from '../../types/express.types.js';
|
import { AuthRequest } from '../../types/express.types.js';
|
||||||
import { Op } from 'sequelize';
|
import { Op } from 'sequelize';
|
||||||
|
import * as EmailService from '../../common/utils/email.service.js';
|
||||||
|
|
||||||
// --- Questionnaires ---
|
// --- 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 });
|
res.status(201).json({ success: true, message: 'Interview scheduled successfully', data: interview });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Schedule interview error:', error);
|
console.error('Schedule interview error:', error);
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
import db from '../../database/models/index.js';
|
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 { AuthRequest } from '../../types/express.types.js';
|
||||||
|
import * as EmailService from '../../common/utils/email.service.js';
|
||||||
|
|
||||||
// --- Worknotes ---
|
// --- 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 });
|
res.status(201).json({ success: true, message: 'Worknote added', data: worknote });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Add worknote error:', error);
|
console.error('Add worknote error:', error);
|
||||||
@ -44,7 +60,7 @@ export const getWorknotes = async (req: AuthRequest, res: Response) => {
|
|||||||
const worknotes = await Worknote.findAll({
|
const worknotes = await Worknote.findAll({
|
||||||
where: { requestId, requestType },
|
where: { requestId, requestType },
|
||||||
include: [
|
include: [
|
||||||
{ model: User, as: 'author', attributes: ['name', 'role'] },
|
{ model: User, as: 'author', attributes: [['fullName', 'name'], 'email', ['roleCode', 'role']] },
|
||||||
{ model: WorkNoteTag, as: 'tags' },
|
{ model: WorkNoteTag, as: 'tags' },
|
||||||
{ model: WorkNoteAttachment, as: 'attachments', include: ['document'] }
|
{ 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' });
|
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' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@ -9,8 +9,8 @@ router.use(authenticate as any);
|
|||||||
router.get('/worknotes', collaborationController.getWorknotes);
|
router.get('/worknotes', collaborationController.getWorknotes);
|
||||||
router.post('/worknotes', collaborationController.addWorknote);
|
router.post('/worknotes', collaborationController.addWorknote);
|
||||||
|
|
||||||
// Documents
|
// Participants
|
||||||
router.post('/documents', collaborationController.uploadDocument);
|
router.post('/participants', collaborationController.addParticipant);
|
||||||
router.post('/documents/version', collaborationController.uploadNewVersion);
|
router.delete('/participants/:id', collaborationController.removeParticipant);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@ -67,7 +67,6 @@ export const submitApplication = async (req: AuthRequest, res: Response) => {
|
|||||||
zoneId = validArea.zoneId;
|
zoneId = validArea.zoneId;
|
||||||
regionId = validArea.regionId;
|
regionId = validArea.regionId;
|
||||||
isOpportunityAvailable = true;
|
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' }]
|
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
|
// Create Status History Entries
|
||||||
const historyEntries = applicationIds.map(appId => ({
|
const historyEntries = applicationIds.map(appId => ({
|
||||||
applicationId: appId,
|
applicationId: appId,
|
||||||
|
|||||||
66
src/scripts/seed-master-emails.ts
Normal file
66
src/scripts/seed-master-emails.ts
Normal file
@ -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();
|
||||||
Loading…
Reference in New Issue
Block a user