major code refactotoring is done all modules coverd also added the email 20+ email tamplates with rich text editor edit code is almost stable
This commit is contained in:
parent
778a3a7452
commit
4fa1898824
208
package-lock.json
generated
208
package-lock.json
generated
@ -25,6 +25,7 @@
|
||||
"nodemailer": "^7.0.12",
|
||||
"pg": "^8.18.0",
|
||||
"pg-hstore": "^2.3.4",
|
||||
"sanitize-html": "^2.17.3",
|
||||
"sequelize": "^6.37.7",
|
||||
"socket.io": "^4.8.3",
|
||||
"uuid": "^13.0.0",
|
||||
@ -42,6 +43,7 @@
|
||||
"@types/node": "^25.0.9",
|
||||
"@types/nodemailer": "^7.0.5",
|
||||
"@types/pg": "^8.16.0",
|
||||
"@types/sanitize-html": "^2.16.1",
|
||||
"@types/supertest": "^6.0.3",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@types/validator": "^13.15.10",
|
||||
@ -3411,6 +3413,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/sanitize-html": {
|
||||
"version": "2.16.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/sanitize-html/-/sanitize-html-2.16.1.tgz",
|
||||
"integrity": "sha512-n9wjs8bCOTyN/ynwD8s/nTcTreIHB1vf31vhLMGqUPNHaweKC4/fAl4Dj+hUlCTKYgm4P3k83fmiFfzkZ6sgMA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"htmlparser2": "^10.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/send": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
|
||||
@ -4887,7 +4899,6 @@
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
|
||||
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@ -4962,6 +4973,73 @@
|
||||
"node": ">=0.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/dom-serializer": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
|
||||
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.2",
|
||||
"entities": "^4.2.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/dom-serializer/node_modules/entities": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
|
||||
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/domelementtype": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
|
||||
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
],
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/domhandler": {
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
|
||||
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/domhandler?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/domutils": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
|
||||
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"dom-serializer": "^2.0.0",
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/domutils?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"version": "17.2.3",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz",
|
||||
@ -5132,6 +5210,18 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
|
||||
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/error-ex": {
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz",
|
||||
@ -5960,6 +6050,25 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/htmlparser2": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
|
||||
"integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==",
|
||||
"funding": [
|
||||
"https://github.com/fb55/htmlparser2?sponsor=1",
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.3",
|
||||
"domutils": "^3.2.2",
|
||||
"entities": "^7.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/http_ece": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz",
|
||||
@ -6253,6 +6362,15 @@
|
||||
"node": ">=0.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-plain-object": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz",
|
||||
"integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-promise": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
|
||||
@ -7548,6 +7666,24 @@
|
||||
"node": ">= 10.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.11",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"nanoid": "bin/nanoid.cjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/napi-postinstall": {
|
||||
"version": "0.3.4",
|
||||
"resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz",
|
||||
@ -7900,6 +8036,12 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/parse-srcset": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz",
|
||||
"integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/parseurl": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||
@ -8078,7 +8220,6 @@
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
@ -8117,6 +8258,34 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.10",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz",
|
||||
"integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/postcss/"
|
||||
},
|
||||
{
|
||||
"type": "tidelift",
|
||||
"url": "https://tidelift.com/funding/github/npm/postcss"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-array": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
||||
@ -8438,6 +8607,32 @@
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/sanitize-html": {
|
||||
"version": "2.17.3",
|
||||
"resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.3.tgz",
|
||||
"integrity": "sha512-Kn4srCAo2+wZyvCNKCSyB2g8RQ8IkX/gQs2uqoSRNu5t9I2qvUyAVvRDiFUVAiX3N3PNuwStY0eNr+ooBHVWEg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"deepmerge": "^4.2.2",
|
||||
"escape-string-regexp": "^4.0.0",
|
||||
"htmlparser2": "^10.1.0",
|
||||
"is-plain-object": "^5.0.0",
|
||||
"parse-srcset": "^1.0.2",
|
||||
"postcss": "^8.3.11"
|
||||
}
|
||||
},
|
||||
"node_modules/sanitize-html/node_modules/escape-string-regexp": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
||||
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
||||
@ -8938,6 +9133,15 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-support": {
|
||||
"version": "0.5.13",
|
||||
"resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz",
|
||||
|
||||
@ -55,6 +55,7 @@
|
||||
"nodemailer": "^7.0.12",
|
||||
"pg": "^8.18.0",
|
||||
"pg-hstore": "^2.3.4",
|
||||
"sanitize-html": "^2.17.3",
|
||||
"sequelize": "^6.37.7",
|
||||
"socket.io": "^4.8.3",
|
||||
"uuid": "^13.0.0",
|
||||
@ -72,6 +73,7 @@
|
||||
"@types/node": "^25.0.9",
|
||||
"@types/nodemailer": "^7.0.5",
|
||||
"@types/pg": "^8.16.0",
|
||||
"@types/sanitize-html": "^2.16.1",
|
||||
"@types/supertest": "^6.0.3",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@types/validator": "^13.15.10",
|
||||
|
||||
10
reset_db.ts
10
reset_db.ts
@ -29,7 +29,11 @@ async function resetAndSeed() {
|
||||
{ roleCode: 'Finance', roleName: 'Finance', category: 'DEPARTMENT' },
|
||||
{ roleCode: 'Dealer', roleName: 'Dealer', category: 'EXTERNAL' },
|
||||
{ roleCode: 'DD Admin', roleName: 'DD Admin', category: 'ADMIN' },
|
||||
{ roleCode: 'ARCHITECTURE', roleName: 'Architecture Team', category: 'DEPARTMENT' }
|
||||
{ roleCode: 'ARCHITECTURE', roleName: 'Architecture Team', category: 'DEPARTMENT' },
|
||||
{ roleCode: 'FDD', roleName: 'FDD Team', category: 'EXTERNAL' },
|
||||
{ roleCode: 'Legal Admin', roleName: 'Legal Admin', category: 'DEPARTMENT' },
|
||||
{ roleCode: 'CEO', roleName: 'CEO', category: 'NATIONAL' },
|
||||
{ roleCode: 'CCO', roleName: 'CCO', category: 'NATIONAL' }
|
||||
];
|
||||
for (const r of roles) await Role.create(r);
|
||||
|
||||
@ -59,7 +63,9 @@ async function resetAndSeed() {
|
||||
{ email: 'finance@royalenfield.com', fullName: 'Finance Admin', roleCode: 'Finance', password: hashedPassword, status: 'active' },
|
||||
{ email: 'abhishek@royalenfield.com', fullName: 'abhishek', roleCode: 'ASM', password: hashedPassword, status: 'active' },
|
||||
{ email: 'lince@royalenfield.com', fullName: 'Lince', roleCode: 'DD Admin', password: hashedPassword, status: 'active' },
|
||||
{ email: 'legal@royalenfield.com', fullName: 'Legal Admin', roleCode: 'Legal Admin', password: hashedPassword, status: 'active' }
|
||||
{ email: 'legal@royalenfield.com', fullName: 'Legal Admin', roleCode: 'Legal Admin', password: hashedPassword, status: 'active' },
|
||||
{ email: 'ceo@royalenfield.com', fullName: 'CEO', roleCode: 'CEO', password: hashedPassword, status: 'active' },
|
||||
{ email: 'cco@royalenfield.com', fullName: 'CCO', roleCode: 'CCO', password: hashedPassword, status: 'active' }
|
||||
];
|
||||
|
||||
for (const u of users) {
|
||||
|
||||
31
scratch/check-models.ts
Normal file
31
scratch/check-models.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import db from '../src/database/models/index.js';
|
||||
|
||||
async function checkModels() {
|
||||
console.log('🔍 Checking model loading...');
|
||||
try {
|
||||
await db.sequelize.authenticate();
|
||||
console.log('✅ Database authentication successful!');
|
||||
|
||||
const modelsCount = Object.keys(db).filter(k => k !== 'sequelize' && k !== 'Sequelize').length;
|
||||
console.log(`📊 Total models loaded: ${modelsCount}`);
|
||||
|
||||
// Check a few specific models
|
||||
const sampleModels = ['User', 'Application', 'Dealer', 'TerminationRequest', 'AuditLog'];
|
||||
for (const m of sampleModels) {
|
||||
if (db[m]) {
|
||||
console.log(`✅ Model ${m} is available`);
|
||||
} else {
|
||||
console.error(`❌ Model ${m} is MISSING!`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('🚀 All checks passed!');
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('❌ Check failed:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
checkModels();
|
||||
@ -68,6 +68,8 @@ async function masterReset() {
|
||||
{ email: 'abhishek@royalenfield.com', fullName: 'abhishek', roleCode: ROLES.ASM },
|
||||
{ email: 'lince@royalenfield.com', fullName: 'Lince', roleCode: ROLES.DD_ADMIN },
|
||||
{ email: 'legal@royalenfield.com', fullName: 'Legal Admin', roleCode: ROLES.LEGAL_ADMIN },
|
||||
{ email: 'ceo@royalenfield.com', fullName: 'CEO', roleCode: ROLES.CEO },
|
||||
{ email: 'cco@royalenfield.com', fullName: 'CCO', roleCode: ROLES.CCO },
|
||||
];
|
||||
|
||||
for (const u of users) {
|
||||
|
||||
@ -24,7 +24,9 @@ async function seedUsers() {
|
||||
{ email: 'finance@royalenfield.com', fullName: 'Finance Admin', password: hashedPassword, roleCode: ROLES.FINANCE, status: 'active' },
|
||||
{ email: 'abhishek@royalenfield.com', fullName: 'abhishek', password: hashedPassword, roleCode: ROLES.ASM, status: 'active' },
|
||||
{ email: 'lince@royalenfield.com', fullName: 'Lince', password: hashedPassword, roleCode: ROLES.DD_ADMIN, status: 'active' },
|
||||
{ email: 'legal@royalenfield.com', fullName: 'Legal Admin', password: hashedPassword, roleCode: ROLES.LEGAL_ADMIN, status: 'active' }
|
||||
{ email: 'legal@royalenfield.com', fullName: 'Legal Admin', password: hashedPassword, roleCode: ROLES.LEGAL_ADMIN, status: 'active' },
|
||||
{ email: 'ceo@royalenfield.com', fullName: 'CEO', password: hashedPassword, roleCode: ROLES.CEO, status: 'active' },
|
||||
{ email: 'cco@royalenfield.com', fullName: 'CCO', password: hashedPassword, roleCode: ROLES.CCO, status: 'active' }
|
||||
];
|
||||
|
||||
for (const u of usersToSeed) {
|
||||
|
||||
@ -122,6 +122,8 @@ async function seed() {
|
||||
{ email: 'lince@royalenfield.com', name: 'Lince', role: 'DD Admin' },
|
||||
{ email: 'fdd@royalenfield.com', name: 'FDD Team', role: 'FDD' },
|
||||
{ email: 'legal@royalenfield.com', name: 'Legal Admin', role: 'Legal Admin' },
|
||||
{ email: 'ceo@royalenfield.com', name: 'CEO', role: 'CEO' },
|
||||
{ email: 'cco@royalenfield.com', name: 'CCO', role: 'CCO' },
|
||||
];
|
||||
for (const u of nationalUsers) {
|
||||
const [user] = await User.findOrCreate({
|
||||
|
||||
56
scripts/verify-standardized-offboarding.ts
Normal file
56
scripts/verify-standardized-offboarding.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
validateOffboardingAction,
|
||||
getPreviousStage,
|
||||
getOffboardingAuditAction
|
||||
} from '../src/common/utils/offboardingWorkflow.utils.js';
|
||||
import {
|
||||
OFFBOARDING_ACTIONS,
|
||||
REQUEST_TYPES,
|
||||
TERMINATION_STAGES,
|
||||
RESIGNATION_STAGES,
|
||||
CONSTITUTIONAL_STAGES,
|
||||
AUDIT_ACTIONS
|
||||
} from '../src/common/config/constants.js';
|
||||
|
||||
console.log('--- Testing Standardized Offboarding Utilities ---');
|
||||
|
||||
// 1. Test validateOffboardingAction
|
||||
console.log('Testing validateOffboardingAction...');
|
||||
assert.deepEqual(validateOffboardingAction(OFFBOARDING_ACTIONS.APPROVE, ''), { valid: true });
|
||||
assert.deepEqual(validateOffboardingAction(OFFBOARDING_ACTIONS.SEND_BACK, 'Short'), { valid: true }); // 'Short' is 5 chars
|
||||
assert.equal(validateOffboardingAction(OFFBOARDING_ACTIONS.SEND_BACK, 'No').valid, false);
|
||||
assert.equal(validateOffboardingAction(OFFBOARDING_ACTIONS.REVOKE, '').valid, false);
|
||||
assert.equal(validateOffboardingAction(OFFBOARDING_ACTIONS.REJECT, '').valid, true); // Remarks not mandatory for reject in current util choice
|
||||
console.log('✓ validateOffboardingAction passed.');
|
||||
|
||||
// 2. Test getPreviousStage - Termination
|
||||
console.log('Testing getPreviousStage (Termination)...');
|
||||
assert.equal(getPreviousStage(REQUEST_TYPES.TERMINATION, TERMINATION_STAGES.RBM_REVIEW), TERMINATION_STAGES.SUBMITTED);
|
||||
assert.equal(getPreviousStage(REQUEST_TYPES.TERMINATION, TERMINATION_STAGES.ZBH_REVIEW), TERMINATION_STAGES.RBM_REVIEW);
|
||||
assert.equal(getPreviousStage(REQUEST_TYPES.TERMINATION, TERMINATION_STAGES.TERMINATED), TERMINATION_STAGES.LEGAL_LETTER);
|
||||
console.log('✓ Termination stage resolution passed.');
|
||||
|
||||
// 3. Test getPreviousStage - Resignation
|
||||
console.log('Testing getPreviousStage (Resignation)...');
|
||||
assert.equal(getPreviousStage(REQUEST_TYPES.RESIGNATION, RESIGNATION_STAGES.RBM), RESIGNATION_STAGES.ASM);
|
||||
assert.equal(getPreviousStage(REQUEST_TYPES.RESIGNATION, RESIGNATION_STAGES.ZBH), RESIGNATION_STAGES.RBM);
|
||||
assert.equal(getPreviousStage(REQUEST_TYPES.RESIGNATION, RESIGNATION_STAGES.COMPLETED), RESIGNATION_STAGES.FNF_INITIATED);
|
||||
console.log('✓ Resignation stage resolution passed.');
|
||||
|
||||
// 4. Test getPreviousStage - Constitutional
|
||||
console.log('Testing getPreviousStage (Constitutional)...');
|
||||
assert.equal(getPreviousStage(REQUEST_TYPES.CONSTITUTIONAL, CONSTITUTIONAL_STAGES.ASM_REVIEW), CONSTITUTIONAL_STAGES.SUBMITTED);
|
||||
assert.equal(getPreviousStage(REQUEST_TYPES.CONSTITUTIONAL, CONSTITUTIONAL_STAGES.ZM_RBM_REVIEW), CONSTITUTIONAL_STAGES.ASM_REVIEW);
|
||||
assert.equal(getPreviousStage(REQUEST_TYPES.CONSTITUTIONAL, CONSTITUTIONAL_STAGES.COMPLETED), CONSTITUTIONAL_STAGES.LEGAL_REVIEW);
|
||||
console.log('✓ Constitutional stage resolution passed.');
|
||||
|
||||
// 5. Test getOffboardingAuditAction mapping
|
||||
console.log('Testing getOffboardingAuditAction...');
|
||||
assert.equal(getOffboardingAuditAction('Sent Back', REQUEST_TYPES.TERMINATION), AUDIT_ACTIONS.UPDATED);
|
||||
assert.equal(getOffboardingAuditAction('Revoke', REQUEST_TYPES.RESIGNATION), AUDIT_ACTIONS.UPDATED);
|
||||
assert.equal(getOffboardingAuditAction('Approve', REQUEST_TYPES.CONSTITUTIONAL), AUDIT_ACTIONS.APPROVED);
|
||||
assert.equal(getOffboardingAuditAction('REJECT', REQUEST_TYPES.TERMINATION), AUDIT_ACTIONS.REJECTED);
|
||||
console.log('✓ Audit action mapping passed.');
|
||||
|
||||
console.log('\nALL STANDARDIZATION UTILITY CHECKS PASSED SUCCESSFULLY.');
|
||||
@ -421,6 +421,10 @@ export const AUDIT_ACTIONS = {
|
||||
RESIGNATION_SUBMITTED: 'RESIGNATION_SUBMITTED',
|
||||
RESIGNATION_APPROVED: 'RESIGNATION_APPROVED',
|
||||
RESIGNATION_REJECTED: 'RESIGNATION_REJECTED',
|
||||
RESIGNATION_REVOKED: 'RESIGNATION_REVOKED',
|
||||
RESIGNATION_SENT_BACK: 'RESIGNATION_SENT_BACK',
|
||||
TERMINATION_REVOKED: 'TERMINATION_REVOKED',
|
||||
TERMINATION_SENT_BACK: 'TERMINATION_SENT_BACK',
|
||||
RELOCATION_SENT_BACK: 'RELOCATION_SENT_BACK',
|
||||
RELOCATION_REVOKED: 'RELOCATION_REVOKED',
|
||||
CONSTITUTIONAL_SENT_BACK: 'CONSTITUTIONAL_SENT_BACK',
|
||||
@ -541,6 +545,20 @@ export const REQUEST_TYPES = {
|
||||
TERMINATION: 'termination'
|
||||
} as const;
|
||||
|
||||
// Standardized Offboarding Actions
|
||||
export const OFFBOARDING_ACTIONS = {
|
||||
APPROVE: 'approve',
|
||||
SEND_BACK: 'sendBack',
|
||||
REVOKE: 'revoke',
|
||||
REJECT: 'reject',
|
||||
WITHDRAWAL: 'withdrawal',
|
||||
ASSIGN: 'assign',
|
||||
PUSH_FNF: 'pushfnf',
|
||||
RECONSIDER: 'reconsider',
|
||||
ISSUE_SCN: 'issueSCN',
|
||||
SCN_RESPONSE: 'scnResponse'
|
||||
} as const;
|
||||
|
||||
// Module List for Document Management
|
||||
export const MODULE_LIST = ['ONBOARDING', 'RESIGNATION', 'RELOCATION', 'CONSTITUTIONAL_CHANGE', 'TERMINATION'] as const;
|
||||
|
||||
|
||||
@ -70,6 +70,19 @@ const upload = multer({
|
||||
// Single file upload
|
||||
export const uploadSingle = upload.single('file');
|
||||
|
||||
/**
|
||||
* Only parse multipart when the client sends multipart/form-data.
|
||||
* Otherwise JSON bodies from express.json() are preserved — required for clearance APIs
|
||||
* called with application/json (e.g. trigger-resignation.js, axios without FormData).
|
||||
*/
|
||||
export const uploadSingleIfMultipart = (req: Request, res: Response, next: NextFunction) => {
|
||||
const ct = String(req.headers['content-type'] || '').toLowerCase();
|
||||
if (ct.includes('multipart/form-data')) {
|
||||
return uploadSingle(req, res, next);
|
||||
}
|
||||
next();
|
||||
};
|
||||
|
||||
// Multiple files upload
|
||||
export const uploadMultiple = upload.array('files', 10); // Max 10 files
|
||||
|
||||
|
||||
@ -52,3 +52,32 @@ export function normalizeToConstitutionalChangeType(raw: string | null | undefin
|
||||
const exact = ALL_CHANGE_TYPES.find((v) => v.toLowerCase() === s.toLowerCase());
|
||||
return exact || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps `constitutional_changes.changeType` → `dealers.constitutionType` when the workflow completes.
|
||||
* Returns `null` when the change type does not represent a single target legal structure (skip master update).
|
||||
*/
|
||||
export function mapConstitutionalChangeTypeToDealerProfile(changeType: string): string | null {
|
||||
const t = String(changeType || '').trim();
|
||||
if (!t) return null;
|
||||
|
||||
if (t === CONSTITUTIONAL_CHANGE_TYPES.LLP_CONVERSION) return CONSTITUTIONAL_CHANGE_TYPES.LLP;
|
||||
|
||||
const structureTargets = [
|
||||
CONSTITUTIONAL_CHANGE_TYPES.PROPRIETORSHIP,
|
||||
CONSTITUTIONAL_CHANGE_TYPES.PARTNERSHIP,
|
||||
CONSTITUTIONAL_CHANGE_TYPES.LLP,
|
||||
CONSTITUTIONAL_CHANGE_TYPES.PRIVATE_LIMITED
|
||||
];
|
||||
if (structureTargets.includes(t as (typeof structureTargets)[number])) return t;
|
||||
|
||||
const skipAutoUpdate = [
|
||||
CONSTITUTIONAL_CHANGE_TYPES.PARTNERSHIP_CHANGE,
|
||||
CONSTITUTIONAL_CHANGE_TYPES.DIRECTOR_CHANGE,
|
||||
CONSTITUTIONAL_CHANGE_TYPES.OWNERSHIP_TRANSFER,
|
||||
CONSTITUTIONAL_CHANGE_TYPES.COMPANY_FORMATION
|
||||
];
|
||||
if (skipAutoUpdate.includes(t as (typeof skipAutoUpdate)[number])) return null;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
90
src/common/utils/email-template-sanitize.ts
Normal file
90
src/common/utils/email-template-sanitize.ts
Normal file
@ -0,0 +1,90 @@
|
||||
import sanitizeHtml from 'sanitize-html';
|
||||
|
||||
/**
|
||||
* Rich-text / HTML editors often encode `>` as `>`, turning `{{> partial}}` into `{{> partial}}`
|
||||
* which Handlebars cannot parse. Decode entities only inside handlebars segments.
|
||||
*/
|
||||
export function decodeHandlebarsEntities(html: string): string {
|
||||
const hb = /\{\{\{[\s\S]*?\}\}\}|\{\{[\s\S]*?\}\}/g;
|
||||
return html.replace(hb, (block) =>
|
||||
block
|
||||
.replace(/&lt;/gi, '<')
|
||||
.replace(/&gt;/gi, '>')
|
||||
.replace(/&quot;/gi, '"')
|
||||
.replace(/&#39;/g, "'")
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/&/g, '&')
|
||||
);
|
||||
}
|
||||
|
||||
/** Preserve `{{...}}` / `{{{...}}}` so `{{> partial}}` is not mangled by the HTML sanitizer. */
|
||||
function maskHandlebars(html: string): { masked: string; tokens: string[] } {
|
||||
const tokens: string[] = [];
|
||||
const hb = /\{\{\{[\s\S]*?\}\}\}|\{\{[\s\S]*?\}\}/g;
|
||||
let i = 0;
|
||||
const masked = html.replace(hb, (m) => {
|
||||
tokens.push(m);
|
||||
return `__HANDLEBARS_TOKEN_${i++}__`;
|
||||
});
|
||||
return { masked, tokens };
|
||||
}
|
||||
|
||||
function unmaskHandlebars(html: string, tokens: string[]): string {
|
||||
let out = html;
|
||||
tokens.forEach((t, idx) => {
|
||||
out = out.split(`__HANDLEBARS_TOKEN_${idx}__`).join(t);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
const emailBodyOptions: sanitizeHtml.IOptions = {
|
||||
allowedTags: [
|
||||
...sanitizeHtml.defaults.allowedTags,
|
||||
'img',
|
||||
'h1',
|
||||
'h2',
|
||||
'h3',
|
||||
'h4',
|
||||
'table',
|
||||
'thead',
|
||||
'tbody',
|
||||
'tr',
|
||||
'td',
|
||||
'th',
|
||||
'caption'
|
||||
],
|
||||
allowedAttributes: {
|
||||
...sanitizeHtml.defaults.allowedAttributes,
|
||||
'*': ['style', 'class', 'align'],
|
||||
a: ['href', 'name', 'target', 'style', 'class'],
|
||||
img: ['src', 'alt', 'width', 'height', 'style', 'class'],
|
||||
td: ['colspan', 'rowspan', 'style', 'class'],
|
||||
th: ['colspan', 'rowspan', 'style', 'class'],
|
||||
table: ['border', 'cellpadding', 'cellspacing', 'style', 'class', 'width']
|
||||
},
|
||||
allowedSchemes: ['http', 'https', 'mailto'],
|
||||
allowedSchemesByTag: {
|
||||
img: ['http', 'https']
|
||||
},
|
||||
allowProtocolRelative: false
|
||||
};
|
||||
|
||||
/** Strip dangerous markup from stored template bodies while keeping typical email HTML. */
|
||||
export function sanitizeEmailTemplateBody(html: string): string {
|
||||
const decoded = decodeHandlebarsEntities(html || '');
|
||||
const { masked, tokens } = maskHandlebars(decoded);
|
||||
const cleaned = sanitizeHtml(masked, emailBodyOptions);
|
||||
return unmaskHandlebars(cleaned, tokens);
|
||||
}
|
||||
|
||||
/** Plain-text-style subject: no HTML tags. */
|
||||
export function sanitizeEmailTemplateSubject(subject: string): string {
|
||||
const stripped = sanitizeHtml(subject || '', {
|
||||
allowedTags: [],
|
||||
allowedAttributes: {}
|
||||
});
|
||||
return stripped.trim().slice(0, 998);
|
||||
}
|
||||
@ -4,6 +4,7 @@ import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import db from '../../database/models/index.js';
|
||||
import handlebars from 'handlebars';
|
||||
import { registerEmailPartials, normalizeCtaPlaceholders } from './handlebars-email.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
@ -38,16 +39,6 @@ initTransporter().catch(err => console.error('Failed to initialize transporter:'
|
||||
|
||||
const { EmailTemplate } = db;
|
||||
|
||||
const readTemplate = (templateName: string, replacements: Record<string, string>) => {
|
||||
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]);
|
||||
}
|
||||
return html;
|
||||
};
|
||||
|
||||
export const sendEmail = async (to: string, subject: string, templateCode: string, replacements: Record<string, string>) => {
|
||||
try {
|
||||
let finalHtml = '';
|
||||
@ -57,32 +48,40 @@ export const sendEmail = async (to: string, subject: string, templateCode: strin
|
||||
const dbTemplate = await EmailTemplate.findOne({ where: { templateCode, isActive: true } });
|
||||
|
||||
if (dbTemplate) {
|
||||
// Prepare replacements with extra global vars
|
||||
const allReplacements = {
|
||||
registerEmailPartials(handlebars);
|
||||
|
||||
const allReplacements = normalizeCtaPlaceholders({
|
||||
...replacements,
|
||||
year: new Date().getFullYear().toString()
|
||||
};
|
||||
});
|
||||
|
||||
// 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, {
|
||||
registerEmailPartials(handlebars);
|
||||
const allReplacements = normalizeCtaPlaceholders({
|
||||
...replacements,
|
||||
year: new Date().getFullYear().toString()
|
||||
});
|
||||
const templatesRoot = path.join(__dirname, '../../emailtemplates');
|
||||
const lowerFile = path.join(templatesRoot, `${templateCode.toLowerCase()}.html`);
|
||||
const exactFile = path.join(templatesRoot, `${templateCode}.html`);
|
||||
const templatePath = fs.existsSync(lowerFile)
|
||||
? lowerFile
|
||||
: fs.existsSync(exactFile)
|
||||
? exactFile
|
||||
: null;
|
||||
|
||||
if (localHtml) {
|
||||
finalHtml = localHtml;
|
||||
} else {
|
||||
if (!templatePath) {
|
||||
throw new Error(`Template not found: ${templateCode}`);
|
||||
}
|
||||
|
||||
const source = fs.readFileSync(templatePath, 'utf-8');
|
||||
const bodyTemplate = handlebars.compile(source);
|
||||
finalHtml = bodyTemplate(allReplacements);
|
||||
}
|
||||
|
||||
const readyTransporter = await initTransporter();
|
||||
@ -109,16 +108,17 @@ export const sendEmail = async (to: string, subject: string, templateCode: strin
|
||||
|
||||
export const sendOpportunityEmail = async (to: string, applicantName: string, location: string, applicationId: string) => {
|
||||
const link = `http://localhost:5173/questionnaire/${applicationId}`;
|
||||
await sendEmail(to, 'Action Required: Royal Enfield Dealership Opportunity', 'opportunity', {
|
||||
await sendEmail(to, 'Action Required: Royal Enfield Dealership Opportunity', 'OPPORTUNITY', {
|
||||
applicantName,
|
||||
location,
|
||||
applicationId,
|
||||
link
|
||||
link,
|
||||
ctaLabel: 'Complete Questionnaire'
|
||||
});
|
||||
};
|
||||
|
||||
export const sendNonOpportunityEmail = async (to: string, applicantName: string, location: string) => {
|
||||
await sendEmail(to, 'Update on your Royal Enfield Dealership Application', 'non_opportunity', {
|
||||
await sendEmail(to, 'Update on your Royal Enfield Dealership Application', 'NON_OPPORTUNITY', {
|
||||
applicantName,
|
||||
location
|
||||
});
|
||||
@ -164,6 +164,7 @@ export const sendShortlistedEmail = async (to: string, applicantName: string, lo
|
||||
applicantName,
|
||||
location,
|
||||
applicationId,
|
||||
portalLink
|
||||
portalLink,
|
||||
ctaLabel: 'Visit Dealer Portal'
|
||||
});
|
||||
};
|
||||
|
||||
@ -94,8 +94,8 @@ export const ExternalMocksService = {
|
||||
},
|
||||
|
||||
/**
|
||||
* Mock SAP Financial Data Retrieval
|
||||
* Simulates fetching outstanding dues and credit limits from SAP.
|
||||
* Mock SAP financial data (ad-hoc tests / future real integration).
|
||||
* F&F settlement no longer auto-creates line items from this on initiate.
|
||||
*/
|
||||
mockGetFinancialDuesFromSap: async (dealerCode: string) => {
|
||||
console.log(`[MOCK SAP] Fetching financial dues for dealer: ${dealerCode}`);
|
||||
|
||||
107
src/common/utils/handlebars-email.ts
Normal file
107
src/common/utils/handlebars-email.ts
Normal file
@ -0,0 +1,107 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import handlebars from 'handlebars';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
let partialsRegistered = false;
|
||||
|
||||
/** Resolve partials folder for both `tsx src/...` and `node dist/src/...` layouts. */
|
||||
/** Inline logo so emails and preview work without public URLs or CID attachments. */
|
||||
function resolveEmailHeaderLogoPath(partialsDir: string): string | null {
|
||||
const candidates = [
|
||||
path.join(partialsDir, '..', 'Re_Logo.png'),
|
||||
path.join(process.cwd(), 'src/emailtemplates/Re_Logo.png'),
|
||||
path.join(process.cwd(), 'Dealer_Onboarding_Backend/src/emailtemplates/Re_Logo.png'),
|
||||
path.join(__dirname, '../../emailtemplates/Re_Logo.png'),
|
||||
path.join(__dirname, '../../../../src/emailtemplates/Re_Logo.png')
|
||||
];
|
||||
for (const p of candidates) {
|
||||
if (fs.existsSync(p)) return p;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function injectEmailHeaderLogo(html: string, partialsDir: string): string {
|
||||
const logoPath = resolveEmailHeaderLogoPath(partialsDir);
|
||||
if (!logoPath) {
|
||||
console.warn('[handlebars-email] Re_Logo.png not found; email header will omit image.');
|
||||
return html.replace(
|
||||
/<img[^>]*src="__EMAIL_HEADER_LOGO_SRC__"[^>]*>/i,
|
||||
'<span style="color:#e31837;font-size:22px;font-weight:bold;letter-spacing:0.05em;">Royal Enfield</span>'
|
||||
);
|
||||
}
|
||||
const b64 = fs.readFileSync(logoPath).toString('base64');
|
||||
const dataUri = `data:image/png;base64,${b64}`;
|
||||
return html.replace(/__EMAIL_HEADER_LOGO_SRC__/g, dataUri);
|
||||
}
|
||||
|
||||
function resolveEmailPartialsDir(): string {
|
||||
const marker = 'email_header.html';
|
||||
const candidates = [
|
||||
path.join(__dirname, '../../emailtemplates/partials'),
|
||||
path.join(__dirname, '../../../../src/emailtemplates/partials'),
|
||||
path.join(process.cwd(), 'src/emailtemplates/partials'),
|
||||
path.join(process.cwd(), 'Dealer_Onboarding_Backend/src/emailtemplates/partials')
|
||||
];
|
||||
for (const dir of candidates) {
|
||||
if (fs.existsSync(path.join(dir, marker))) return dir;
|
||||
}
|
||||
console.warn('[handlebars-email] Could not resolve emailtemplates/partials; tried:', candidates);
|
||||
return candidates[0];
|
||||
}
|
||||
|
||||
/** Register shared layout & CTA partials (idempotent). */
|
||||
export function registerEmailPartials(h: typeof handlebars = handlebars): void {
|
||||
if (partialsRegistered) return;
|
||||
|
||||
const partialsDir = resolveEmailPartialsDir();
|
||||
const map: Record<string, string> = {
|
||||
email_header: 'email_header.html',
|
||||
email_footer: 'email_footer.html',
|
||||
primary_cta: 'primary_cta.html'
|
||||
};
|
||||
|
||||
let loaded = 0;
|
||||
for (const [name, file] of Object.entries(map)) {
|
||||
const filePath = path.join(partialsDir, file);
|
||||
if (fs.existsSync(filePath)) {
|
||||
let src = fs.readFileSync(filePath, 'utf-8');
|
||||
if (name === 'email_header') {
|
||||
src = injectEmailHeaderLogo(src, partialsDir);
|
||||
}
|
||||
h.registerPartial(name, src);
|
||||
loaded++;
|
||||
} else {
|
||||
console.warn(`[handlebars-email] Missing partial file: ${filePath}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (loaded === Object.keys(map).length) {
|
||||
partialsRegistered = true;
|
||||
} else {
|
||||
console.error(
|
||||
`[handlebars-email] Only ${loaded}/${Object.keys(map).length} partials loaded from ${partialsDir}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Normalize CTA fields for templates using {{> primary_cta}} — URLs come from backend placeholders only. */
|
||||
export function normalizeCtaPlaceholders(replacements: Record<string, string>): Record<string, string> {
|
||||
const ctaUrl =
|
||||
replacements.ctaUrl ||
|
||||
replacements.link ||
|
||||
replacements.portalLink ||
|
||||
replacements.actionUrl ||
|
||||
'';
|
||||
|
||||
const ctaLabel = replacements.ctaLabel || 'View details';
|
||||
|
||||
return {
|
||||
...replacements,
|
||||
ctaUrl,
|
||||
ctaLabel
|
||||
};
|
||||
}
|
||||
168
src/common/utils/offboardingWorkflow.utils.ts
Normal file
168
src/common/utils/offboardingWorkflow.utils.ts
Normal file
@ -0,0 +1,168 @@
|
||||
import {
|
||||
TERMINATION_STAGES,
|
||||
RESIGNATION_STAGES,
|
||||
CONSTITUTIONAL_STAGES,
|
||||
RELOCATION_STAGES,
|
||||
REQUEST_TYPES,
|
||||
OFFBOARDING_ACTIONS,
|
||||
AUDIT_ACTIONS
|
||||
} from '../config/constants.js';
|
||||
|
||||
/**
|
||||
* Resolves the previous stage for a given offboarding module and its current stage.
|
||||
* Used for Send Back actions to determine where to roll back the workflow.
|
||||
*/
|
||||
export const getPreviousStage = (requestType: string, currentStage: string): string | null => {
|
||||
switch (requestType) {
|
||||
case REQUEST_TYPES.TERMINATION: {
|
||||
const flow: Record<string, string> = {
|
||||
[TERMINATION_STAGES.RBM_REVIEW]: TERMINATION_STAGES.SUBMITTED,
|
||||
[TERMINATION_STAGES.ZBH_REVIEW]: TERMINATION_STAGES.RBM_REVIEW,
|
||||
[TERMINATION_STAGES.DD_LEAD_REVIEW]: TERMINATION_STAGES.ZBH_REVIEW,
|
||||
[TERMINATION_STAGES.LEGAL_VERIFICATION]: TERMINATION_STAGES.DD_LEAD_REVIEW,
|
||||
[TERMINATION_STAGES.DD_HEAD_REVIEW]: TERMINATION_STAGES.LEGAL_VERIFICATION,
|
||||
[TERMINATION_STAGES.NBH_EVALUATION]: TERMINATION_STAGES.DD_HEAD_REVIEW,
|
||||
[TERMINATION_STAGES.SCN_ISSUED]: TERMINATION_STAGES.NBH_EVALUATION,
|
||||
[TERMINATION_STAGES.PERSONAL_HEARING]: TERMINATION_STAGES.SCN_ISSUED,
|
||||
[TERMINATION_STAGES.NBH_FINAL_APPROVAL]: TERMINATION_STAGES.PERSONAL_HEARING,
|
||||
[TERMINATION_STAGES.CCO_APPROVAL]: TERMINATION_STAGES.NBH_FINAL_APPROVAL,
|
||||
[TERMINATION_STAGES.CEO_APPROVAL]: TERMINATION_STAGES.CCO_APPROVAL,
|
||||
[TERMINATION_STAGES.LEGAL_LETTER]: TERMINATION_STAGES.CEO_APPROVAL,
|
||||
[TERMINATION_STAGES.TERMINATED]: TERMINATION_STAGES.LEGAL_LETTER
|
||||
};
|
||||
return flow[currentStage] || null;
|
||||
}
|
||||
|
||||
case REQUEST_TYPES.RESIGNATION: {
|
||||
const flow: Record<string, string> = {
|
||||
[RESIGNATION_STAGES.RBM]: RESIGNATION_STAGES.ASM,
|
||||
[RESIGNATION_STAGES.ZBH]: RESIGNATION_STAGES.RBM,
|
||||
[RESIGNATION_STAGES.DD_LEAD]: RESIGNATION_STAGES.ZBH,
|
||||
[RESIGNATION_STAGES.NBH]: RESIGNATION_STAGES.DD_LEAD,
|
||||
[RESIGNATION_STAGES.DD_ADMIN]: RESIGNATION_STAGES.NBH,
|
||||
[RESIGNATION_STAGES.LEGAL]: RESIGNATION_STAGES.DD_ADMIN,
|
||||
[RESIGNATION_STAGES.FNF_INITIATED]: RESIGNATION_STAGES.LEGAL,
|
||||
[RESIGNATION_STAGES.COMPLETED]: RESIGNATION_STAGES.FNF_INITIATED
|
||||
};
|
||||
return flow[currentStage] || null;
|
||||
}
|
||||
|
||||
case REQUEST_TYPES.CONSTITUTIONAL: {
|
||||
const flow: Record<string, string> = {
|
||||
[CONSTITUTIONAL_STAGES.ZM_RBM_REVIEW]: CONSTITUTIONAL_STAGES.ASM_REVIEW,
|
||||
|
||||
[CONSTITUTIONAL_STAGES.ZBH_REVIEW]: CONSTITUTIONAL_STAGES.ZM_RBM_REVIEW,
|
||||
[CONSTITUTIONAL_STAGES.LEAD_REVIEW]: CONSTITUTIONAL_STAGES.ZBH_REVIEW,
|
||||
[CONSTITUTIONAL_STAGES.HEAD_REVIEW]: CONSTITUTIONAL_STAGES.LEAD_REVIEW,
|
||||
[CONSTITUTIONAL_STAGES.NBH_APPROVAL]: CONSTITUTIONAL_STAGES.HEAD_REVIEW,
|
||||
[CONSTITUTIONAL_STAGES.LEGAL_REVIEW]: CONSTITUTIONAL_STAGES.NBH_APPROVAL,
|
||||
[CONSTITUTIONAL_STAGES.COMPLETED]: CONSTITUTIONAL_STAGES.LEGAL_REVIEW
|
||||
};
|
||||
return flow[currentStage] || null;
|
||||
}
|
||||
|
||||
case REQUEST_TYPES.RELOCATION: {
|
||||
const flow: Record<string, string> = {
|
||||
[RELOCATION_STAGES.RBM_REVIEW]: RELOCATION_STAGES.ASM_REVIEW,
|
||||
[RELOCATION_STAGES.DD_ZM_REVIEW]: RELOCATION_STAGES.RBM_REVIEW,
|
||||
[RELOCATION_STAGES.ZBH_REVIEW]: RELOCATION_STAGES.DD_ZM_REVIEW,
|
||||
[RELOCATION_STAGES.DD_LEAD_REVIEW]: RELOCATION_STAGES.ZBH_REVIEW,
|
||||
[RELOCATION_STAGES.DD_HEAD_APPROVAL]: RELOCATION_STAGES.DD_LEAD_REVIEW,
|
||||
[RELOCATION_STAGES.NBH_APPROVAL]: RELOCATION_STAGES.DD_HEAD_APPROVAL,
|
||||
[RELOCATION_STAGES.LEGAL_CLEARANCE]: RELOCATION_STAGES.NBH_APPROVAL,
|
||||
[RELOCATION_STAGES.NBH_CLEARANCE_EOR]: RELOCATION_STAGES.LEGAL_CLEARANCE
|
||||
};
|
||||
return flow[currentStage] || null;
|
||||
}
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates the common offboarding action payload.
|
||||
* Standardizes the mandatory remarks check for Send Back and Revoke actions.
|
||||
*/
|
||||
export const validateOffboardingAction = (action: string, remarks: string): { valid: boolean; message?: string } => {
|
||||
const actionLower = action.toLowerCase();
|
||||
const isSendBack = actionLower === OFFBOARDING_ACTIONS.SEND_BACK.toLowerCase() || actionLower.includes('send') && actionLower.includes('back');
|
||||
const isRevoke = actionLower === OFFBOARDING_ACTIONS.REVOKE.toLowerCase();
|
||||
|
||||
if ((isSendBack || isRevoke) && (!remarks || remarks.trim().length < 5)) {
|
||||
return {
|
||||
valid: false,
|
||||
message: `Mandatory remarks (min 5 characters) required for ${isSendBack ? 'Send Back' : 'Revoke'} action.`
|
||||
};
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
};
|
||||
|
||||
/**
|
||||
* Maps offboarding action to system AUDIT_ACTIONS
|
||||
*/
|
||||
export const getOffboardingAuditAction = (action: string, requestType: string): string => {
|
||||
const actionLower = action.toLowerCase();
|
||||
|
||||
// 1. Direct intent mapping (Deterministic)
|
||||
if (actionLower === OFFBOARDING_ACTIONS.REJECT.toLowerCase()) return AUDIT_ACTIONS.REJECTED;
|
||||
if (actionLower === OFFBOARDING_ACTIONS.APPROVE.toLowerCase()) return AUDIT_ACTIONS.APPROVED;
|
||||
if (actionLower === OFFBOARDING_ACTIONS.REVOKE.toLowerCase()) {
|
||||
switch (requestType) {
|
||||
case REQUEST_TYPES.RESIGNATION: return AUDIT_ACTIONS.RESIGNATION_REVOKED;
|
||||
case REQUEST_TYPES.TERMINATION: return AUDIT_ACTIONS.TERMINATION_REVOKED;
|
||||
case REQUEST_TYPES.RELOCATION: return AUDIT_ACTIONS.RELOCATION_REVOKED;
|
||||
case REQUEST_TYPES.CONSTITUTIONAL: return AUDIT_ACTIONS.CONSTITUTIONAL_REVOKED;
|
||||
default: return AUDIT_ACTIONS.UPDATED;
|
||||
}
|
||||
}
|
||||
if (actionLower === OFFBOARDING_ACTIONS.SEND_BACK.toLowerCase() || actionLower === OFFBOARDING_ACTIONS.RECONSIDER.toLowerCase()) {
|
||||
switch (requestType) {
|
||||
case REQUEST_TYPES.RESIGNATION: return AUDIT_ACTIONS.RESIGNATION_SENT_BACK;
|
||||
case REQUEST_TYPES.TERMINATION: return AUDIT_ACTIONS.TERMINATION_SENT_BACK;
|
||||
case REQUEST_TYPES.RELOCATION: return AUDIT_ACTIONS.RELOCATION_SENT_BACK;
|
||||
case REQUEST_TYPES.CONSTITUTIONAL: return AUDIT_ACTIONS.CONSTITUTIONAL_SENT_BACK;
|
||||
default: return AUDIT_ACTIONS.UPDATED;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Fallback to descriptive scanning (Legacy/Edge cases)
|
||||
if (actionLower.includes('reject')) return AUDIT_ACTIONS.REJECTED;
|
||||
if (actionLower.includes('revok')) {
|
||||
switch (requestType) {
|
||||
case REQUEST_TYPES.TERMINATION: return AUDIT_ACTIONS.TERMINATION_REVOKED;
|
||||
default: return AUDIT_ACTIONS.UPDATED;
|
||||
}
|
||||
}
|
||||
if (actionLower.includes('send back') || actionLower.includes('sent back') || actionLower.includes('reconsider')) {
|
||||
switch (requestType) {
|
||||
case REQUEST_TYPES.TERMINATION: return AUDIT_ACTIONS.TERMINATION_SENT_BACK;
|
||||
default: return AUDIT_ACTIONS.UPDATED;
|
||||
}
|
||||
}
|
||||
|
||||
return (actionLower.includes('approval') || actionLower.includes('approve')) ? AUDIT_ACTIONS.APPROVED : AUDIT_ACTIONS.UPDATED;
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats offboarding actions and stages for user-friendly display (Title Case).
|
||||
* Replaces underscores/hyphens with spaces and preserves specific abbreviations.
|
||||
*/
|
||||
export const formatOffboardingAction = (label: string): string => {
|
||||
if (!label) return '';
|
||||
|
||||
// 1. Clean up separators and normalize case
|
||||
let formatted = label.replace(/[_-]/g, ' ').toLowerCase();
|
||||
|
||||
// 2. Identify tokens and preserve known abbreviations
|
||||
const abbreviations = ['CCO', 'NBH', 'CEO', 'RBM', 'ZBH', 'ASM', 'LOA', 'SCN', 'DD', 'ZM', 'FNF'];
|
||||
|
||||
return formatted.split(' ').map(word => {
|
||||
const upperWord = word.toUpperCase();
|
||||
if (abbreviations.includes(upperWord)) return upperWord;
|
||||
|
||||
// Capitalize first letter
|
||||
return word.charAt(0).toUpperCase() + word.slice(1);
|
||||
}).join(' ');
|
||||
};
|
||||
@ -2,8 +2,26 @@ import { Op } from 'sequelize';
|
||||
import { REQUEST_TYPES } from '../config/constants.js';
|
||||
|
||||
type DbLike = Record<string, any>;
|
||||
|
||||
/** RFC-style UUID v1–v5 shape; same rule used inside {@link resolveEntityUuidByType}. */
|
||||
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
|
||||
/** Use before binding to UUID-typed columns (e.g. `worknotes.request_id`) — human codes like REL-xxx / CC-xxx must not be passed raw. */
|
||||
export function isEntityUuidString(id: string | undefined | null): boolean {
|
||||
return UUID_REGEX.test(String(id || '').trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* Distinct UUID strings safe for FK lookups on `worknotes.request_id` after {@link resolveEntityUuidByType}.
|
||||
* Drops human-readable business IDs when the resolved row exists (resolvedId is UUID).
|
||||
*/
|
||||
export function uuidCandidatesForWorknoteRequestId(
|
||||
rawId: string | undefined | null,
|
||||
resolvedId: string | undefined | null
|
||||
): string[] {
|
||||
return [...new Set([String(rawId || '').trim(), String(resolvedId || '').trim()].filter(Boolean))].filter(isEntityUuidString);
|
||||
}
|
||||
|
||||
const TYPE_ALIASES: Record<string, string> = {
|
||||
application: 'application',
|
||||
onboarding: 'application',
|
||||
@ -50,7 +68,7 @@ export async function resolveEntityUuidByType(
|
||||
const cfg = LOOKUP_CONFIG[normalizedType];
|
||||
if (!cfg || !db?.[cfg.model]) return { resolvedId: id, normalizedType };
|
||||
|
||||
const isUuid = UUID_REGEX.test(id);
|
||||
const isUuid = isEntityUuidString(id);
|
||||
const where = isUuid
|
||||
? { [Op.or]: [{ id }, { [cfg.codeField]: id }] }
|
||||
: { [cfg.codeField]: id };
|
||||
|
||||
146
src/common/utils/workflow-email-notifications.ts
Normal file
146
src/common/utils/workflow-email-notifications.ts
Normal file
@ -0,0 +1,146 @@
|
||||
import db from '../../database/models/index.js';
|
||||
import { Op } from 'sequelize';
|
||||
import { sendEmail } from './email.service.js';
|
||||
import { NotificationService } from '../../services/NotificationService.js';
|
||||
import { REQUEST_TYPES } from '../config/constants.js';
|
||||
|
||||
const { RequestParticipant, User, Outlet, District } = db;
|
||||
|
||||
const frontendBase = () => process.env.FRONTEND_URL || 'http://localhost:5173';
|
||||
|
||||
/** Dealer acknowledgement + internal reviewers after resignation is created. */
|
||||
export async function notifyResignationSubmittedEmails(resignation: any): Promise<void> {
|
||||
const dealerUser = await User.findByPk(resignation.dealerId, {
|
||||
attributes: ['id', 'email', 'fullName']
|
||||
});
|
||||
if (!dealerUser?.email) return;
|
||||
|
||||
const base = frontendBase();
|
||||
const resignationCode = resignation.resignationId || resignation.id;
|
||||
const lwd =
|
||||
resignation.lastOperationalDateSales ||
|
||||
resignation.lastOperationalDateServices ||
|
||||
'As per application';
|
||||
const dealerName = dealerUser.fullName || 'Dealer';
|
||||
|
||||
await sendEmail(
|
||||
dealerUser.email,
|
||||
`We received your resignation request — ${resignationCode}`,
|
||||
'RESIGNATION_RECEIVED',
|
||||
{
|
||||
dealerName,
|
||||
resignationId: resignationCode,
|
||||
lwd: String(lwd),
|
||||
link: `${base}/dealer-resignation/${resignation.id}`,
|
||||
ctaLabel: 'View request'
|
||||
}
|
||||
).catch((err) => console.error('[notifyResignationSubmittedEmails] dealer ack:', err));
|
||||
|
||||
const participants = await RequestParticipant.findAll({
|
||||
where: {
|
||||
requestId: resignation.id,
|
||||
requestType: REQUEST_TYPES.RESIGNATION,
|
||||
userId: { [Op.ne]: resignation.dealerId }
|
||||
},
|
||||
include: [{ model: User, as: 'user', attributes: ['id', 'email', 'fullName'] }]
|
||||
});
|
||||
|
||||
const internalLink = `${base}/resignation/${resignation.id}`;
|
||||
for (const p of participants) {
|
||||
const u = (p as any).user;
|
||||
if (!u?.email) continue;
|
||||
await NotificationService.notify(u.id, u.email, {
|
||||
title: `New resignation request: ${resignationCode}`,
|
||||
message: `Submitted by ${dealerName}.`,
|
||||
channels: ['email', 'system'],
|
||||
templateCode: 'RESIGNATION_SUBMITTED',
|
||||
placeholders: {
|
||||
dealerName,
|
||||
resignationId: resignationCode,
|
||||
lwd: String(lwd),
|
||||
link: internalLink,
|
||||
ctaLabel: 'Review resignation'
|
||||
}
|
||||
}).catch((err) => console.error('[notifyResignationSubmittedEmails] internal:', err));
|
||||
}
|
||||
}
|
||||
|
||||
/** Internal reviewers after constitutional request is created. */
|
||||
export async function notifyConstitutionalSubmittedEmails(request: any, dealerDisplayName: string): Promise<void> {
|
||||
const participants = await RequestParticipant.findAll({
|
||||
where: {
|
||||
requestId: request.id,
|
||||
requestType: REQUEST_TYPES.CONSTITUTIONAL,
|
||||
userId: { [Op.ne]: request.dealerId }
|
||||
},
|
||||
include: [{ model: User, as: 'user', attributes: ['id', 'email', 'fullName'] }]
|
||||
});
|
||||
|
||||
const base = frontendBase();
|
||||
const link = `${base}/constitutional-change/${request.id}`;
|
||||
|
||||
for (const p of participants) {
|
||||
const u = (p as any).user;
|
||||
if (!u?.email) continue;
|
||||
await NotificationService.notify(u.id, u.email, {
|
||||
title: `New constitutional change request: ${request.requestId}`,
|
||||
message: `${dealerDisplayName} submitted a request.`,
|
||||
channels: ['email', 'system'],
|
||||
templateCode: 'CONSTITUTIONAL_CHANGE_SUBMITTED',
|
||||
placeholders: {
|
||||
dealerName: dealerDisplayName,
|
||||
changeType: request.changeType || '',
|
||||
requestId: request.requestId,
|
||||
link,
|
||||
ctaLabel: 'Review request'
|
||||
}
|
||||
}).catch((err) => console.error('[notifyConstitutionalSubmittedEmails]:', err));
|
||||
}
|
||||
}
|
||||
|
||||
/** Dealer + ASM when relocation request is submitted. */
|
||||
export async function notifyRelocationSubmittedEmails(
|
||||
request: any,
|
||||
submitter: { email: string; fullName?: string | null }
|
||||
): Promise<void> {
|
||||
const base = frontendBase();
|
||||
const code = request.requestId || request.id;
|
||||
const dealerName = submitter.fullName?.trim() || 'Dealer';
|
||||
|
||||
if (submitter.email) {
|
||||
await sendEmail(
|
||||
submitter.email,
|
||||
`Relocation request received — ${code}`,
|
||||
'RELOCATION_RECEIVED',
|
||||
{
|
||||
dealerName,
|
||||
requestId: code,
|
||||
link: `${base}/relocation-requests/${request.id}`,
|
||||
ctaLabel: 'View request'
|
||||
}
|
||||
).catch((err) => console.error('[notifyRelocationSubmittedEmails] dealer:', err));
|
||||
}
|
||||
|
||||
const outlet = await Outlet.findByPk(request.outletId, {
|
||||
include: [{ model: District, as: 'district', attributes: ['id', 'asmId'] }]
|
||||
});
|
||||
const asmId = (outlet as any)?.district?.asmId;
|
||||
if (!asmId) return;
|
||||
|
||||
const asm = await User.findByPk(asmId, { attributes: ['id', 'email', 'fullName'] });
|
||||
if (!asm?.email) return;
|
||||
|
||||
await NotificationService.notify(asm.id, asm.email, {
|
||||
title: `New relocation request: ${code}`,
|
||||
message: 'A dealer submitted an outlet relocation request.',
|
||||
channels: ['email', 'system'],
|
||||
templateCode: 'RELOCATION_SUBMITTED',
|
||||
placeholders: {
|
||||
dealerName,
|
||||
requestId: code,
|
||||
outletCode: outlet?.code || '',
|
||||
link: `${base}/relocation-requests/${request.id}`,
|
||||
ctaLabel: 'Review relocation'
|
||||
}
|
||||
}).catch((err) => console.error('[notifyRelocationSubmittedEmails] ASM:', err));
|
||||
}
|
||||
38
src/constants/allowed-email-template-codes.ts
Normal file
38
src/constants/allowed-email-template-codes.ts
Normal file
@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Allowed email template trigger codes — must match workflows and seed-master-emails.ts.
|
||||
*/
|
||||
export const ALLOWED_EMAIL_TEMPLATE_CODES = [
|
||||
'APPLICANT_SHORTLISTED',
|
||||
'CONSTITUTIONAL_CHANGE_SUBMITTED',
|
||||
'CONSTITUTIONAL_CHANGE_UPDATE',
|
||||
'DEALER_CODE_READY',
|
||||
'GENERIC_NOTIFICATION',
|
||||
'INTERVIEW_SCHEDULED',
|
||||
'LOA_ISSUED',
|
||||
'LOI_ISSUED',
|
||||
'NON_OPPORTUNITY',
|
||||
'ONBOARDING_STATUS_UPDATE',
|
||||
'OPPORTUNITY',
|
||||
'QUESTIONNAIRE_REMINDER',
|
||||
'QUESTIONNAIRE_SUBMITTED',
|
||||
'RELOCATION_RECEIVED',
|
||||
'RELOCATION_SUBMITTED',
|
||||
'RELOCATION_UPDATE',
|
||||
'RESIGNATION_APPROVED',
|
||||
'RESIGNATION_RECEIVED',
|
||||
'RESIGNATION_SUBMITTED',
|
||||
'RESIGNATION_UPDATE',
|
||||
'SLA_BREACH_WARNING',
|
||||
'TERMINATION_SCN_ISSUED',
|
||||
'TERMINATION_UPDATE',
|
||||
'USER_ASSIGNED',
|
||||
'WORKNOTE_NOTIFICATION'
|
||||
] as const;
|
||||
|
||||
export type AllowedEmailTemplateCode = (typeof ALLOWED_EMAIL_TEMPLATE_CODES)[number];
|
||||
|
||||
const ALLOWED_SET = new Set<string>(ALLOWED_EMAIL_TEMPLATE_CODES);
|
||||
|
||||
export function isAllowedEmailTemplateCode(code: string): boolean {
|
||||
return ALLOWED_SET.has(code.trim().toUpperCase());
|
||||
}
|
||||
@ -1,9 +1,22 @@
|
||||
import { Request, Response } from 'express';
|
||||
import db from '../../database/models/index.js';
|
||||
import handlebars from 'handlebars';
|
||||
import { registerEmailPartials, normalizeCtaPlaceholders } from '../../common/utils/handlebars-email.js';
|
||||
import { sanitizeEmailTemplateBody, sanitizeEmailTemplateSubject } from '../../common/utils/email-template-sanitize.js';
|
||||
import { isAllowedEmailTemplateCode } from '../../constants/allowed-email-template-codes.js';
|
||||
|
||||
const { EmailTemplate } = db;
|
||||
|
||||
const editableTemplateFields = ['templateCode', 'description', 'subject', 'body', 'placeholders', 'isActive'] as const;
|
||||
|
||||
function pickEditableTemplatePayload(body: Record<string, unknown>) {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const key of editableTemplateFields) {
|
||||
if (key in body) out[key] = body[key];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export const EmailTemplateController = {
|
||||
// Get all templates
|
||||
getAllTemplates: async (req: Request, res: Response) => {
|
||||
@ -38,7 +51,22 @@ export const EmailTemplateController = {
|
||||
// Create template
|
||||
createTemplate: async (req: Request, res: Response) => {
|
||||
try {
|
||||
const template = await EmailTemplate.create(req.body);
|
||||
const payload = pickEditableTemplatePayload(req.body as Record<string, unknown>);
|
||||
if (typeof payload.body === 'string') payload.body = sanitizeEmailTemplateBody(payload.body);
|
||||
if (typeof payload.subject === 'string') payload.subject = sanitizeEmailTemplateSubject(payload.subject);
|
||||
|
||||
const rawCode = payload.templateCode;
|
||||
const normalized =
|
||||
typeof rawCode === 'string' ? rawCode.trim().toUpperCase() : '';
|
||||
if (!normalized || !isAllowedEmailTemplateCode(normalized)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'templateCode must be one of the system-defined trigger codes.'
|
||||
});
|
||||
}
|
||||
payload.templateCode = normalized;
|
||||
|
||||
const template = await EmailTemplate.create(payload);
|
||||
res.status(201).json({ success: true, data: template, message: 'Template created successfully' });
|
||||
} catch (error) {
|
||||
console.error('Error creating template:', error);
|
||||
@ -50,7 +78,30 @@ export const EmailTemplateController = {
|
||||
updateTemplate: async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const [updated] = await EmailTemplate.update(req.body, {
|
||||
const existing = await EmailTemplate.findByPk(id);
|
||||
if (!existing) {
|
||||
return res.status(404).json({ success: false, message: 'Template not found' });
|
||||
}
|
||||
|
||||
const payload = pickEditableTemplatePayload(req.body as Record<string, unknown>);
|
||||
if (typeof payload.body === 'string') payload.body = sanitizeEmailTemplateBody(payload.body);
|
||||
if (typeof payload.subject === 'string') payload.subject = sanitizeEmailTemplateSubject(payload.subject);
|
||||
|
||||
if ('templateCode' in payload && payload.templateCode !== undefined) {
|
||||
const raw = payload.templateCode;
|
||||
const normalized =
|
||||
typeof raw === 'string' ? raw.trim().toUpperCase() : '';
|
||||
const previous = existing.templateCode.trim().toUpperCase();
|
||||
if (normalized !== previous && !isAllowedEmailTemplateCode(normalized)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'templateCode must be one of the system-defined trigger codes.'
|
||||
});
|
||||
}
|
||||
payload.templateCode = normalized;
|
||||
}
|
||||
|
||||
const [updated] = await EmailTemplate.update(payload, {
|
||||
where: { id }
|
||||
});
|
||||
|
||||
@ -98,15 +149,23 @@ export const EmailTemplateController = {
|
||||
let compiledSubject = subject;
|
||||
let compiledBody = body;
|
||||
|
||||
const safeData = data || {};
|
||||
registerEmailPartials(handlebars);
|
||||
|
||||
const safeBody = sanitizeEmailTemplateBody(body);
|
||||
const safeSubject = subject ? sanitizeEmailTemplateSubject(subject) : '';
|
||||
|
||||
const safeData = normalizeCtaPlaceholders({
|
||||
...(data || {}),
|
||||
year: new Date().getFullYear().toString()
|
||||
});
|
||||
|
||||
try {
|
||||
if (subject) {
|
||||
const subjectTemplate = handlebars.compile(subject);
|
||||
const subjectTemplate = handlebars.compile(safeSubject);
|
||||
compiledSubject = subjectTemplate(safeData);
|
||||
}
|
||||
|
||||
const bodyTemplate = handlebars.compile(body);
|
||||
const bodyTemplate = handlebars.compile(safeBody);
|
||||
compiledBody = bodyTemplate(safeData);
|
||||
|
||||
res.json({
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { Model, DataTypes, Sequelize } from 'sequelize';
|
||||
import { AUDIT_ACTIONS } from '../../common/config/constants.js';
|
||||
import { AUDIT_ACTIONS } from '../../../common/config/constants.js';
|
||||
|
||||
export interface AuditLogAttributes {
|
||||
id: string;
|
||||
@ -1,5 +1,5 @@
|
||||
import { Model, DataTypes, Sequelize } from 'sequelize';
|
||||
import { APPLICATION_STAGES, APPLICATION_STATUS, BUSINESS_TYPES } from '../../common/config/constants.js';
|
||||
import { APPLICATION_STAGES, APPLICATION_STATUS, BUSINESS_TYPES } from '../../../common/config/constants.js';
|
||||
|
||||
export interface ApplicationAttributes {
|
||||
id: string;
|
||||
@ -1,5 +1,5 @@
|
||||
import { Model, DataTypes, Sequelize } from 'sequelize';
|
||||
import { REQUEST_TYPES, DOCUMENT_TYPES } from '../../common/config/constants.js';
|
||||
import { REQUEST_TYPES, DOCUMENT_TYPES } from '../../../common/config/constants.js';
|
||||
|
||||
export interface DocumentAttributes {
|
||||
id: string;
|
||||
@ -1,5 +1,5 @@
|
||||
import { Model, DataTypes, Sequelize } from 'sequelize';
|
||||
import { ROLES, REGIONS } from '../../common/config/constants.js';
|
||||
import { ROLES, REGIONS } from '../../../common/config/constants.js';
|
||||
|
||||
export interface UserAttributes {
|
||||
id: string;
|
||||
@ -1,5 +1,5 @@
|
||||
import { Model, DataTypes, Sequelize } from 'sequelize';
|
||||
import { OUTLET_TYPES, OUTLET_STATUS, REGIONS } from '../../common/config/constants.js';
|
||||
import { OUTLET_TYPES, OUTLET_STATUS, REGIONS } from '../../../common/config/constants.js';
|
||||
|
||||
export interface OutletAttributes {
|
||||
id: string;
|
||||
@ -1,5 +1,5 @@
|
||||
import { Model, DataTypes, Sequelize } from 'sequelize';
|
||||
import { PAYMENT_TYPES, PAYMENT_STATUS } from '../../common/config/constants.js';
|
||||
import { PAYMENT_TYPES, PAYMENT_STATUS } from '../../../common/config/constants.js';
|
||||
|
||||
export interface FinancePaymentAttributes {
|
||||
id: string;
|
||||
@ -1,5 +1,5 @@
|
||||
import { Model, DataTypes, Sequelize } from 'sequelize';
|
||||
import { FNF_STATUS } from '../../common/config/constants.js';
|
||||
import { FNF_STATUS } from '../../../common/config/constants.js';
|
||||
|
||||
export interface FnFAttributes {
|
||||
id: string;
|
||||
@ -2,104 +2,106 @@ import { Sequelize } from 'sequelize';
|
||||
import config from '../../common/config/database.js';
|
||||
|
||||
// Import individual model factories
|
||||
import createUser from './User.js';
|
||||
import createApplication from './Application.js';
|
||||
import createResignation from './Resignation.js';
|
||||
import createConstitutionalChange from './ConstitutionalChange.js';
|
||||
import createRelocationRequest from './RelocationRequest.js';
|
||||
import createOutlet from './Outlet.js';
|
||||
import createWorknote from './Worknote.js';
|
||||
import createOnboardingDocument from './OnboardingDocument.js';
|
||||
import createAuditLog from './AuditLog.js';
|
||||
import createFinancePayment from './FinancePayment.js';
|
||||
import createRelocationDocument from './RelocationDocument.js';
|
||||
import createResignationDocument from './ResignationDocument.js';
|
||||
import createConstitutionalDocument from './ConstitutionalDocument.js';
|
||||
import createTerminationDocument from './TerminationDocument.js';
|
||||
import createFnF from './FnF.js';
|
||||
import createFnFLineItem from './FnFLineItem.js';
|
||||
import createSLAConfiguration from './SLAConfiguration.js';
|
||||
import createSLAReminder from './SLAReminder.js';
|
||||
import createSLAEscalationConfig from './SLAEscalationConfig.js';
|
||||
import createWorkflowStageConfig from './WorkflowStageConfig.js';
|
||||
import createSystemConfiguration from './SystemConfiguration.js';
|
||||
import createDocumentStageConfig from './DocumentStageConfig.js';
|
||||
import createNotification from './Notification.js';
|
||||
import createDistrict from './District.js';
|
||||
import createLocation from './Location.js';
|
||||
import createZone from './Zone.js';
|
||||
import createRegion from './Region.js';
|
||||
import createState from './State.js';
|
||||
import createTerminationScnResponse from './TerminationScnResponse.js';
|
||||
import createTerminationHearingRecord from './TerminationHearingRecord.js';
|
||||
import createFffClearance from './FffClearance.js';
|
||||
import createResignationAudit from './ResignationAudit.js';
|
||||
import createTerminationAudit from './TerminationAudit.js';
|
||||
import createFnFAudit from './FnFAudit.js';
|
||||
import createConstitutionalAudit from './ConstitutionalAudit.js';
|
||||
import createRelocationAudit from './RelocationAudit.js';
|
||||
|
||||
// Batch 1: Organizational Hierarchy & User Management
|
||||
import createRole from './Role.js';
|
||||
import createPermission from './Permission.js';
|
||||
import createRolePermission from './RolePermission.js';
|
||||
import createUserRole from './UserRole.js';
|
||||
// Core
|
||||
import createUser from './core/User.js';
|
||||
import createRole from './core/Role.js';
|
||||
import createPermission from './core/Permission.js';
|
||||
import createRolePermission from './core/RolePermission.js';
|
||||
import createUserRole from './core/UserRole.js';
|
||||
import createSystemConfiguration from './core/SystemConfiguration.js';
|
||||
import createEmailTemplate from './core/EmailTemplate.js';
|
||||
import createPushSubscription from './core/PushSubscription.js';
|
||||
import createNotification from './core/Notification.js';
|
||||
import createState from './core/State.js';
|
||||
import createDistrict from './core/District.js';
|
||||
import createRegion from './core/Region.js';
|
||||
import createZone from './core/Zone.js';
|
||||
import createLocation from './core/Location.js';
|
||||
|
||||
// Batch 2: Opportunity & Application Framework
|
||||
import createOpportunity from './Opportunity.js';
|
||||
import createApplicationStatusHistory from './ApplicationStatusHistory.js';
|
||||
import createApplicationProgress from './ApplicationProgress.js';
|
||||
// Application
|
||||
import createApplication from './application/Application.js';
|
||||
import createApplicationProgress from './application/ApplicationProgress.js';
|
||||
import createApplicationStatusHistory from './application/ApplicationStatusHistory.js';
|
||||
import createOpportunity from './application/Opportunity.js';
|
||||
import createOnboardingDocument from './application/OnboardingDocument.js';
|
||||
import createDocumentVersion from './application/DocumentVersion.js';
|
||||
|
||||
// Batch 3: Questionnaire & Interview Systems
|
||||
import createQuestionnaire from './Questionnaire.js';
|
||||
import createQuestionnaireQuestion from './QuestionnaireQuestion.js';
|
||||
import createQuestionnaireOption from './QuestionnaireOption.js';
|
||||
import createQuestionnaireResponse from './QuestionnaireResponse.js';
|
||||
import createQuestionnaireScore from './QuestionnaireScore.js';
|
||||
import createInterview from './Interview.js';
|
||||
import createInterviewParticipant from './InterviewParticipant.js';
|
||||
import createInterviewEvaluation from './InterviewEvaluation.js';
|
||||
import createKTMatrixScore from './KTMatrixScore.js';
|
||||
import createInterviewFeedback from './InterviewFeedback.js';
|
||||
import createAiSummary from './AiSummary.js';
|
||||
// Dealer
|
||||
import createDealer from './dealer/Dealer.js';
|
||||
import createDealerCode from './dealer/DealerCode.js';
|
||||
import createDealerBankDetail from './dealer/DealerBankDetail.js';
|
||||
import createOutlet from './dealer/Outlet.js';
|
||||
|
||||
// Batch 4: Dealer Entity, Documents & Work Notes
|
||||
import createDealer from './Dealer.js';
|
||||
import createDealerCode from './DealerCode.js';
|
||||
import createDealerBankDetail from './DealerBankDetail.js';
|
||||
import createDocumentVersion from './DocumentVersion.js';
|
||||
import createWorkNoteTag from './WorkNoteTag.js';
|
||||
import createWorkNoteAttachment from './WorkNoteAttachment.js';
|
||||
import createRequestParticipant from './RequestParticipant.js';
|
||||
// Verification
|
||||
import createInterview from './verification/Interview.js';
|
||||
import createInterviewEvaluation from './verification/InterviewEvaluation.js';
|
||||
import createInterviewFeedback from './verification/InterviewFeedback.js';
|
||||
import createInterviewParticipant from './verification/InterviewParticipant.js';
|
||||
import createQuestionnaire from './verification/Questionnaire.js';
|
||||
import createQuestionnaireOption from './verification/QuestionnaireOption.js';
|
||||
import createQuestionnaireQuestion from './verification/QuestionnaireQuestion.js';
|
||||
import createQuestionnaireResponse from './verification/QuestionnaireResponse.js';
|
||||
import createQuestionnaireScore from './verification/QuestionnaireScore.js';
|
||||
import createKTMatrixScore from './verification/KTMatrixScore.js';
|
||||
import createAiSummary from './verification/AiSummary.js';
|
||||
import createFddAssignment from './verification/FddAssignment.js';
|
||||
import createFddReport from './verification/FddReport.js';
|
||||
|
||||
// Batch 5: FDD, LOI, LOA, EOR & Security Deposit
|
||||
import createFddAssignment from './FddAssignment.js';
|
||||
import createFddReport from './FddReport.js';
|
||||
import createLoiRequest from './LoiRequest.js';
|
||||
import createLoiApproval from './LoiApproval.js';
|
||||
import createLoiDocumentGenerated from './LoiDocumentGenerated.js';
|
||||
import createLoiAcknowledgement from './LoiAcknowledgement.js';
|
||||
import createSecurityDeposit from './SecurityDeposit.js';
|
||||
import createLoaRequest from './LoaRequest.js';
|
||||
import createLoaApproval from './LoaApproval.js';
|
||||
import createLoaDocumentGenerated from './LoaDocumentGenerated.js';
|
||||
import createLoaAcknowledgement from './LoaAcknowledgement.js';
|
||||
import createEorChecklist from './EorChecklist.js';
|
||||
import createEorChecklistItem from './EorChecklistItem.js';
|
||||
// Approval
|
||||
import createLoiRequest from './approval/LoiRequest.js';
|
||||
import createLoiApproval from './approval/LoiApproval.js';
|
||||
import createLoiAcknowledgement from './approval/LoiAcknowledgement.js';
|
||||
import createLoiDocumentGenerated from './approval/LoiDocumentGenerated.js';
|
||||
import createLoaRequest from './approval/LoaRequest.js';
|
||||
import createLoaApproval from './approval/LoaApproval.js';
|
||||
import createLoaAcknowledgement from './approval/LoaAcknowledgement.js';
|
||||
import createLoaDocumentGenerated from './approval/LoaDocumentGenerated.js';
|
||||
import createSecurityDeposit from './approval/SecurityDeposit.js';
|
||||
|
||||
// Batch 6: Offboarding & F&F Settlement
|
||||
import createTerminationRequest from './TerminationRequest.js';
|
||||
import createExitFeedback from './ExitFeedback.js';
|
||||
// Offboarding
|
||||
import createTerminationRequest from './offboarding/termination/TerminationRequest.js';
|
||||
import createTerminationAudit from './offboarding/termination/TerminationAudit.js';
|
||||
import createTerminationDocument from './offboarding/termination/TerminationDocument.js';
|
||||
import createTerminationHearingRecord from './offboarding/termination/TerminationHearingRecord.js';
|
||||
import createTerminationScnResponse from './offboarding/termination/TerminationScnResponse.js';
|
||||
import createResignation from './offboarding/resignation/Resignation.js';
|
||||
import createResignationAudit from './offboarding/resignation/ResignationAudit.js';
|
||||
import createResignationDocument from './offboarding/resignation/ResignationDocument.js';
|
||||
import createRelocationRequest from './offboarding/relocation/RelocationRequest.js';
|
||||
import createRelocationAudit from './offboarding/relocation/RelocationAudit.js';
|
||||
import createRelocationDocument from './offboarding/relocation/RelocationDocument.js';
|
||||
import createConstitutionalChange from './offboarding/constitutional/ConstitutionalChange.js';
|
||||
import createConstitutionalAudit from './offboarding/constitutional/ConstitutionalAudit.js';
|
||||
import createConstitutionalDocument from './offboarding/constitutional/ConstitutionalDocument.js';
|
||||
import createExitFeedback from './offboarding/common/ExitFeedback.js';
|
||||
|
||||
// Batch 7: Notifications, Logs & Templates
|
||||
import createEmailTemplate from './EmailTemplate.js';
|
||||
import createPushSubscription from './PushSubscription.js';
|
||||
// Financial
|
||||
import createFnF from './financial/FnF.js';
|
||||
import createFnFAudit from './financial/FnFAudit.js';
|
||||
import createFnFLineItem from './financial/FnFLineItem.js';
|
||||
import createFffClearance from './financial/FffClearance.js';
|
||||
import createFinancePayment from './financial/FinancePayment.js';
|
||||
|
||||
// Batch 8: SLA & TAT Tracking
|
||||
import createSLATracking from './SLATracking.js';
|
||||
import createSLABreach from './SLABreach.js';
|
||||
import createStageApprovalPolicy from './StageApprovalPolicy.js';
|
||||
import createStageApprovalAction from './StageApprovalAction.js';
|
||||
// Compliance
|
||||
import createEorChecklist from './compliance/EorChecklist.js';
|
||||
import createEorChecklistItem from './compliance/EorChecklistItem.js';
|
||||
import createSLAConfiguration from './compliance/SLAConfiguration.js';
|
||||
import createSLABreach from './compliance/SLABreach.js';
|
||||
import createSLAEscalationConfig from './compliance/SLAEscalationConfig.js';
|
||||
import createSLAReminder from './compliance/SLAReminder.js';
|
||||
import createSLATracking from './compliance/SLATracking.js';
|
||||
import createWorkflowStageConfig from './compliance/WorkflowStageConfig.js';
|
||||
import createStageApprovalAction from './compliance/StageApprovalAction.js';
|
||||
import createStageApprovalPolicy from './compliance/StageApprovalPolicy.js';
|
||||
import createDocumentStageConfig from './compliance/DocumentStageConfig.js';
|
||||
import createRequestParticipant from './compliance/RequestParticipant.js';
|
||||
|
||||
// Activity
|
||||
import createAuditLog from './activity/AuditLog.js';
|
||||
import createWorknote from './activity/Worknote.js';
|
||||
import createWorkNoteAttachment from './activity/WorkNoteAttachment.js';
|
||||
import createWorkNoteTag from './activity/WorkNoteTag.js';
|
||||
|
||||
const env = process.env.NODE_ENV || 'development';
|
||||
const dbConfig = config[env];
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { Model, DataTypes, Sequelize } from 'sequelize';
|
||||
import { CONSTITUTIONAL_CHANGE_TYPES, CONSTITUTIONAL_STAGES } from '../../common/config/constants.js';
|
||||
import { CONSTITUTIONAL_CHANGE_TYPES, CONSTITUTIONAL_STAGES } from '../../../../common/config/constants.js';
|
||||
|
||||
export interface ConstitutionalChangeAttributes {
|
||||
id: string;
|
||||
@ -71,8 +71,9 @@ export default (sequelize: Sequelize) => {
|
||||
},
|
||||
currentStage: {
|
||||
type: DataTypes.ENUM(...Object.values(CONSTITUTIONAL_STAGES)),
|
||||
defaultValue: CONSTITUTIONAL_STAGES.SUBMITTED
|
||||
defaultValue: CONSTITUTIONAL_STAGES.ASM_REVIEW
|
||||
},
|
||||
|
||||
status: {
|
||||
type: DataTypes.STRING,
|
||||
defaultValue: 'Pending'
|
||||
@ -1,5 +1,5 @@
|
||||
import { Model, DataTypes, Sequelize } from 'sequelize';
|
||||
import { RELOCATION_TYPES, RELOCATION_STAGES } from '../../common/config/constants.js';
|
||||
import { RELOCATION_TYPES, RELOCATION_STAGES } from '../../../../common/config/constants.js';
|
||||
|
||||
export interface RelocationRequestAttributes {
|
||||
id: string;
|
||||
@ -1,5 +1,5 @@
|
||||
import { Model, DataTypes, Sequelize } from 'sequelize';
|
||||
import { RESIGNATION_TYPES, RESIGNATION_STAGES } from '../../common/config/constants.js';
|
||||
import { RESIGNATION_TYPES, RESIGNATION_STAGES } from '../../../../common/config/constants.js';
|
||||
|
||||
export interface ResignationAttributes {
|
||||
id: string;
|
||||
@ -21,7 +21,8 @@ export interface ResignationAttributes {
|
||||
departmentalClearances: Record<string, {
|
||||
status: 'Pending' | 'Cleared' | 'Dues';
|
||||
amount?: number;
|
||||
type?: 'Payable' | 'Recovery';
|
||||
/** Receivable = amount owed by dealer; Recovery kept for legacy JSON only */
|
||||
type?: 'Payable' | 'Receivable' | 'Recovery';
|
||||
remarks?: string;
|
||||
updatedAt?: string;
|
||||
updatedBy?: string;
|
||||
@ -16,7 +16,7 @@ export interface TerminationRequestAttributes {
|
||||
departmentalClearances: Record<string, {
|
||||
status: 'Pending' | 'Cleared' | 'Dues';
|
||||
amount?: number;
|
||||
type?: 'Payable' | 'Recovery';
|
||||
type?: 'Payable' | 'Receivable' | 'Recovery';
|
||||
remarks?: string;
|
||||
updatedAt?: string;
|
||||
updatedBy?: string;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user