diff --git a/package-lock.json b/package-lock.json index 8a50489..bf56629 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 181477f..66d93a4 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/reset_db.ts b/reset_db.ts index 12da08d..66d3dd0 100644 --- a/reset_db.ts +++ b/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) { diff --git a/scratch/check-models.ts b/scratch/check-models.ts new file mode 100644 index 0000000..b4b9f18 --- /dev/null +++ b/scratch/check-models.ts @@ -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(); diff --git a/scripts/reset_db_stable.ts b/scripts/reset_db_stable.ts index 593971e..3636861 100644 --- a/scripts/reset_db_stable.ts +++ b/scripts/reset_db_stable.ts @@ -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) { diff --git a/scripts/seed-users.ts b/scripts/seed-users.ts index 76c413f..cafd0f7 100644 --- a/scripts/seed-users.ts +++ b/scripts/seed-users.ts @@ -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) { diff --git a/scripts/seed_normalized_data.ts b/scripts/seed_normalized_data.ts index d305abf..66a5e8f 100644 --- a/scripts/seed_normalized_data.ts +++ b/scripts/seed_normalized_data.ts @@ -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({ diff --git a/scripts/verify-standardized-offboarding.ts b/scripts/verify-standardized-offboarding.ts new file mode 100644 index 0000000..d0dd1dd --- /dev/null +++ b/scripts/verify-standardized-offboarding.ts @@ -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.'); diff --git a/src/common/config/constants.ts b/src/common/config/constants.ts index 427b080..dc50a81 100644 --- a/src/common/config/constants.ts +++ b/src/common/config/constants.ts @@ -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; diff --git a/src/common/middleware/upload.ts b/src/common/middleware/upload.ts index f6df669..d7a2420 100644 --- a/src/common/middleware/upload.ts +++ b/src/common/middleware/upload.ts @@ -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 diff --git a/src/common/utils/constitutionalNormalize.ts b/src/common/utils/constitutionalNormalize.ts index a26b50b..a7ac5ee 100644 --- a/src/common/utils/constitutionalNormalize.ts +++ b/src/common/utils/constitutionalNormalize.ts @@ -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; +} diff --git a/src/common/utils/email-template-sanitize.ts b/src/common/utils/email-template-sanitize.ts new file mode 100644 index 0000000..26c20dc --- /dev/null +++ b/src/common/utils/email-template-sanitize.ts @@ -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); +} diff --git a/src/common/utils/email.service.ts b/src/common/utils/email.service.ts index 07e0956..9812376 100644 --- a/src/common/utils/email.service.ts +++ b/src/common/utils/email.service.ts @@ -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) => { - 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) => { 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' }); }; diff --git a/src/common/utils/externalMocks.service.ts b/src/common/utils/externalMocks.service.ts index 1029994..f46c25b 100644 --- a/src/common/utils/externalMocks.service.ts +++ b/src/common/utils/externalMocks.service.ts @@ -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}`); diff --git a/src/common/utils/handlebars-email.ts b/src/common/utils/handlebars-email.ts new file mode 100644 index 0000000..cf34176 --- /dev/null +++ b/src/common/utils/handlebars-email.ts @@ -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( + /]*src="__EMAIL_HEADER_LOGO_SRC__"[^>]*>/i, + 'Royal Enfield' + ); + } + 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 = { + 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): Record { + const ctaUrl = + replacements.ctaUrl || + replacements.link || + replacements.portalLink || + replacements.actionUrl || + ''; + + const ctaLabel = replacements.ctaLabel || 'View details'; + + return { + ...replacements, + ctaUrl, + ctaLabel + }; +} diff --git a/src/common/utils/offboardingWorkflow.utils.ts b/src/common/utils/offboardingWorkflow.utils.ts new file mode 100644 index 0000000..c467541 --- /dev/null +++ b/src/common/utils/offboardingWorkflow.utils.ts @@ -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 = { + [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 = { + [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 = { + [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 = { + [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(' '); +}; diff --git a/src/common/utils/requestResolver.ts b/src/common/utils/requestResolver.ts index 6686710..6a8ddc4 100644 --- a/src/common/utils/requestResolver.ts +++ b/src/common/utils/requestResolver.ts @@ -2,8 +2,26 @@ import { Op } from 'sequelize'; import { REQUEST_TYPES } from '../config/constants.js'; type DbLike = Record; + +/** 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 = { 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 }; diff --git a/src/common/utils/workflow-email-notifications.ts b/src/common/utils/workflow-email-notifications.ts new file mode 100644 index 0000000..5d1d481 --- /dev/null +++ b/src/common/utils/workflow-email-notifications.ts @@ -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 { + 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 { + 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 { + 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)); +} diff --git a/src/constants/allowed-email-template-codes.ts b/src/constants/allowed-email-template-codes.ts new file mode 100644 index 0000000..cf28874 --- /dev/null +++ b/src/constants/allowed-email-template-codes.ts @@ -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(ALLOWED_EMAIL_TEMPLATE_CODES); + +export function isAllowedEmailTemplateCode(code: string): boolean { + return ALLOWED_SET.has(code.trim().toUpperCase()); +} diff --git a/src/controllers/admin/EmailTemplateController.ts b/src/controllers/admin/EmailTemplateController.ts index 5a2522a..e83ef4c 100644 --- a/src/controllers/admin/EmailTemplateController.ts +++ b/src/controllers/admin/EmailTemplateController.ts @@ -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) { + const out: Record = {}; + 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); + 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); + 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({ diff --git a/src/database/models/AuditLog.ts b/src/database/models/activity/AuditLog.ts similarity index 96% rename from src/database/models/AuditLog.ts rename to src/database/models/activity/AuditLog.ts index 29c8a58..89bc95e 100644 --- a/src/database/models/AuditLog.ts +++ b/src/database/models/activity/AuditLog.ts @@ -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; diff --git a/src/database/models/WorkNoteAttachment.ts b/src/database/models/activity/WorkNoteAttachment.ts similarity index 100% rename from src/database/models/WorkNoteAttachment.ts rename to src/database/models/activity/WorkNoteAttachment.ts diff --git a/src/database/models/WorkNoteTag.ts b/src/database/models/activity/WorkNoteTag.ts similarity index 100% rename from src/database/models/WorkNoteTag.ts rename to src/database/models/activity/WorkNoteTag.ts diff --git a/src/database/models/Worknote.ts b/src/database/models/activity/Worknote.ts similarity index 100% rename from src/database/models/Worknote.ts rename to src/database/models/activity/Worknote.ts diff --git a/src/database/models/Application.ts b/src/database/models/application/Application.ts similarity index 99% rename from src/database/models/Application.ts rename to src/database/models/application/Application.ts index 3b0c839..aa607b5 100644 --- a/src/database/models/Application.ts +++ b/src/database/models/application/Application.ts @@ -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; diff --git a/src/database/models/ApplicationProgress.ts b/src/database/models/application/ApplicationProgress.ts similarity index 100% rename from src/database/models/ApplicationProgress.ts rename to src/database/models/application/ApplicationProgress.ts diff --git a/src/database/models/ApplicationStatusHistory.ts b/src/database/models/application/ApplicationStatusHistory.ts similarity index 100% rename from src/database/models/ApplicationStatusHistory.ts rename to src/database/models/application/ApplicationStatusHistory.ts diff --git a/src/database/models/DocumentVersion.ts b/src/database/models/application/DocumentVersion.ts similarity index 100% rename from src/database/models/DocumentVersion.ts rename to src/database/models/application/DocumentVersion.ts diff --git a/src/database/models/OnboardingDocument.ts b/src/database/models/application/OnboardingDocument.ts similarity index 97% rename from src/database/models/OnboardingDocument.ts rename to src/database/models/application/OnboardingDocument.ts index cf34ecc..a8f51a3 100644 --- a/src/database/models/OnboardingDocument.ts +++ b/src/database/models/application/OnboardingDocument.ts @@ -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; diff --git a/src/database/models/Opportunity.ts b/src/database/models/application/Opportunity.ts similarity index 100% rename from src/database/models/Opportunity.ts rename to src/database/models/application/Opportunity.ts diff --git a/src/database/models/LoaAcknowledgement.ts b/src/database/models/approval/LoaAcknowledgement.ts similarity index 100% rename from src/database/models/LoaAcknowledgement.ts rename to src/database/models/approval/LoaAcknowledgement.ts diff --git a/src/database/models/LoaApproval.ts b/src/database/models/approval/LoaApproval.ts similarity index 100% rename from src/database/models/LoaApproval.ts rename to src/database/models/approval/LoaApproval.ts diff --git a/src/database/models/LoaDocumentGenerated.ts b/src/database/models/approval/LoaDocumentGenerated.ts similarity index 100% rename from src/database/models/LoaDocumentGenerated.ts rename to src/database/models/approval/LoaDocumentGenerated.ts diff --git a/src/database/models/LoaRequest.ts b/src/database/models/approval/LoaRequest.ts similarity index 100% rename from src/database/models/LoaRequest.ts rename to src/database/models/approval/LoaRequest.ts diff --git a/src/database/models/LoiAcknowledgement.ts b/src/database/models/approval/LoiAcknowledgement.ts similarity index 100% rename from src/database/models/LoiAcknowledgement.ts rename to src/database/models/approval/LoiAcknowledgement.ts diff --git a/src/database/models/LoiApproval.ts b/src/database/models/approval/LoiApproval.ts similarity index 100% rename from src/database/models/LoiApproval.ts rename to src/database/models/approval/LoiApproval.ts diff --git a/src/database/models/LoiDocumentGenerated.ts b/src/database/models/approval/LoiDocumentGenerated.ts similarity index 100% rename from src/database/models/LoiDocumentGenerated.ts rename to src/database/models/approval/LoiDocumentGenerated.ts diff --git a/src/database/models/LoiRequest.ts b/src/database/models/approval/LoiRequest.ts similarity index 100% rename from src/database/models/LoiRequest.ts rename to src/database/models/approval/LoiRequest.ts diff --git a/src/database/models/SecurityDeposit.ts b/src/database/models/approval/SecurityDeposit.ts similarity index 100% rename from src/database/models/SecurityDeposit.ts rename to src/database/models/approval/SecurityDeposit.ts diff --git a/src/database/models/DocumentStageConfig.ts b/src/database/models/compliance/DocumentStageConfig.ts similarity index 100% rename from src/database/models/DocumentStageConfig.ts rename to src/database/models/compliance/DocumentStageConfig.ts diff --git a/src/database/models/EorChecklist.ts b/src/database/models/compliance/EorChecklist.ts similarity index 100% rename from src/database/models/EorChecklist.ts rename to src/database/models/compliance/EorChecklist.ts diff --git a/src/database/models/EorChecklistItem.ts b/src/database/models/compliance/EorChecklistItem.ts similarity index 100% rename from src/database/models/EorChecklistItem.ts rename to src/database/models/compliance/EorChecklistItem.ts diff --git a/src/database/models/RequestParticipant.ts b/src/database/models/compliance/RequestParticipant.ts similarity index 100% rename from src/database/models/RequestParticipant.ts rename to src/database/models/compliance/RequestParticipant.ts diff --git a/src/database/models/SLABreach.ts b/src/database/models/compliance/SLABreach.ts similarity index 100% rename from src/database/models/SLABreach.ts rename to src/database/models/compliance/SLABreach.ts diff --git a/src/database/models/SLAConfiguration.ts b/src/database/models/compliance/SLAConfiguration.ts similarity index 100% rename from src/database/models/SLAConfiguration.ts rename to src/database/models/compliance/SLAConfiguration.ts diff --git a/src/database/models/SLAEscalationConfig.ts b/src/database/models/compliance/SLAEscalationConfig.ts similarity index 100% rename from src/database/models/SLAEscalationConfig.ts rename to src/database/models/compliance/SLAEscalationConfig.ts diff --git a/src/database/models/SLAReminder.ts b/src/database/models/compliance/SLAReminder.ts similarity index 100% rename from src/database/models/SLAReminder.ts rename to src/database/models/compliance/SLAReminder.ts diff --git a/src/database/models/SLATracking.ts b/src/database/models/compliance/SLATracking.ts similarity index 100% rename from src/database/models/SLATracking.ts rename to src/database/models/compliance/SLATracking.ts diff --git a/src/database/models/StageApprovalAction.ts b/src/database/models/compliance/StageApprovalAction.ts similarity index 100% rename from src/database/models/StageApprovalAction.ts rename to src/database/models/compliance/StageApprovalAction.ts diff --git a/src/database/models/StageApprovalPolicy.ts b/src/database/models/compliance/StageApprovalPolicy.ts similarity index 100% rename from src/database/models/StageApprovalPolicy.ts rename to src/database/models/compliance/StageApprovalPolicy.ts diff --git a/src/database/models/WorkflowStageConfig.ts b/src/database/models/compliance/WorkflowStageConfig.ts similarity index 100% rename from src/database/models/WorkflowStageConfig.ts rename to src/database/models/compliance/WorkflowStageConfig.ts diff --git a/src/database/models/District.ts b/src/database/models/core/District.ts similarity index 100% rename from src/database/models/District.ts rename to src/database/models/core/District.ts diff --git a/src/database/models/EmailTemplate.ts b/src/database/models/core/EmailTemplate.ts similarity index 100% rename from src/database/models/EmailTemplate.ts rename to src/database/models/core/EmailTemplate.ts diff --git a/src/database/models/Location.ts b/src/database/models/core/Location.ts similarity index 100% rename from src/database/models/Location.ts rename to src/database/models/core/Location.ts diff --git a/src/database/models/LocationHierarchy.ts b/src/database/models/core/LocationHierarchy.ts similarity index 100% rename from src/database/models/LocationHierarchy.ts rename to src/database/models/core/LocationHierarchy.ts diff --git a/src/database/models/Notification.ts b/src/database/models/core/Notification.ts similarity index 100% rename from src/database/models/Notification.ts rename to src/database/models/core/Notification.ts diff --git a/src/database/models/Permission.ts b/src/database/models/core/Permission.ts similarity index 100% rename from src/database/models/Permission.ts rename to src/database/models/core/Permission.ts diff --git a/src/database/models/PushSubscription.ts b/src/database/models/core/PushSubscription.ts similarity index 100% rename from src/database/models/PushSubscription.ts rename to src/database/models/core/PushSubscription.ts diff --git a/src/database/models/Region.ts b/src/database/models/core/Region.ts similarity index 100% rename from src/database/models/Region.ts rename to src/database/models/core/Region.ts diff --git a/src/database/models/Role.ts b/src/database/models/core/Role.ts similarity index 100% rename from src/database/models/Role.ts rename to src/database/models/core/Role.ts diff --git a/src/database/models/RolePermission.ts b/src/database/models/core/RolePermission.ts similarity index 100% rename from src/database/models/RolePermission.ts rename to src/database/models/core/RolePermission.ts diff --git a/src/database/models/State.ts b/src/database/models/core/State.ts similarity index 100% rename from src/database/models/State.ts rename to src/database/models/core/State.ts diff --git a/src/database/models/SystemConfiguration.ts b/src/database/models/core/SystemConfiguration.ts similarity index 100% rename from src/database/models/SystemConfiguration.ts rename to src/database/models/core/SystemConfiguration.ts diff --git a/src/database/models/User.ts b/src/database/models/core/User.ts similarity index 98% rename from src/database/models/User.ts rename to src/database/models/core/User.ts index 490ac0d..5414047 100644 --- a/src/database/models/User.ts +++ b/src/database/models/core/User.ts @@ -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; diff --git a/src/database/models/UserRole.ts b/src/database/models/core/UserRole.ts similarity index 100% rename from src/database/models/UserRole.ts rename to src/database/models/core/UserRole.ts diff --git a/src/database/models/Zone.ts b/src/database/models/core/Zone.ts similarity index 100% rename from src/database/models/Zone.ts rename to src/database/models/core/Zone.ts diff --git a/src/database/models/Dealer.ts b/src/database/models/dealer/Dealer.ts similarity index 100% rename from src/database/models/Dealer.ts rename to src/database/models/dealer/Dealer.ts diff --git a/src/database/models/DealerBankDetail.ts b/src/database/models/dealer/DealerBankDetail.ts similarity index 100% rename from src/database/models/DealerBankDetail.ts rename to src/database/models/dealer/DealerBankDetail.ts diff --git a/src/database/models/DealerCode.ts b/src/database/models/dealer/DealerCode.ts similarity index 100% rename from src/database/models/DealerCode.ts rename to src/database/models/dealer/DealerCode.ts diff --git a/src/database/models/Outlet.ts b/src/database/models/dealer/Outlet.ts similarity index 97% rename from src/database/models/Outlet.ts rename to src/database/models/dealer/Outlet.ts index e808709..876fa4a 100644 --- a/src/database/models/Outlet.ts +++ b/src/database/models/dealer/Outlet.ts @@ -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; diff --git a/src/database/models/FffClearance.ts b/src/database/models/financial/FffClearance.ts similarity index 100% rename from src/database/models/FffClearance.ts rename to src/database/models/financial/FffClearance.ts diff --git a/src/database/models/FinancePayment.ts b/src/database/models/financial/FinancePayment.ts similarity index 96% rename from src/database/models/FinancePayment.ts rename to src/database/models/financial/FinancePayment.ts index e55b46a..aa135c6 100644 --- a/src/database/models/FinancePayment.ts +++ b/src/database/models/financial/FinancePayment.ts @@ -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; diff --git a/src/database/models/FnF.ts b/src/database/models/financial/FnF.ts similarity index 98% rename from src/database/models/FnF.ts rename to src/database/models/financial/FnF.ts index cb2adbe..fbd20dd 100644 --- a/src/database/models/FnF.ts +++ b/src/database/models/financial/FnF.ts @@ -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; diff --git a/src/database/models/FnFAudit.ts b/src/database/models/financial/FnFAudit.ts similarity index 100% rename from src/database/models/FnFAudit.ts rename to src/database/models/financial/FnFAudit.ts diff --git a/src/database/models/FnFLineItem.ts b/src/database/models/financial/FnFLineItem.ts similarity index 100% rename from src/database/models/FnFLineItem.ts rename to src/database/models/financial/FnFLineItem.ts diff --git a/src/database/models/index.ts b/src/database/models/index.ts index 19cebf0..4a40d17 100644 --- a/src/database/models/index.ts +++ b/src/database/models/index.ts @@ -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]; diff --git a/src/database/models/ExitFeedback.ts b/src/database/models/offboarding/common/ExitFeedback.ts similarity index 100% rename from src/database/models/ExitFeedback.ts rename to src/database/models/offboarding/common/ExitFeedback.ts diff --git a/src/database/models/ConstitutionalAudit.ts b/src/database/models/offboarding/constitutional/ConstitutionalAudit.ts similarity index 100% rename from src/database/models/ConstitutionalAudit.ts rename to src/database/models/offboarding/constitutional/ConstitutionalAudit.ts diff --git a/src/database/models/ConstitutionalChange.ts b/src/database/models/offboarding/constitutional/ConstitutionalChange.ts similarity index 97% rename from src/database/models/ConstitutionalChange.ts rename to src/database/models/offboarding/constitutional/ConstitutionalChange.ts index 8e05041..3620b91 100644 --- a/src/database/models/ConstitutionalChange.ts +++ b/src/database/models/offboarding/constitutional/ConstitutionalChange.ts @@ -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' diff --git a/src/database/models/ConstitutionalDocument.ts b/src/database/models/offboarding/constitutional/ConstitutionalDocument.ts similarity index 100% rename from src/database/models/ConstitutionalDocument.ts rename to src/database/models/offboarding/constitutional/ConstitutionalDocument.ts diff --git a/src/database/models/RelocationAudit.ts b/src/database/models/offboarding/relocation/RelocationAudit.ts similarity index 100% rename from src/database/models/RelocationAudit.ts rename to src/database/models/offboarding/relocation/RelocationAudit.ts diff --git a/src/database/models/RelocationDocument.ts b/src/database/models/offboarding/relocation/RelocationDocument.ts similarity index 100% rename from src/database/models/RelocationDocument.ts rename to src/database/models/offboarding/relocation/RelocationDocument.ts diff --git a/src/database/models/RelocationRequest.ts b/src/database/models/offboarding/relocation/RelocationRequest.ts similarity index 98% rename from src/database/models/RelocationRequest.ts rename to src/database/models/offboarding/relocation/RelocationRequest.ts index 181b057..c3c7e91 100644 --- a/src/database/models/RelocationRequest.ts +++ b/src/database/models/offboarding/relocation/RelocationRequest.ts @@ -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; diff --git a/src/database/models/Resignation.ts b/src/database/models/offboarding/resignation/Resignation.ts similarity index 95% rename from src/database/models/Resignation.ts rename to src/database/models/offboarding/resignation/Resignation.ts index 4b8c469..a0ad62e 100644 --- a/src/database/models/Resignation.ts +++ b/src/database/models/offboarding/resignation/Resignation.ts @@ -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 - - - - - -
-
-

ROYAL ENFIELD

-
-
-

Hi {{applicantName}},

-

We are pleased to inform you that your dealership application for {{location}} has been shortlisted for further evaluation.

-

Our team will contact you shortly to schedule the next level of interviews. In the meantime, you can track your application status on our dealer portal.

- -

Regards,
Royal Enfield Dealer Development Team

-
- -
- - +{{> email_header}} +

Hi {{applicantName}},

+

We are pleased to inform you that your dealership application for {{location}} has been shortlisted for further evaluation.

+

Our team will contact you shortly to schedule the next level of interviews. In the meantime, you can track your application status on our dealer portal.

+{{> primary_cta}} +

Regards,
Royal Enfield Dealer Development Team

+{{> email_footer}} diff --git a/src/emailtemplates/constitutional_change_submitted.html b/src/emailtemplates/constitutional_change_submitted.html index 12eb73e..223cd57 100644 --- a/src/emailtemplates/constitutional_change_submitted.html +++ b/src/emailtemplates/constitutional_change_submitted.html @@ -1,30 +1,7 @@ - - - - - - -
-

ROYAL ENFIELD

-
-

Hi Team,

-

A new Constitutional Change request has been submitted by {{dealerName}}.

-

Request Type: {{changeType}}
Request ID: {{requestId}}

-

Please log in to the Dealer development portal to review the request and documents.

- -
- -
- - +{{> email_header}} +

Hi Team,

+

A new Constitutional Change request has been submitted by {{dealerName}}.

+

Request Type: {{changeType}}
Request ID: {{requestId}}

+

Please log in to the Dealer development portal to review the request and documents.

+{{> primary_cta}} +{{> email_footer}} diff --git a/src/emailtemplates/constitutional_change_update.html b/src/emailtemplates/constitutional_change_update.html index e5052b1..42eedcc 100644 --- a/src/emailtemplates/constitutional_change_update.html +++ b/src/emailtemplates/constitutional_change_update.html @@ -1,30 +1,7 @@ - - - - - - -
-

ROYAL ENFIELD

-
-

Hi {{dealerName}},

-

This is to inform you that your Constitutional Change request has been updated.

-

Current Stage: {{status}}

-

{{remarks}}

- -
- -
- - +{{> email_header}} +

Hi {{dealerName}},

+

This is to inform you that your Constitutional Change request has been updated.

+

Current Stage: {{status}}

+

{{remarks}}

+{{> primary_cta}} +{{> email_footer}} diff --git a/src/emailtemplates/dealer_code_ready.html b/src/emailtemplates/dealer_code_ready.html index 9601be9..4c50aa0 100644 --- a/src/emailtemplates/dealer_code_ready.html +++ b/src/emailtemplates/dealer_code_ready.html @@ -1,31 +1,10 @@ - - - - - - -
-

ROYAL ENFIELD

-
-

SAP Dealer Codes Generated

-

Hi {{applicantName}},

-

We are pleased to inform you that your SAP Dealer Codes for application **{{applicationId}}** are now ready and active in our system.

-
- Sales Code: {{salesCode}}
- Service Code: {{serviceCode}} -
-

You can now proceed with system onboarding and initial orders.

-
- -
- - +{{> email_header}} +

SAP Dealer Codes Generated

+

Hi {{applicantName}},

+

We are pleased to inform you that your SAP Dealer Codes for application {{applicationId}} are now ready and active in our system.

+
+Sales Code: {{salesCode}}
+Service Code: {{serviceCode}} +
+

You can now proceed with system onboarding and initial orders.

+{{> email_footer}} diff --git a/src/emailtemplates/generic_notification.html b/src/emailtemplates/generic_notification.html index f877ce4..b63925e 100644 --- a/src/emailtemplates/generic_notification.html +++ b/src/emailtemplates/generic_notification.html @@ -1,25 +1,5 @@ - - - - - - -
-

ROYAL ENFIELD

-
-

{{title}}

-

{{message}}

-

Please log in to the portal for more details.

-
- -
- - +{{> email_header}} +

{{title}}

+

{{message}}

+

Please log in to the portal for more details.

+{{> email_footer}} diff --git a/src/emailtemplates/interview_scheduled.html b/src/emailtemplates/interview_scheduled.html index e89ef44..1610b90 100644 --- a/src/emailtemplates/interview_scheduled.html +++ b/src/emailtemplates/interview_scheduled.html @@ -1,32 +1,11 @@ - - - - - - -
-

ROYAL ENFIELD

-
-

Hi {{name}},

-

Your interview for the Royal Enfield dealership application ({{applicationId}}) has been scheduled.

-
-

Interview Level: {{level}}
- Date & Time: {{dateTime}}
- Mode/Location: {{location}}
- Type: {{type}}

-
-

Please ensure you are available at the scheduled time. If it's a virtual interview, the link will be shared separately or is included in the location field above.

-
- -
- - +{{> email_header}} +

Hi {{name}},

+

Your interview for the Royal Enfield dealership application ({{applicationId}}) has been scheduled.

+
+

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

+
+

Please ensure you are available at the scheduled time. If it's a virtual interview, the link will be shared separately or is included in the location field above.

+{{> email_footer}} diff --git a/src/emailtemplates/loa_issued.html b/src/emailtemplates/loa_issued.html index 43640c4..aae62ab 100644 --- a/src/emailtemplates/loa_issued.html +++ b/src/emailtemplates/loa_issued.html @@ -1,30 +1,7 @@ - - - - - - -
-

ROYAL ENFIELD

-
-

Welcome to the Family!

-

Dear {{applicantName}},

-

We are honored to issue your Letter of Appointment (LOA) for the Royal Enfield dealership ({{applicationId}}). Your dealership is now officially authorized under the code: {{dealerCode}}.

-

We look forward to a successful partnership.

-
- View LOA -
-
- -
- - +{{> email_header}} +

Welcome to the Family!

+

Dear {{applicantName}},

+

We are honored to issue your Letter of Appointment (LOA) for the Royal Enfield dealership ({{applicationId}}). Your dealership is now officially authorized under the code: {{dealerCode}}.

+

We look forward to a successful partnership.

+{{> primary_cta}} +{{> email_footer}} diff --git a/src/emailtemplates/loi_issued.html b/src/emailtemplates/loi_issued.html index cbec568..19a4b4e 100644 --- a/src/emailtemplates/loi_issued.html +++ b/src/emailtemplates/loi_issued.html @@ -1,29 +1,6 @@ - - - - - - -
-

ROYAL ENFIELD

-
-

Congratulations {{applicantName}}!

-

We are delighted to inform you that your Letter of Intent (LOI) for the Royal Enfield dealership has been issued for the application {{applicationId}}.

-

Please log in to the portal to view and acknowledge the document to move to the next stage.

-
- View LOI -
-
- -
- - +{{> email_header}} +

Congratulations {{applicantName}}!

+

We are delighted to inform you that your Letter of Intent (LOI) for the Royal Enfield dealership has been issued for the application {{applicationId}}.

+

Please log in to the portal to view and acknowledge the document to move to the next stage.

+{{> primary_cta}} +{{> email_footer}} diff --git a/src/emailtemplates/non_opportunity.html b/src/emailtemplates/non_opportunity.html index f9b3ddc..a274d15 100644 --- a/src/emailtemplates/non_opportunity.html +++ b/src/emailtemplates/non_opportunity.html @@ -1,27 +1,7 @@ - - - - - - -
-

ROYAL ENFIELD

-
-

Dear {{applicantName}},

-

Thank you for your interest in a Royal Enfield dealership for {{location}}.

-

After careful review of your application and the current organizational requirements, we regret to inform you that we are unable to proceed with your request at this time.

-

We will keep your profile in our database for future opportunities. We wish you the very best in your future endeavors.

-

Regards,
Royal Enfield Team

-
- -
- - \ No newline at end of file +{{> email_header}} +

Dear {{applicantName}},

+

Thank you for your interest in a Royal Enfield dealership for {{location}}.

+

After careful review of your application and the current organizational requirements, we regret to inform you that we are unable to proceed with your request at this time.

+

We will keep your profile in our database for future opportunities. We wish you the very best in your future endeavors.

+

Regards,
Royal Enfield Team

+{{> email_footer}} diff --git a/src/emailtemplates/onboarding_status_update.html b/src/emailtemplates/onboarding_status_update.html new file mode 100644 index 0000000..7b90a25 --- /dev/null +++ b/src/emailtemplates/onboarding_status_update.html @@ -0,0 +1,9 @@ +{{> email_header}} +

Hi {{applicantName}},

+

Your dealership onboarding application status has been updated.

+

Current status: {{status}}

+

Application ID: {{applicationId}}

+

Details: {{reason}}

+

You can sign in to the dealer development portal for more information.

+{{> primary_cta}} +{{> email_footer}} diff --git a/src/emailtemplates/opportunity.html b/src/emailtemplates/opportunity.html index e852f80..4f465c3 100644 --- a/src/emailtemplates/opportunity.html +++ b/src/emailtemplates/opportunity.html @@ -1,32 +1,8 @@ - - - - - - -
-

ROYAL ENFIELD

-
-

Hi {{applicantName}},

-

Thank you for expressing interest in a Royal Enfield dealership opportunity for {{location}}.

-

To proceed with your application, we require you to complete a mandatory business assessment questionnaire. This will help us evaluate your profile better.

- -

Please complete this at your earliest convenience. If you have any questions, feel free to contact our development team.

-

Regards,
Royal Enfield Team

-
- -
- - +{{> email_header}} +

Hi {{applicantName}},

+

Thank you for expressing interest in a Royal Enfield dealership opportunity for {{location}}.

+

To proceed with your application, we require you to complete a mandatory business assessment questionnaire. This will help us evaluate your profile better.

+{{> primary_cta}} +

Please complete this at your earliest convenience. If you have any questions, feel free to contact our development team.

+

Regards,
Royal Enfield Team

+{{> email_footer}} diff --git a/src/emailtemplates/partials/email_footer.html b/src/emailtemplates/partials/email_footer.html new file mode 100644 index 0000000..f7f2d8f --- /dev/null +++ b/src/emailtemplates/partials/email_footer.html @@ -0,0 +1,8 @@ + + + + + diff --git a/src/emailtemplates/partials/email_header.html b/src/emailtemplates/partials/email_header.html new file mode 100644 index 0000000..d7fcfcf --- /dev/null +++ b/src/emailtemplates/partials/email_header.html @@ -0,0 +1,22 @@ + + + + + + + +
+
+Royal Enfield +
+
diff --git a/src/emailtemplates/partials/primary_cta.html b/src/emailtemplates/partials/primary_cta.html new file mode 100644 index 0000000..dd6aafa --- /dev/null +++ b/src/emailtemplates/partials/primary_cta.html @@ -0,0 +1,5 @@ +{{#if ctaUrl}} + +{{/if}} diff --git a/src/emailtemplates/questionnaire_reminder.html b/src/emailtemplates/questionnaire_reminder.html index 4b98046..29cfbae 100644 --- a/src/emailtemplates/questionnaire_reminder.html +++ b/src/emailtemplates/questionnaire_reminder.html @@ -1,30 +1,7 @@ - - - - - - -
-

ROYAL ENFIELD

-
-

Hi {{applicantName}},

-

This is a reminder that we are awaiting your response to the dealership assessment questionnaire for {{location}}.

-

Completing this questionnaire is a mandatory step to move forward with your application.

- -

If you have already submitted it, please ignore this email.

-
- -
- - +{{> email_header}} +

Hi {{applicantName}},

+

This is a reminder that we are awaiting your response to the dealership assessment questionnaire for {{location}}.

+

Completing this questionnaire is a mandatory step to move forward with your application.

+{{> primary_cta}} +

If you have already submitted it, please ignore this email.

+{{> email_footer}} diff --git a/src/emailtemplates/questionnaire_submitted.html b/src/emailtemplates/questionnaire_submitted.html index a08ce11..b7764ea 100644 --- a/src/emailtemplates/questionnaire_submitted.html +++ b/src/emailtemplates/questionnaire_submitted.html @@ -1,26 +1,6 @@ - - - - - - -
-

ROYAL ENFIELD

-
-

Hi {{applicantName}},

-

Your business assessment questionnaire for {{location}} (Application ID: {{applicationId}}) has been successfully submitted.

-

Our team will review your responses and get back to you with the next steps.

-

Regards,
Royal Enfield Team

-
- -
- - +{{> email_header}} +

Hi {{applicantName}},

+

Your business assessment questionnaire for {{location}} (Application ID: {{applicationId}}) has been successfully submitted.

+

Our team will review your responses and get back to you with the next steps.

+

Regards,
Royal Enfield Team

+{{> email_footer}} diff --git a/src/emailtemplates/relocation_received.html b/src/emailtemplates/relocation_received.html new file mode 100644 index 0000000..9a2342b --- /dev/null +++ b/src/emailtemplates/relocation_received.html @@ -0,0 +1,6 @@ +{{> email_header}} +

Hi {{dealerName}},

+

Your outlet relocation request {{requestId}} has been received.

+

You will receive email updates as the request moves through approvals.

+{{> primary_cta}} +{{> email_footer}} diff --git a/src/emailtemplates/relocation_submitted.html b/src/emailtemplates/relocation_submitted.html new file mode 100644 index 0000000..62e8897 --- /dev/null +++ b/src/emailtemplates/relocation_submitted.html @@ -0,0 +1,7 @@ +{{> email_header}} +

New relocation request

+

A dealer has submitted an outlet relocation request.

+

Request ID: {{requestId}}
Outlet: {{outletCode}}

+

Please review in the Dealer Development portal.

+{{> primary_cta}} +{{> email_footer}} diff --git a/src/emailtemplates/relocation_update.html b/src/emailtemplates/relocation_update.html new file mode 100644 index 0000000..100c76c --- /dev/null +++ b/src/emailtemplates/relocation_update.html @@ -0,0 +1,7 @@ +{{> email_header}} +

Hi {{dealerName}},

+

Your relocation request {{requestId}} has been updated.

+

Current stage: {{status}}

+

{{remarks}}

+{{> primary_cta}} +{{> email_footer}} diff --git a/src/emailtemplates/resignation_approved.html b/src/emailtemplates/resignation_approved.html index 9d8dd57..cbfcff9 100644 --- a/src/emailtemplates/resignation_approved.html +++ b/src/emailtemplates/resignation_approved.html @@ -1,27 +1,7 @@ - - - - - - -
-

ROYAL ENFIELD

-
-

Resignation Request Approved

-

Dear {{dealerName}},

-

Your resignation request (Request ID: {{resignationId}}) has been approved by the Dealer Development team.

-

Proposed Last Working Day: {{lwd}}

-

The clearance process has been initiated. Please ensure all department dues are cleared as per the timeline.

-
- -
- - +{{> email_header}} +

Resignation Request Approved

+

Dear {{dealerName}},

+

Your resignation request (Request ID: {{resignationId}}) has been approved by the Dealer Development team.

+

Proposed Last Working Day: {{lwd}}

+

The clearance process has been initiated. Please ensure all department dues are cleared as per the timeline.

+{{> email_footer}} diff --git a/src/emailtemplates/resignation_received.html b/src/emailtemplates/resignation_received.html new file mode 100644 index 0000000..e270f2b --- /dev/null +++ b/src/emailtemplates/resignation_received.html @@ -0,0 +1,7 @@ +{{> email_header}} +

Hi {{dealerName}},

+

We have received your resignation request {{resignationId}}.

+

Last working day (as submitted): {{lwd}}

+

Our team will review and progress clearances as per process. You can track status anytime on the portal.

+{{> primary_cta}} +{{> email_footer}} diff --git a/src/emailtemplates/resignation_submitted.html b/src/emailtemplates/resignation_submitted.html index db006c6..b29f666 100644 --- a/src/emailtemplates/resignation_submitted.html +++ b/src/emailtemplates/resignation_submitted.html @@ -1,30 +1,7 @@ - - - - - - -
-

ROYAL ENFIELD

-
-

Hi Team,

-

A new resignation request has been submitted by {{dealerName}}.

-

Request ID: {{resignationId}}
Proposed Last Working Day: {{lwd}}

-

Please log in to the Dealer development portal to review and initiate the clearance process.

- -
- -
- - +{{> email_header}} +

Hi Team,

+

A new resignation request has been submitted by {{dealerName}}.

+

Request ID: {{resignationId}}
Proposed Last Working Day: {{lwd}}

+

Please log in to the Dealer development portal to review and initiate the clearance process.

+{{> primary_cta}} +{{> email_footer}} diff --git a/src/emailtemplates/resignation_update.html b/src/emailtemplates/resignation_update.html index f1faf55..a930d29 100644 --- a/src/emailtemplates/resignation_update.html +++ b/src/emailtemplates/resignation_update.html @@ -1,31 +1,8 @@ - - - - - - -
-

ROYAL ENFIELD

-
-

Hi {{dealerName}},

-

This is to inform you that the status of your resignation request has been updated.

-

Current Stage: {{status}}

-

{{remarks}}

-

You can track the progress and clearance status on the dealer development portal.

- -
- -
- - +{{> email_header}} +

Hi {{dealerName}},

+

This is to inform you that the status of your resignation request has been updated.

+

Current Stage: {{status}}

+

{{remarks}}

+

You can track the progress and clearance status on the dealer development portal.

+{{> primary_cta}} +{{> email_footer}} diff --git a/src/emailtemplates/sla_breach_warning.html b/src/emailtemplates/sla_breach_warning.html new file mode 100644 index 0000000..1deb3b6 --- /dev/null +++ b/src/emailtemplates/sla_breach_warning.html @@ -0,0 +1,9 @@ +{{> email_header}} +

SLA breach alert

+

The following application has breached SLA for a workflow stage.

+

Application ID: {{applicationId}}
+SLA stage: {{stageName}}
+Current stage: {{currentStage}}

+

Please take immediate action in the portal.

+{{> primary_cta}} +{{> email_footer}} diff --git a/src/emailtemplates/termination_scn.html b/src/emailtemplates/termination_scn.html index fda0b4c..a75836f 100644 --- a/src/emailtemplates/termination_scn.html +++ b/src/emailtemplates/termination_scn.html @@ -1,31 +1,7 @@ - - - - - - -
-

ROYAL ENFIELD

-
-

URGENT: Show Cause Notice Issued

-

Dear {{dealerName}},

-

This is to inform you that a Show Cause Notice has been issued regarding your dealership contract (Request ID: {{terminationId}}).

-

You are required to submit your response on the dealer development portal by {{deadline}}. Failure to respond may lead to further action as per the dealership agreement.

- -
- -
- - +{{> email_header}} +

URGENT: Show Cause Notice Issued

+

Dear {{dealerName}},

+

This is to inform you that a Show Cause Notice has been issued regarding your dealership contract (Request ID: {{terminationId}}).

+

You are required to submit your response on the dealer development portal by {{deadline}}. Failure to respond may lead to further action as per the dealership agreement.

+{{> primary_cta}} +{{> email_footer}} diff --git a/src/emailtemplates/termination_update.html b/src/emailtemplates/termination_update.html index 563640a..e4c2a0e 100644 --- a/src/emailtemplates/termination_update.html +++ b/src/emailtemplates/termination_update.html @@ -1,31 +1,8 @@ - - - - - - -
-

ROYAL ENFIELD

-
-

Hi {{dealerName}},

-

This is to inform you that the status of your dealership termination request has been updated.

-

Current Stage: {{status}}

-

{{remarks}}

-

Please log in to the portal to check if any actions are required from your end.

- -
- -
- - +{{> email_header}} +

Hi {{dealerName}},

+

This is to inform you that the status of your dealership termination request has been updated.

+

Current Stage: {{status}}

+

{{remarks}}

+

Please log in to the portal to check if any actions are required from your end.

+{{> primary_cta}} +{{> email_footer}} diff --git a/src/emailtemplates/user_assigned.html b/src/emailtemplates/user_assigned.html index 5ae84f6..1d70108 100644 --- a/src/emailtemplates/user_assigned.html +++ b/src/emailtemplates/user_assigned.html @@ -1,26 +1,6 @@ - - - - - - -
-

ROYAL ENFIELD

-
-

Hi {{userName}},

-

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

-

Applicant: {{dealerName}}
Application ID: {{applicationId}}

-

Please log in to the portal to review the application and complete your tasks.

-
- -
- - \ No newline at end of file +{{> email_header}} +

Hi {{userName}},

+

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

+

Applicant: {{dealerName}}
Application ID: {{applicationId}}

+

Please log in to the portal to review the application and complete your tasks.

+{{> email_footer}} diff --git a/src/emailtemplates/worknote_notification.html b/src/emailtemplates/worknote_notification.html index 73ce395..66e02fc 100644 --- a/src/emailtemplates/worknote_notification.html +++ b/src/emailtemplates/worknote_notification.html @@ -1,29 +1,6 @@ - - - - - - -
-

ROYAL ENFIELD

-
-

Hi {{userName}},

-

You have a new work note or update on an application you are participating in.

-

Application: {{dealerName}} ({{applicationId}})
Update: {{message}}

- -
- -
- - +{{> email_header}} +

Hi {{userName}},

+

You have a new work note or update on an application you are participating in.

+

Application: {{dealerName}} ({{applicationId}})
Update: {{message}}

+{{> primary_cta}} +{{> email_footer}} diff --git a/src/modules/assessment/assessment.controller.ts b/src/modules/assessment/assessment.controller.ts index 2f6f983..49a26e7 100644 --- a/src/modules/assessment/assessment.controller.ts +++ b/src/modules/assessment/assessment.controller.ts @@ -368,6 +368,19 @@ export const submitQuestionnaireResponse = async (req: AuthRequest, res: Respons status: 'Completed' }); + try { + const loc = + (application as any).preferredLocation || (application as any).city || 'your preferred location'; + await EmailService.sendQuestionnaireAckEmail( + application.email, + application.applicantName || 'Applicant', + loc, + application.applicationId || applicationId + ); + } catch (mailErr) { + console.error('[submitQuestionnaireResponse] acknowledgement email:', mailErr); + } + res.status(201).json({ success: true, message: 'Responses submitted successfully', score: totalWeightedScore }); } catch (error) { console.error('Submit response error:', error); diff --git a/src/modules/audit/audit.controller.ts b/src/modules/audit/audit.controller.ts index 6008b6e..a63cd6f 100644 --- a/src/modules/audit/audit.controller.ts +++ b/src/modules/audit/audit.controller.ts @@ -8,7 +8,7 @@ import { resolveEntityUuidByType, normalizeRequestType } from '../../common/util const ACTION_DESCRIPTIONS: Record = { CREATED: 'Record created', UPDATED: 'Record updated', - APPROVED: 'Approved', + APPROVED: 'Approval', REJECTED: 'Rejected', DELETED: 'Record deleted', LOGIN: 'User logged in', @@ -63,6 +63,10 @@ const ACTION_DESCRIPTIONS: Record = { RESIGNATION_SUBMITTED: 'Resignation submitted', RESIGNATION_APPROVED: 'Resignation approved', RESIGNATION_REJECTED: 'Resignation rejected', + RESIGNATION_REVOKED: 'Resignation request revoked', + RESIGNATION_SENT_BACK: 'Resignation sent back for clarification', + TERMINATION_REVOKED: 'Termination request revoked', + TERMINATION_SENT_BACK: 'Termination sent back for clarification', RELOCATION_SENT_BACK: 'Relocation sent back', RELOCATION_REVOKED: 'Relocation revoked', CONSTITUTIONAL_SENT_BACK: 'Constitutional change sent back', diff --git a/src/modules/collaboration/collaboration.controller.ts b/src/modules/collaboration/collaboration.controller.ts index 6ee47ea..2b0d0e4 100644 --- a/src/modules/collaboration/collaboration.controller.ts +++ b/src/modules/collaboration/collaboration.controller.ts @@ -6,10 +6,15 @@ const { } = db; import { AuthRequest } from '../../types/express.types.js'; import { AUDIT_ACTIONS, REQUEST_TYPES } from '../../common/config/constants.js'; -import { resolveEntityUuidByType, requestTypeQueryVariants } from '../../common/utils/requestResolver.js'; +import { + resolveEntityUuidByType, + requestTypeQueryVariants, + uuidCandidatesForWorknoteRequestId +} from '../../common/utils/requestResolver.js'; import * as EmailService from '../../common/utils/email.service.js'; import { getIO } from '../../common/utils/socket.js'; -import * as NotificationService from '../../common/utils/notification.service.js'; +import * as InAppPushNotificationService from '../../common/utils/notification.service.js'; +import { NotificationService as EmailNotificationService } from '../../services/NotificationService.js'; import logger from '../../common/utils/logger.js'; // --- Helpers --- @@ -66,10 +71,23 @@ const stitchWorknoteAttachments = async (worknotes: any[]) => { return Promise.all(notePromises); }; +/** + * Build where-clause for `worknotes.request_id` (UUID column). Must not include human-readable + * request codes (REL-*, CC-*, etc.) โ€” Postgres rejects casting them to uuid (500 on IN lists). + */ function worknoteListWhere(rawId: string, resolvedId: string, normalizedType: string) { - const idVariants = Array.from(new Set([String(rawId || '').trim(), String(resolvedId || '').trim()].filter(Boolean))); const variants = requestTypeQueryVariants(normalizedType); - const requestIdWhere = idVariants.length > 1 ? { [db.Sequelize.Op.in]: idVariants } : idVariants[0]; + const uuidIds = uuidCandidatesForWorknoteRequestId(rawId, resolvedId); + + if (uuidIds.length === 0) { + // No UUID to match โ€” return unsatisfiable predicate without invalid uuid casts + return { + userId: { [db.Sequelize.Op.is]: null } as any, + requestType: variants.length > 1 ? { [db.Sequelize.Op.in]: variants } : variants[0] + }; + } + + const requestIdWhere = uuidIds.length > 1 ? { [db.Sequelize.Op.in]: uuidIds } : uuidIds[0]; if (variants.length > 1) return { requestId: requestIdWhere, requestType: { [db.Sequelize.Op.in]: variants } }; return { requestId: requestIdWhere, requestType: variants[0] }; } @@ -172,17 +190,60 @@ export const addWorknote = async (req: AuthRequest, res: Response) => { }); } - // Send Notifications + const portalBase = process.env.FRONTEND_URL || 'http://localhost:5173'; + let worknoteLink = `${portalBase}/dashboard`; + let applicationIdLabel = String(resolvedId).slice(0, 12); + let dealerNameLabel = 'Record'; + + try { + const appRow = await Application.findByPk(resolvedId, { + attributes: ['applicationId', 'applicantName'] + }); + if (appRow) { + applicationIdLabel = appRow.applicationId || applicationIdLabel; + dealerNameLabel = appRow.applicantName || dealerNameLabel; + worknoteLink = `${portalBase}/applications/${resolvedId}?tab=worknotes`; + } + } catch { + /* optional lookup */ + } + + const mentionSnippet = + noteText && typeof noteText === 'string' + ? noteText.replace(/@\[([^\]]+)\]\(user:[^\)]+\)/g, '$1').slice(0, 280) + : 'You were mentioned in a work note.'; + + // Send Notifications (in-app + push + email template) for (const userId of notifiedUserIds) { logger.info(`Sending notification to user ID: ${userId}`); try { - await NotificationService.sendNotification({ + await InAppPushNotificationService.sendNotification({ userId, title: 'New Mention', message: `${req.user?.fullName || 'Someone'} mentioned you in a worknote.`, type: 'info', link: `/applications/${resolvedId}?tab=worknotes` }); + + const targetUser = await User.findByPk(userId, { + attributes: ['id', 'email', 'fullName'] + }); + if (targetUser?.email) { + await EmailNotificationService.notify(userId, targetUser.email, { + title: 'Worknote mention', + message: mentionSnippet, + channels: ['email'], + templateCode: 'WORKNOTE_NOTIFICATION', + placeholders: { + userName: targetUser.fullName || 'there', + applicationId: applicationIdLabel, + dealerName: dealerNameLabel, + message: mentionSnippet, + link: worknoteLink, + ctaLabel: 'View worknotes' + } + }); + } } catch (notifyErr) { logger.warn(`Failed to send notification to ${userId}:`, notifyErr); } diff --git a/src/modules/onboarding/questionnaire.controller.ts b/src/modules/onboarding/questionnaire.controller.ts index a245735..100178b 100644 --- a/src/modules/onboarding/questionnaire.controller.ts +++ b/src/modules/onboarding/questionnaire.controller.ts @@ -4,6 +4,7 @@ const { Questionnaire, QuestionnaireQuestion, QuestionnaireOption, Questionnaire import { v4 as uuidv4 } from 'uuid'; import { AuthRequest } from '../../types/express.types.js'; import { APPLICATION_STATUS } from '../../common/config/constants.js'; +import { sendQuestionnaireAckEmail } from '../../common/utils/email.service.js'; export const getLatestQuestionnaire = async (req: Request, res: Response) => { try { @@ -278,6 +279,21 @@ export const submitPublicResponse = async (req: Request, res: Response) => { changedBy: null // System action / Public user }); + try { + const location = + (application as any).preferredLocation || + (application as any).city || + 'your preferred location'; + await sendQuestionnaireAckEmail( + application.email, + application.applicantName || 'Applicant', + location, + application.applicationId || application.id + ); + } catch (mailErr) { + console.error('[submitPublicResponse] acknowledgement email:', mailErr); + } + res.json({ success: true, message: 'Responses submitted successfully' }); } catch (error) { console.error('Submit public response error:', error); diff --git a/src/modules/self-service/constitutional.controller.ts b/src/modules/self-service/constitutional.controller.ts index 67e9bcd..5b8c25a 100644 --- a/src/modules/self-service/constitutional.controller.ts +++ b/src/modules/self-service/constitutional.controller.ts @@ -12,12 +12,15 @@ import { import { ConstitutionalWorkflowService } from '../../services/ConstitutionalWorkflowService.js'; import { NomenclatureService } from '../../common/utils/nomenclature.js'; import { ParticipantService } from '../../services/ParticipantService.js'; -import { writeWorkflowActivityWorknote } from '../../common/utils/workflowWorknote.js'; import { resolveEntityUuidByType } from '../../common/utils/requestResolver.js'; import { isRegisteredConstitutionalChangeType, normalizeToConstitutionalChangeType } from '../../common/utils/constitutionalNormalize.js'; +import { OFFBOARDING_ACTIONS, REQUEST_TYPES } from '../../common/config/constants.js'; +import { validateOffboardingAction, getPreviousStage } from '../../common/utils/offboardingWorkflow.utils.js'; +import { writeWorkflowActivityWorknote } from '../../common/utils/workflowWorknote.js'; +import { notifyConstitutionalSubmittedEmails } from '../../common/utils/workflow-email-notifications.js'; const STRUCTURE_TARGET_VALUES = new Set( CONSTITUTIONAL_STRUCTURE_TARGET_OPTIONS.map((o) => o.value as string) @@ -178,9 +181,10 @@ export const submitRequest = async (req: AuthRequest, res: Response) => { changeType: resolvedChangeType, description: remarksText, currentConstitution: resolvedCurrent, - currentStage: CONSTITUTIONAL_STAGES.SUBMITTED, - status: 'Submitted', - progressPercentage: ConstitutionalWorkflowService.calculateProgress(CONSTITUTIONAL_STAGES.SUBMITTED), + currentStage: CONSTITUTIONAL_STAGES.ASM_REVIEW, + status: 'ASM Review', + progressPercentage: ConstitutionalWorkflowService.calculateProgress(CONSTITUTIONAL_STAGES.ASM_REVIEW), + metadata, documents: [], timeline: [{ @@ -204,12 +208,17 @@ export const submitRequest = async (req: AuthRequest, res: Response) => { constitutionalChangeId: request.id, action: AUDIT_ACTIONS.CREATED, remarks: remarksText || 'Constitutional change request submitted', - details: { stage: CONSTITUTIONAL_STAGES.SUBMITTED, requestId: request.requestId } + details: { stage: CONSTITUTIONAL_STAGES.ASM_REVIEW, requestId: request.requestId } + }); - // Add as chat participants (Async) - ParticipantService.assignConstitutionalParticipants(request.id) - .catch(err => console.error('Error assigning participants to constitutional change:', err)); + try { + await ParticipantService.assignConstitutionalParticipants(request.id); + const displayName = (await User.findByPk(dealerUserId, { attributes: ['fullName'] }))?.fullName || 'Dealer'; + await notifyConstitutionalSubmittedEmails(request, displayName); + } catch (e) { + console.error('Error assigning participants or sending constitutional submit emails:', e); + } res.status(201).json({ success: true, @@ -340,8 +349,8 @@ export const getRequestById = async (req: AuthRequest, res: Response) => { }; const STAGE_FLOW_FORWARD: Record = { - [CONSTITUTIONAL_STAGES.SUBMITTED]: CONSTITUTIONAL_STAGES.ASM_REVIEW, [CONSTITUTIONAL_STAGES.ASM_REVIEW]: CONSTITUTIONAL_STAGES.ZM_RBM_REVIEW, + [CONSTITUTIONAL_STAGES.ZM_RBM_REVIEW]: CONSTITUTIONAL_STAGES.ZBH_REVIEW, [CONSTITUTIONAL_STAGES.ZBH_REVIEW]: CONSTITUTIONAL_STAGES.LEAD_REVIEW, [CONSTITUTIONAL_STAGES.LEAD_REVIEW]: CONSTITUTIONAL_STAGES.HEAD_REVIEW, @@ -352,8 +361,8 @@ const STAGE_FLOW_FORWARD: Record = { /** SRS ยง12.2.3 โ€” return to previous review stage */ const STAGE_FLOW_BACK: Record = { - [CONSTITUTIONAL_STAGES.ASM_REVIEW]: CONSTITUTIONAL_STAGES.SUBMITTED, [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, @@ -386,8 +395,10 @@ export const takeAction = async (req: AuthRequest, res: Response) => { const { id } = req.params; const rawAction = String(req.body.action || '').trim(); - const actionNorm = rawAction.toLowerCase().replace(/\s+/g, ' '); - const comments = req.body.comments; + // Normalize to lowercase alphanumeric for robust comparison (handles "Send Back", "sendBack", "send-back") + const actionNorm = rawAction.toLowerCase().replace(/[^a-z0-9]/g, ''); + /** Prefer `comments`; `remarks` accepted for parity with relocation / other modules */ + const comments = req.body.comments ?? req.body.remarks; const resolvedId = await resolveConstitutionalUuid(String(id)); const request = await ConstitutionalChange.findOne({ @@ -395,14 +406,15 @@ export const takeAction = async (req: AuthRequest, res: Response) => { }); if (!request) return res.status(404).json({ success: false, message: 'Request not found' }); - const sourceStage = request.currentStage; const remarksTrim = String(comments || '').trim(); - const isReject = actionNorm === 'reject'; - const isRevoke = actionNorm === 'revoke'; - const isSendBack = actionNorm.includes('send') && actionNorm.includes('back'); - const isApprove = actionNorm === 'approve' || actionNorm === 'approved'; + const normalize = (s: string) => String(s || '').toLowerCase().replace(/[^a-z0-9]/g, ''); + const isReject = actionNorm === normalize(OFFBOARDING_ACTIONS.REJECT); + const isRevoke = actionNorm === normalize(OFFBOARDING_ACTIONS.REVOKE); + const isSendBack = actionNorm === normalize(OFFBOARDING_ACTIONS.SEND_BACK); + const isApprove = actionNorm === normalize(OFFBOARDING_ACTIONS.APPROVE); + if (isReject) { await ConstitutionalWorkflowService.transitionRequest(request, CONSTITUTIONAL_STAGES.REJECTED, req.user.id, { @@ -410,74 +422,45 @@ export const takeAction = async (req: AuthRequest, res: Response) => { status: 'Rejected', remarks: comments, userFullName: req.user.fullName, - auditAction: AUDIT_ACTIONS.REJECTED + metadata: { actionType: OFFBOARDING_ACTIONS.REJECT } }); - try { - const reviewText = remarksTrim || `[Rejected] at ${sourceStage}`; - await writeWorkflowActivityWorknote({ - requestId: request.id, - requestType: 'constitutional', - userId: req.user.id, - noteText: reviewText, - noteType: 'internal' - }); - } catch (wnErr) { - console.error('[constitutional] workflow worknote:', wnErr); - } - return res.json({ success: true, message: actionSuccessMessage(rawAction) }); + return res.json({ success: true, message: actionSuccessMessage(OFFBOARDING_ACTIONS.REJECT) }); } if (isRevoke) { - if (!remarksTrim) { - return res.status(400).json({ success: false, message: 'Remarks are required to revoke (SRS ยง12.2.3 Work Notes).' }); + const validation = validateOffboardingAction(OFFBOARDING_ACTIONS.REVOKE, comments); + if (!validation.valid) { + return res.status(400).json({ success: false, message: validation.message }); } + await ConstitutionalWorkflowService.transitionRequest(request, CONSTITUTIONAL_STAGES.REVOKED, req.user.id, { action: 'Revoked', status: 'Revoked', remarks: comments, userFullName: req.user.fullName, - auditAction: AUDIT_ACTIONS.CONSTITUTIONAL_REVOKED + metadata: { actionType: OFFBOARDING_ACTIONS.REVOKE } }); - try { - await writeWorkflowActivityWorknote({ - requestId: request.id, - requestType: 'constitutional', - userId: req.user.id, - noteText: `[Revoked] ${remarksTrim}`, - noteType: 'workflow' - }); - } catch (wnErr) { - console.error('[constitutional] workflow worknote:', wnErr); - } - return res.json({ success: true, message: actionSuccessMessage(rawAction) }); + return res.json({ success: true, message: actionSuccessMessage(OFFBOARDING_ACTIONS.REVOKE) }); } if (isSendBack) { - if (!remarksTrim) { - return res.status(400).json({ success: false, message: 'Remarks are required to send back (SRS ยง12.2.3 Work Notes).' }); + const validation = validateOffboardingAction(OFFBOARDING_ACTIONS.SEND_BACK, comments); + if (!validation.valid) { + return res.status(400).json({ success: false, message: validation.message }); } - const prevStage = STAGE_FLOW_BACK[request.currentStage]; + + const prevStage = getPreviousStage(REQUEST_TYPES.CONSTITUTIONAL, request.currentStage); if (!prevStage) { return res.status(400).json({ success: false, message: 'Cannot send back from this stage' }); } + await ConstitutionalWorkflowService.transitionRequest(request, prevStage, req.user.id, { action: `Sent back to ${prevStage}`, remarks: comments, userFullName: req.user.fullName, - auditAction: AUDIT_ACTIONS.CONSTITUTIONAL_SENT_BACK + metadata: { actionType: OFFBOARDING_ACTIONS.SEND_BACK } }); - try { - await writeWorkflowActivityWorknote({ - requestId: request.id, - requestType: 'constitutional', - userId: req.user.id, - noteText: `[Send Back] ${remarksTrim}`, - noteType: 'workflow' - }); - } catch (wnErr) { - console.error('[constitutional] workflow worknote:', wnErr); - } - return res.json({ success: true, message: actionSuccessMessage(rawAction) }); + return res.json({ success: true, message: actionSuccessMessage(OFFBOARDING_ACTIONS.SEND_BACK) }); } if (!isApprove) { @@ -485,7 +468,8 @@ export const takeAction = async (req: AuthRequest, res: Response) => { } const isZmRbmJointStage = request.currentStage === CONSTITUTIONAL_STAGES.ZM_RBM_REVIEW; - if (isZmRbmJointStage) { + if (isZmRbmJointStage && isApprove) { + const stageCode = CONSTITUTIONAL_STAGE_POLICY_CODES[request.currentStage]; const policy = stageCode ? await StageApprovalPolicy.findOne({ where: { stageCode, isActive: true } }) @@ -536,11 +520,15 @@ export const takeAction = async (req: AuthRequest, res: Response) => { if (!approvalThresholdMet) { await request.update({ metadata, updatedAt: new Date() }); + const userComment = String(comments ?? '').trim(); + const systemNote = `Joint approval recorded at ${request.currentStage} (waiting for remaining approver(s))`; + const auditRemarks = userComment ? `${userComment} โ€” ${systemNote}` : systemNote; + await ConstitutionalAudit.create({ userId: req.user.id, constitutionalChangeId: request.id, action: AUDIT_ACTIONS.APPROVED, - remarks: `Joint approval recorded at ${request.currentStage} (waiting for remaining approver(s))`, + remarks: auditRemarks, details: { stage: request.currentStage, policy: { @@ -555,11 +543,13 @@ export const takeAction = async (req: AuthRequest, res: Response) => { }); try { + const jointLine = `[Joint Approval] ${req.user.fullName} approved at ZM/RBM stage. Waiting for ${waitingFor.join(', ')}.`; + const noteText = userComment ? `${jointLine} Remarks: ${userComment}` : jointLine; await writeWorkflowActivityWorknote({ requestId: request.id, requestType: 'constitutional', userId: req.user.id, - noteText: `[Joint Approval] ${req.user.fullName} approved at ZM/RBM stage. Waiting for ${waitingFor.join(', ')}.`, + noteText, noteType: 'internal' }); } catch (wnErr) { @@ -573,8 +563,24 @@ export const takeAction = async (req: AuthRequest, res: Response) => { } await request.update({ metadata, updatedAt: new Date() }); + + // Ensure the final joint approver also gets an explicit audit/worknote trail + try { + const finalUserComment = String(comments ?? '').trim(); + const finalBase = `[Joint Approval] ${req.user.fullName} provided the final required approval for the ${request.currentStage} stage.`; + await writeWorkflowActivityWorknote({ + requestId: request.id, + requestType: 'constitutional', + userId: req.user.id, + noteText: finalUserComment ? `${finalBase} Remarks: ${finalUserComment}` : finalBase, + noteType: 'internal' + }); + } catch (wnErr) { + console.error('[constitutional] final joint worknote:', wnErr); + } } + const nextStage = STAGE_FLOW_FORWARD[request.currentStage]; if (!nextStage) { return res.status(400).json({ success: false, message: 'Cannot move forward from current stage' }); @@ -583,25 +589,10 @@ export const takeAction = async (req: AuthRequest, res: Response) => { await ConstitutionalWorkflowService.transitionRequest(request, nextStage, req.user.id, { action: `Approved to ${nextStage}`, remarks: comments, - userFullName: req.user.fullName, - auditAction: AUDIT_ACTIONS.APPROVED + userFullName: req.user.fullName }); - try { - const reviewText = remarksTrim; - const noteText = reviewText || `[Approved] ${sourceStage} โ†’ ${nextStage}`; - await writeWorkflowActivityWorknote({ - requestId: request.id, - requestType: 'constitutional', - userId: req.user.id, - noteText, - noteType: 'internal' - }); - } catch (wnErr) { - console.error('[constitutional] workflow worknote:', wnErr); - } - - res.json({ success: true, message: actionSuccessMessage(rawAction) }); + res.json({ success: true, message: actionSuccessMessage(OFFBOARDING_ACTIONS.APPROVE) }); } catch (error) { console.error('Take action error:', error); res.status(500).json({ success: false, message: 'Error processing action' }); diff --git a/src/modules/self-service/relocation.controller.ts b/src/modules/self-service/relocation.controller.ts index e96364e..2a5051f 100644 --- a/src/modules/self-service/relocation.controller.ts +++ b/src/modules/self-service/relocation.controller.ts @@ -2,19 +2,97 @@ import { Response } from 'express'; import db from '../../database/models/index.js'; import { RelocationWorkflowService } from '../../services/RelocationWorkflowService.js'; import { NomenclatureService } from '../../common/utils/nomenclature.js'; -const { RelocationRequest, Outlet, User, Worknote, District, Region, Zone, RelocationDocument } = db; -import { AUDIT_ACTIONS, ROLES, RELOCATION_STAGES, OUTLET_STATUS } from '../../common/config/constants.js'; +const { RelocationRequest, Outlet, User, Worknote, District, Region, Zone, RelocationDocument, AuditLog } = db; +import { AUDIT_ACTIONS, ROLES, RELOCATION_STAGES, OUTLET_STATUS, OFFBOARDING_ACTIONS, REQUEST_TYPES } from '../../common/config/constants.js'; +import { validateOffboardingAction, getPreviousStage } from '../../common/utils/offboardingWorkflow.utils.js'; +import { writeWorkflowActivityWorknote } from '../../common/utils/workflowWorknote.js'; import { Op, Transaction } from 'sequelize'; import { v4 as uuidv4 } from 'uuid'; import { AuthRequest } from '../../types/express.types.js'; import { formatDateTime } from '../../common/utils/dateUtils.js'; import { resolveEntityUuidByType } from '../../common/utils/requestResolver.js'; +import { notifyRelocationSubmittedEmails } from '../../common/utils/workflow-email-notifications.js'; const resolveRelocationUuid = async (id: string) => { const { resolvedId } = await resolveEntityUuidByType(db as any, id, 'relocation'); return resolvedId; }; +/** + * SRS ยง12.2.8 โ€” persist approved new location onto the outlet master before the request is marked Completed. + * Writes relocation_audit_logs, audit_logs (outlet entity), and a workflow worknote. + */ +async function syncOutletFromRelocationCompletion(relocation: any, actingUserId: string): Promise { + const outlet = await Outlet.findByPk(relocation.outletId); + if (!outlet) { + throw new Error(`Outlet not found for relocation ${relocation.requestId}`); + } + + const oldData = { + address: outlet.address, + city: outlet.city, + state: outlet.state, + pincode: outlet.pincode, + districtId: String(outlet.districtId) + }; + + const patch: Record = { + address: relocation.newAddress, + city: relocation.newCity, + state: relocation.newState + }; + if (relocation.newDistrictId) { + patch.districtId = relocation.newDistrictId; + } + + await outlet.update(patch as any); + await outlet.reload(); + + const newData = { + address: outlet.address, + city: outlet.city, + state: outlet.state, + pincode: outlet.pincode, + districtId: String(outlet.districtId) + }; + + await db.RelocationAudit.create({ + userId: actingUserId, + relocationRequestId: relocation.id, + action: 'Outlet location updated (relocation completed)', + remarks: `Outlet ${outlet.code} master address synced from approved relocation ${relocation.requestId}.`, + details: { + relocationRequestId: relocation.requestId, + outletId: outlet.id, + outletCode: outlet.code, + previousLocation: oldData, + updatedLocation: newData + } + }); + + await AuditLog.create({ + userId: actingUserId, + action: AUDIT_ACTIONS.UPDATED, + entityType: 'outlet', + entityId: outlet.id, + oldData, + newData, + ipAddress: null, + userAgent: null + } as any); + + await writeWorkflowActivityWorknote({ + requestId: relocation.id, + requestType: 'relocation', + userId: actingUserId, + noteText: + `Final relocation approval: outlet ${outlet.code} master location updated from ${relocation.requestId}. ` + + `Before: ${oldData.address}, ${oldData.city}, ${oldData.state}. ` + + `After: ${newData.address}, ${newData.city}, ${newData.state}.`, + noteType: 'workflow' + }); +} + /** * Helper to assign evaluators for relocation requests based on outlet location hierarchy * Similar to assignStageEvaluators in onboarding @@ -289,6 +367,11 @@ export const submitRequest = async (req: AuthRequest, res: Response) => { } }); + await notifyRelocationSubmittedEmails(request.toJSON ? request.toJSON() : request, { + email: req.user.email || '', + fullName: req.user.fullName + }).catch((e) => console.error('[relocation.submitRequest] notify emails:', e)); + res.status(201).json({ success: true, message: 'Relocation request submitted successfully', @@ -514,10 +597,16 @@ export const takeAction = async (req: AuthRequest, res: Response) => { const { action, comments, remarks } = req.body; const reviewComments = (comments ?? remarks ?? '') as string; - const normalizedAction = String(action || '') - .trim() - .toUpperCase() - .replace(/\s+/g, '_'); + // Normalize to lowercase alphanumeric for robust comparison (handles "Send Back", "sendBack", "send-back") + const normalizedActionFull = String(action || '').trim().toLowerCase().replace(/[^a-z0-9]/g, ''); + + // Map back to uppercase underscore format for internal logic if needed, + // but it's safer to compare against constants directly. + const normalizedAction = normalizedActionFull === 'approve' ? 'APPROVE' : + normalizedActionFull === 'reject' ? 'REJECT' : + normalizedActionFull === 'sendback' ? 'SEND_BACK' : + normalizedActionFull === 'revoke' ? 'REVOKE' : + normalizedActionFull === 'hold' ? 'HOLD' : normalizedActionFull.toUpperCase(); const resolvedId = await resolveRelocationUuid(String(id)); @@ -548,11 +637,14 @@ export const takeAction = async (req: AuthRequest, res: Response) => { } // SRS ยง12.2.8 โ€” Send Back / Revoke communicated through Work Notes with mandatory remarks - if ((normalizedAction === 'SEND_BACK' || normalizedAction === 'REVOKE') && !String(reviewComments).trim()) { - return res.status(400).json({ - success: false, - message: 'Remarks are required for Send Back and Revoke.' - }); + if (normalizedAction === 'SEND_BACK' || normalizedAction === 'REVOKE') { + const validation = validateOffboardingAction( + normalizedAction === 'SEND_BACK' ? OFFBOARDING_ACTIONS.SEND_BACK : OFFBOARDING_ACTIONS.REVOKE, + reviewComments + ); + if (!validation.valid) { + return res.status(400).json({ success: false, message: validation.message }); + } } // 1. Authorization Check via Workflow Service @@ -592,12 +684,38 @@ export const takeAction = async (req: AuthRequest, res: Response) => { [RELOCATION_STAGES.NBH_CLEARANCE_EOR]: RELOCATION_STAGES.LEGAL_CLEARANCE }; + /** Canonical order for progress % (must stay aligned with stageFlow chain, excluding terminal). */ + const RELOCATION_PIPELINE_STAGES: string[] = [ + RELOCATION_STAGES.ASM_REVIEW, + RELOCATION_STAGES.RBM_REVIEW, + RELOCATION_STAGES.DD_ZM_REVIEW, + RELOCATION_STAGES.ZBH_REVIEW, + RELOCATION_STAGES.DD_LEAD_REVIEW, + RELOCATION_STAGES.DD_HEAD_APPROVAL, + RELOCATION_STAGES.NBH_APPROVAL, + RELOCATION_STAGES.LEGAL_CLEARANCE, + RELOCATION_STAGES.NBH_CLEARANCE_EOR + ]; + + /** ~10% per stage while in flight; 100% only when Completed. Avoids showing 100% on NBH EOR before final approve. */ + const relocationProgressPercentageForStage = (stage: string): number => { + if (stage === RELOCATION_STAGES.COMPLETED) return 100; + const normalized = stage === 'DD Admin Review' ? RELOCATION_STAGES.ASM_REVIEW : stage; + const i = RELOCATION_PIPELINE_STAGES.indexOf(normalized); + if (i < 0) return 10; + return Math.min(100, Math.round(((i + 1) / (RELOCATION_PIPELINE_STAGES.length + 1)) * 100)); + }; + + let actionType = OFFBOARDING_ACTIONS.APPROVE; + if (normalizedAction === 'APPROVE') { - newCurrentStage = stageFlow[request.currentStage] || request.currentStage; + newCurrentStage = stageFlow[request.currentStage as string] || request.currentStage; newStatus = newCurrentStage === RELOCATION_STAGES.COMPLETED ? 'Completed' : `Pending ${newCurrentStage}`; + actionType = OFFBOARDING_ACTIONS.APPROVE; } else if (normalizedAction === 'REJECT') { newStatus = 'Rejected'; newCurrentStage = RELOCATION_STAGES.REJECTED; + actionType = OFFBOARDING_ACTIONS.REJECT; } else if (normalizedAction === 'SEND_BACK') { const prevStage = reverseStageFlow[request.currentStage as string]; if (!prevStage) { @@ -631,15 +749,28 @@ export const takeAction = async (req: AuthRequest, res: Response) => { } } - const progressSteps = 9; - const stageKeys = Object.keys(stageFlow); - const currentStepIndex = stageKeys.indexOf(request.currentStage as string); - const backStepIndex = stageKeys.indexOf(newCurrentStage as string); let newProgress = request.progressPercentage; if (normalizedAction === 'APPROVE') { - newProgress = Math.min(Math.round(((currentStepIndex + 2) / progressSteps) * 100), 100); - } else if (normalizedAction === 'SEND_BACK' && backStepIndex >= 0) { - newProgress = Math.max(0, Math.min(100, Math.round(((backStepIndex + 1) / progressSteps) * 100))); + newProgress = relocationProgressPercentageForStage(newCurrentStage as string); + } else if (normalizedAction === 'SEND_BACK') { + newProgress = relocationProgressPercentageForStage(newCurrentStage as string); + } else if (normalizedAction === 'REJECT' || normalizedAction === 'REVOKE') { + newProgress = 100; + } + + // SRS ยง12.2.8 โ€” update outlet master before marking relocation Completed (fails the action if sync fails) + if (normalizedAction === 'APPROVE' && newCurrentStage === RELOCATION_STAGES.COMPLETED) { + try { + await syncOutletFromRelocationCompletion(request, req.user.id); + } catch (syncErr: any) { + console.error('[RelocationController] Outlet sync failed before relocation completion:', syncErr); + return res.status(500).json({ + success: false, + message: + syncErr?.message || + 'Could not update outlet master location before completing relocation. The request was not completed.' + }); + } } await RelocationWorkflowService.transitionRelocation(request, newStatus, req.user?.id || null, { diff --git a/src/modules/self-service/resignation.controller.ts b/src/modules/self-service/resignation.controller.ts index a28e26d..b25a5ab 100644 --- a/src/modules/self-service/resignation.controller.ts +++ b/src/modules/self-service/resignation.controller.ts @@ -17,9 +17,11 @@ import ExternalMocksService from '../../common/utils/externalMocks.service.js'; import { ResignationWorkflowService } from '../../services/ResignationWorkflowService.js'; import { NomenclatureService } from '../../common/utils/nomenclature.js'; import { ParticipantService } from '../../services/ParticipantService.js'; +import { notifyResignationSubmittedEmails } from '../../common/utils/workflow-email-notifications.js'; import { getResignationStatusForStage, normalizeClearanceStatus } from '../../common/utils/offboardingStatus.js'; -import { writeWorkflowActivityWorknote } from '../../common/utils/workflowWorknote.js'; import { resolveEntityUuidByType } from '../../common/utils/requestResolver.js'; +import { OFFBOARDING_ACTIONS, REQUEST_TYPES } from '../../common/config/constants.js'; +import { validateOffboardingAction, getPreviousStage } from '../../common/utils/offboardingWorkflow.utils.js'; // Removed generateResignationId and moved to NomenclatureService const resolveResignationUuid = async (id: string) => { @@ -52,7 +54,7 @@ export const createResignation = async (req: AuthRequest, res: Response, next: N const { FNF_DEPARTMENTS } = await import('../../common/config/constants.js'); const initialClearances: Record = {}; FNF_DEPARTMENTS.forEach(dept => { - initialClearances[dept] = { status: 'Pending', remarks: '', amount: 0, type: 'Recovery' }; + initialClearances[dept] = { status: 'Pending', remarks: '', amount: 0, type: 'Receivable' }; }); const resignationId = NomenclatureService.generateResignationId(); @@ -90,9 +92,12 @@ export const createResignation = async (req: AuthRequest, res: Response, next: N await transaction.commit(); logger.info(`Resignation request created: ${resignationId} by dealer: ${req.user.email}`); - // Add as chat participants (Async) - ParticipantService.assignResignationParticipants(resignation.id) - .catch(err => logger.error('Error assigning participants to resignation:', err)); + try { + await ParticipantService.assignResignationParticipants(resignation.id); + await notifyResignationSubmittedEmails(resignation.toJSON ? resignation.toJSON() : resignation); + } catch (partErr) { + logger.error('Error assigning resignation participants or submit emails:', partErr); + } res.status(201).json({ success: true, message: 'Resignation request submitted successfully', resignationId, resignation }); } catch (error) { @@ -358,6 +363,7 @@ export const approveResignation = async (req: AuthRequest, res: Response, next: // Transition via Workflow Service await ResignationWorkflowService.transitionResignation(resignation, nextStage, req.user.id, { remarks, + actionType: OFFBOARDING_ACTIONS.APPROVE, status: getResignationStatusForStage(nextStage), transaction }); @@ -372,25 +378,20 @@ export const approveResignation = async (req: AuthRequest, res: Response, next: if (nextStage === RESIGNATION_STAGES.FNF_INITIATED) { const existingFnF = await db.FnF.findOne({ where: { resignationId: resignation.id }, transaction }); if (!existingFnF) { - const sapDues = await ExternalMocksService.mockGetFinancialDuesFromSap((resignation as any).outlet.code); const dealerProfileId = (resignation as any).dealer?.dealerId; + // No seed line items or mock SAP amounts โ€” totals stay 0 until users/scripts add FnF line items or department clearances. const fnf = await db.FnF.create({ settlementId: NomenclatureService.generateFnFId(), resignationId: resignation.id, outletId: resignation.outletId, dealerId: dealerProfileId, // Correctly using the Dealer model ID status: 'Initiated', - totalReceivables: sapDues.data.outstandingInvoices, - totalPayables: sapDues.data.securityDeposit, - netAmount: sapDues.data.securityDeposit - sapDues.data.outstandingInvoices + totalReceivables: 0, + totalPayables: 0, + netAmount: 0 }, { transaction }); - await db.FnFLineItem.bulkCreate([ - { fnfId: fnf.id, itemType: 'Receivable', description: 'Outstanding Invoices from SAP', department: 'Finance', amount: sapDues.data.outstandingInvoices, addedBy: req.user.id }, - { fnfId: fnf.id, itemType: 'Payable', description: 'Security Deposit from SAP', department: 'Finance', amount: sapDues.data.securityDeposit, addedBy: req.user.id } - ], { transaction }); - const { FNF_DEPARTMENTS } = await import('../../common/config/constants.js'); await db.FffClearance.bulkCreate( FNF_DEPARTMENTS.map(dept => ({ @@ -405,19 +406,6 @@ export const approveResignation = async (req: AuthRequest, res: Response, next: await transaction.commit(); - try { - const noteText = String(remarks || '').trim() || `[Approved] ${sourceStage} โ†’ ${nextStage}`; - await writeWorkflowActivityWorknote({ - requestId: resignation.id, - requestType: 'resignation', - userId: req.user.id, - noteText, - noteType: 'internal' - }); - } catch (wnErr) { - logger.error('[resignation] workflow worknote (approve):', wnErr); - } - const message = (sourceStage === RESIGNATION_STAGES.LEGAL && nextStage === RESIGNATION_STAGES.LEGAL) ? 'Legal stage approved successfully. Use Push to F&F to initiate settlement as per LWD rules.' : 'Resignation approved successfully'; @@ -454,25 +442,14 @@ export const rejectResignation = async (req: AuthRequest, res: Response, next: N await ResignationWorkflowService.transitionResignation(resignation, RESIGNATION_STAGES.REJECTED, req.user.id, { remarks: reason, - action: 'Rejected', + action: OFFBOARDING_ACTIONS.REJECT, + actionType: OFFBOARDING_ACTIONS.REJECT, status: 'Rejected' }); await (resignation as any).outlet.update({ status: 'Active' }, { transaction }); await transaction.commit(); - try { - await writeWorkflowActivityWorknote({ - requestId: resignation.id, - requestType: 'resignation', - userId: req.user.id, - noteText: String(reason || '').trim(), - noteType: 'internal' - }); - } catch (wnErr) { - logger.error('[resignation] workflow worknote (reject):', wnErr); - } - res.json({ success: true, message: 'Resignation rejected', resignation }); } catch (error) { if (transaction) await transaction.rollback(); @@ -524,21 +501,6 @@ export const withdrawResignation = async (req: AuthRequest, res: Response, next: await (resignation as any).outlet.update({ status: 'Active' }, { transaction }); await transaction.commit(); - try { - const noteText = String(reason || '').trim() - ? `[Withdrawn] ${String(reason).trim()}` - : '[Withdrawn]'; - await writeWorkflowActivityWorknote({ - requestId: resignation.id, - requestType: 'resignation', - userId: req.user.id, - noteText, - noteType: 'workflow' - }); - } catch (wnErr) { - logger.error('[resignation] workflow worknote (withdraw):', wnErr); - } - res.json({ success: true, message: 'Resignation withdrawn successfully' }); } catch (error) { if (transaction) await transaction.rollback(); @@ -564,16 +526,14 @@ export const sendBackResignation = async (req: AuthRequest, res: Response, next: return res.status(404).json({ success: false, message: 'Resignation not found' }); } - const stageFlowBack: Record = { - [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 - }; + // Standardized validation + const validation = validateOffboardingAction(OFFBOARDING_ACTIONS.SEND_BACK, remarks); + if (!validation.valid) { + await transaction.rollback(); + return res.status(400).json({ success: false, message: validation.message }); + } - const prevStage = targetStage || stageFlowBack[resignation.currentStage]; + const prevStage = targetStage || getPreviousStage(REQUEST_TYPES.RESIGNATION, resignation.currentStage); if (!prevStage) { await transaction.rollback(); return res.status(400).json({ success: false, message: 'Cannot send back from current stage' }); @@ -581,26 +541,11 @@ export const sendBackResignation = async (req: AuthRequest, res: Response, next: await ResignationWorkflowService.transitionResignation(resignation, prevStage, req.user.id, { remarks, - action: 'Sent Back', + action: OFFBOARDING_ACTIONS.SEND_BACK, status: `${getResignationStatusForStage(prevStage)} (Sent Back)` }); await transaction.commit(); - - try { - const r = String(remarks || '').trim(); - const noteText = r ? `[Send Back] ${r}` : `[Send Back] Returned to ${prevStage}`; - await writeWorkflowActivityWorknote({ - requestId: resignation.id, - requestType: 'resignation', - userId: req.user.id, - noteText, - noteType: 'workflow' - }); - } catch (wnErr) { - logger.error('[resignation] workflow worknote (send back):', wnErr); - } - res.json({ success: true, message: `Resignation sent back to ${prevStage}` }); } catch (error) { if (transaction) await transaction.rollback(); @@ -609,6 +554,46 @@ export const sendBackResignation = async (req: AuthRequest, res: Response, next: } }; +// Revoke resignation (Standardized Action) +export const revokeResignation = async (req: AuthRequest, res: Response, next: NextFunction) => { + const transaction: Transaction = await db.sequelize.transaction(); + try { + if (!req.user) throw new Error('Unauthorized'); + const { id } = req.params; + const { remarks } = req.body; + const resolvedId = await resolveResignationUuid(String(id)); + + const resignation = await db.Resignation.findOne({ + where: { id: resolvedId } + }); + if (!resignation) { + await transaction.rollback(); + return res.status(404).json({ success: false, message: 'Resignation not found' }); + } + + // Standardized validation + const validation = validateOffboardingAction(OFFBOARDING_ACTIONS.REVOKE, remarks); + if (!validation.valid) { + await transaction.rollback(); + return res.status(400).json({ success: false, message: validation.message }); + } + + // Transition to REJECTED stage with Revoked status (Terminal) + await ResignationWorkflowService.transitionResignation(resignation, RESIGNATION_STAGES.REJECTED, req.user.id, { + remarks, + action: OFFBOARDING_ACTIONS.REVOKE, + status: 'Revoked' + }); + + await transaction.commit(); + res.json({ success: true, message: `Resignation for ${resignation.resignationId} has been revoked and closed.` }); + } catch (error) { + if (transaction) await transaction.rollback(); + logger.error('Error revoking resignation:', error); + next(error); + } +}; + // Update departmental clearance (existing code)... // Manually assign participant @@ -717,6 +702,30 @@ export const updateClearance = async (req: AuthRequest, res: Response, next: Nex if (!req.user) throw new Error('Unauthorized'); const { id } = req.params; const { department, status, remarks, amount, type } = req.body; + + // Align with F&F: dealer-owed side is always stored as Receivable (legacy payloads may send Recovery) + const clearanceType = String(type || '').toLowerCase(); + const resolvedItemType: 'Payable' | 'Receivable' | 'Deduction' = + clearanceType === 'payable' + ? 'Payable' + : clearanceType === 'deduction' + ? 'Deduction' + : clearanceType === 'recovery' || clearanceType === 'receivable' + ? 'Receivable' + : type === 'Payable' || type === 'Deduction' + ? type + : type === 'Recovery' + ? 'Receivable' + : type === 'Receivable' + ? 'Receivable' + : 'Receivable'; + + const clearanceStoredType: 'Payable' | 'Receivable' | 'Deduction' = + resolvedItemType === 'Payable' + ? 'Payable' + : resolvedItemType === 'Deduction' + ? 'Deduction' + : 'Receivable'; const resolvedId = await resolveResignationUuid(String(id)); const resignation = await db.Resignation.findOne({ @@ -738,7 +747,7 @@ export const updateClearance = async (req: AuthRequest, res: Response, next: Nex status: normalizedDeptStatus, remarks, amount: normalizedAmount, - type: type || 'Recovery', + type: clearanceStoredType, supportingDocument: documentUrl, updatedAt: new Date().toISOString(), updatedBy: req.user.fullName @@ -821,7 +830,7 @@ export const updateClearance = async (req: AuthRequest, res: Response, next: Nex } await db.FnFLineItem.create({ fnfId: fnf.id, - itemType: type || 'Receivable', + itemType: resolvedItemType, description: '[DEPARTMENT_CLAIM] Department Clearance - Manual Update', department, amount: enteredAmount, @@ -879,11 +888,16 @@ export const updateResignationStatus = async (req: AuthRequest, res: Response, n try { const { action } = req.body; - switch (action) { + // Normalize to lowercase alphanumeric for robust comparison (handles "Send Back", "sendBack", "send-back") + const actionNode = String(action || '').toLowerCase().trim().replace(/[^a-z0-9]/g, ''); + + switch (actionNode) { case 'approve': return approveResignation(req, res, next); case 'reject': return rejectResignation(req, res, next); + case 'revoke': + return revokeResignation(req, res, next); case 'withdrawal': case 'withdraw': return withdrawResignation(req, res, next); diff --git a/src/modules/self-service/resignation.routes.ts b/src/modules/self-service/resignation.routes.ts index 262dff2..27d6991 100644 --- a/src/modules/self-service/resignation.routes.ts +++ b/src/modules/self-service/resignation.routes.ts @@ -3,7 +3,7 @@ const router = express.Router(); import * as resignationController from './resignation.controller.js'; import { authenticate } from '../../common/middleware/auth.js'; -import { uploadSingle } from '../../common/middleware/upload.js'; +import { uploadSingle, uploadSingleIfMultipart } from '../../common/middleware/upload.js'; // Protected routes router.post('/', authenticate as any, resignationController.createResignation); @@ -20,7 +20,7 @@ router.post('/:id/withdraw', authenticate as any, resignationController.withdraw router.put('/:id/sendback', authenticate as any, resignationController.sendBackResignation); router.post('/:id/sendback', authenticate as any, resignationController.sendBackResignation); -router.put('/:id/clearance', authenticate as any, uploadSingle, resignationController.updateClearance); +router.put('/:id/clearance', authenticate as any, uploadSingleIfMultipart, resignationController.updateClearance); router.post('/:id/documents', authenticate as any, uploadSingle, resignationController.uploadResignationDocument); export default router; diff --git a/src/modules/settlement/settlement.routes.ts b/src/modules/settlement/settlement.routes.ts index d85d05c..da99d77 100644 --- a/src/modules/settlement/settlement.routes.ts +++ b/src/modules/settlement/settlement.routes.ts @@ -4,7 +4,7 @@ import * as settlementController from './settlement.controller.js'; import { authenticate } from '../../common/middleware/auth.js'; import { checkRole } from '../../common/middleware/roleCheck.js'; import { ROLES } from '../../common/config/constants.js'; -import { uploadSingle } from '../../common/middleware/upload.js'; +import { uploadSingleIfMultipart } from '../../common/middleware/upload.js'; // All routes require authentication router.use(authenticate as any); @@ -20,7 +20,7 @@ router.put('/payments/:id', checkRole([ROLES.FINANCE, ROLES.SUPER_ADMIN]) as any router.put('/fnf/:id', checkRole([ROLES.FINANCE, ROLES.SUPER_ADMIN]) as any, settlementController.updateFnF); router.post('/fnf/:id/calculate', checkRole([ROLES.FINANCE, ROLES.SUPER_ADMIN]) as any, settlementController.calculateFnF); -router.put('/fnf/:id/clearances/:clearanceId', uploadSingle, checkRole([ROLES.FINANCE, ROLES.SPARES_MANAGER, ROLES.SERVICE_MANAGER, ROLES.ACCOUNTS_MANAGER, ROLES.SUPER_ADMIN]) as any, settlementController.updateClearance); +router.put('/fnf/:id/clearances/:clearanceId', uploadSingleIfMultipart, checkRole([ROLES.FINANCE, ROLES.SPARES_MANAGER, ROLES.SERVICE_MANAGER, ROLES.ACCOUNTS_MANAGER, ROLES.SUPER_ADMIN]) as any, settlementController.updateClearance); // Line item management router.post('/fnf/:id/line-items', checkRole([ROLES.FINANCE, ROLES.SUPER_ADMIN]) as any, settlementController.addLineItem); diff --git a/src/modules/termination/termination.controller.ts b/src/modules/termination/termination.controller.ts index e68c0c6..a2bda80 100644 --- a/src/modules/termination/termination.controller.ts +++ b/src/modules/termination/termination.controller.ts @@ -16,8 +16,9 @@ import { TerminationWorkflowService } from '../../services/TerminationWorkflowSe import { NomenclatureService } from '../../common/utils/nomenclature.js'; import { ParticipantService } from '../../services/ParticipantService.js'; import { getTerminationStatusForStage, normalizeClearanceStatus } from '../../common/utils/offboardingStatus.js'; -import { writeWorkflowActivityWorknote } from '../../common/utils/workflowWorknote.js'; import { resolveEntityUuidByType } from '../../common/utils/requestResolver.js'; +import { OFFBOARDING_ACTIONS, REQUEST_TYPES } from '../../common/config/constants.js'; +import { validateOffboardingAction, getPreviousStage } from '../../common/utils/offboardingWorkflow.utils.js'; const resolveTerminationUuid = async (id: string) => { const { resolvedId } = await resolveEntityUuidByType(db as any, id, 'termination'); @@ -170,13 +171,14 @@ export const uploadTerminationDocument = async (req: AuthRequest, res: Response, const transaction: Transaction = await db.sequelize.transaction(); try { if (!req.user) throw new Error('Unauthorized'); + const { id } = req.params; + const { documentType = TERMINATION_DOCUMENT_TYPES[0], stage = null } = req.body; + if (!req.file) { await transaction.rollback(); return res.status(400).json({ success: false, message: 'File is required' }); } - const { id } = req.params; - const { documentType = TERMINATION_DOCUMENT_TYPES[0], stage = null } = req.body; if (!TERMINATION_DOCUMENT_TYPES.includes(documentType)) { await transaction.rollback(); return res.status(400).json({ @@ -270,12 +272,43 @@ export const updateTerminationStatus = async (req: AuthRequest, res: Response, n const fromStage = termination.currentStage; let approvedToStage: string | null = null; - if (action === 'reject') { + if (action === OFFBOARDING_ACTIONS.REJECT) { await TerminationWorkflowService.transitionTermination(termination, TERMINATION_STAGES.REJECTED, req.user.id, { action: 'Rejected', status: 'Rejected', remarks }); + } else if (action === OFFBOARDING_ACTIONS.REVOKE) { + // Validation: Remarks mandatory for Revoke + const validation = validateOffboardingAction(action, remarks); + if (!validation.valid) { + await transaction.rollback(); + return res.status(400).json({ success: false, message: validation.message }); + } + + await TerminationWorkflowService.transitionTermination(termination, TERMINATION_STAGES.REJECTED, req.user.id, { + action: 'Revoked', + status: 'Revoked', + remarks + }); + } else if (action === OFFBOARDING_ACTIONS.SEND_BACK || action === 'sendback') { + // Validation: Remarks mandatory for Send Back + const validation = validateOffboardingAction(OFFBOARDING_ACTIONS.SEND_BACK, remarks); + if (!validation.valid) { + await transaction.rollback(); + return res.status(400).json({ success: false, message: validation.message }); + } + + const previousStage = getPreviousStage(REQUEST_TYPES.TERMINATION, termination.currentStage); + if (!previousStage) { + await transaction.rollback(); + return res.status(400).json({ success: false, message: 'Cannot send back from current stage' }); + } + + await TerminationWorkflowService.transitionTermination(termination, previousStage, req.user.id, { + action: 'Sent Back', + remarks + }); } else { const stageFlow: Record = { [TERMINATION_STAGES.SUBMITTED]: TERMINATION_STAGES.RBM_REVIEW, @@ -329,32 +362,6 @@ export const updateTerminationStatus = async (req: AuthRequest, res: Response, n } await transaction.commit(); - - try { - if (action === 'reject') { - const noteText = String(remarks || '').trim() || `[Rejected] at ${fromStage}`; - await writeWorkflowActivityWorknote({ - requestId: termination.id, - requestType: 'termination', - userId: req.user.id, - noteText, - noteType: 'internal' - }); - } else if (approvedToStage) { - const noteText = - String(remarks || '').trim() || `[Approved] ${fromStage} โ†’ ${approvedToStage}`; - await writeWorkflowActivityWorknote({ - requestId: termination.id, - requestType: 'termination', - userId: req.user.id, - noteText, - noteType: 'internal' - }); - } - } catch (wnErr) { - logger.error('[termination] workflow worknote:', wnErr); - } - res.json({ success: true, message: 'Termination updated', termination }); } catch (error) { if (transaction) await transaction.rollback(); @@ -517,13 +524,25 @@ export const finalizeTermination = async (req: AuthRequest, res: Response, next: if (decision === 'Reject') { await TerminationWorkflowService.transitionTermination(termination, TERMINATION_STAGES.REJECTED, req.user.id, { action: 'Final Authorization Rejected', + actionType: OFFBOARDING_ACTIONS.REJECT, status: 'Rejected', remarks: remarks || 'Rejected during final authorization' }); - } else if (decision === 'Reconsider') { - await TerminationWorkflowService.transitionTermination(termination, TERMINATION_STAGES.NBH_EVALUATION, req.user.id, { + } else if (decision === 'Reconsider' || decision === 'Send Back' as any) { + // Standardizing Send Back / Reconsideration logic + const validation = validateOffboardingAction(OFFBOARDING_ACTIONS.SEND_BACK, remarks || ''); + if (!validation.valid) { + await transaction.rollback(); + return res.status(400).json({ success: false, message: validation.message }); + } + + const previousStage = getPreviousStage(REQUEST_TYPES.TERMINATION, termination.currentStage); + const targetStage = previousStage || TERMINATION_STAGES.NBH_EVALUATION; // Fallback to NBH if manual resolve fails + + await TerminationWorkflowService.transitionTermination(termination, targetStage, req.user.id, { action: 'Sent for Reconsideration', - status: 'NBH Evaluation', + actionType: OFFBOARDING_ACTIONS.RECONSIDER, + status: getTerminationStatusForStage(targetStage), remarks: remarks || 'Sent back for reconsideration' }); } else { @@ -535,6 +554,7 @@ export const finalizeTermination = async (req: AuthRequest, res: Response, next: const targetStage = approveFlow[currentStage]; await TerminationWorkflowService.transitionTermination(termination, targetStage, req.user.id, { action: `Final Authorization Approved to ${targetStage}`, + actionType: OFFBOARDING_ACTIONS.APPROVE, status: getTerminationStatusForStage(targetStage), remarks: remarks || 'Approved' }); diff --git a/src/scripts/seed-master-emails.ts b/src/scripts/seed-master-emails.ts index 5a9187c..c387dca 100644 --- a/src/scripts/seed-master-emails.ts +++ b/src/scripts/seed-master-emails.ts @@ -2,6 +2,7 @@ import db from '../database/models/index.js'; import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; +import { Op } from 'sequelize'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -10,139 +11,186 @@ const seedTemplates = async () => { try { const templatesDir = path.join(__dirname, '../emailtemplates'); + // Legacy rows used lowercase codes; remove to avoid duplicate keys after rename + await db.EmailTemplate.destroy({ + where: { templateCode: { [Op.in]: ['opportunity', 'non_opportunity'] } } + }); + const templates = [ { - templateCode: 'opportunity', + templateCode: 'OPPORTUNITY', description: 'Opportunity link for dealership application assessment', subject: 'Action Required: Royal Enfield Dealership Opportunity', fileName: 'opportunity.html', - placeholders: ['applicantName', 'location', 'link', 'year'] + placeholders: ['applicantName', 'location'] }, { - templateCode: 'non_opportunity', + templateCode: 'NON_OPPORTUNITY', description: 'Regret email for non-opportunity applications', subject: 'Update on your Royal Enfield Dealership Application', fileName: 'non_opportunity.html', - placeholders: ['applicantName', 'location', 'year'] + placeholders: ['applicantName', 'location'] }, { templateCode: 'INTERVIEW_SCHEDULED', description: 'Notification for scheduled interview', subject: 'Interview Scheduled: {{applicationId}}', fileName: 'interview_scheduled.html', - placeholders: ['name', 'applicationId', 'level', 'dateTime', 'type', 'location', 'year'] + placeholders: ['name', 'applicationId', 'level', 'dateTime', 'type', 'location'] }, { 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'] + placeholders: ['userName', 'applicationId', 'dealerName', 'participantType'] }, { templateCode: 'QUESTIONNAIRE_SUBMITTED', description: 'Acknowledgement for questionnaire submission', subject: 'Questionnaire Submitted Successfully: {{applicationId}}', fileName: 'questionnaire_submitted.html', - placeholders: ['applicantName', 'location', 'applicationId', 'year'] + placeholders: ['applicantName', 'location', 'applicationId'] }, { templateCode: 'APPLICANT_SHORTLISTED', description: 'Notification for shortlisted applicants', subject: 'Congratulations! You are Shortlisted: {{applicationId}}', fileName: 'applicant_shortlisted.html', - placeholders: ['applicantName', 'location', 'applicationId', 'portalLink', 'year'] + placeholders: ['applicantName', 'location', 'applicationId'] }, { templateCode: 'LOI_ISSUED', description: 'Notification when Letter of Intent is issued', subject: 'Letter of Intent (LOI) Issued: {{applicationId}}', fileName: 'loi_issued.html', - placeholders: ['applicantName', 'applicationId', 'portalLink', 'year'] + placeholders: ['applicantName', 'applicationId'] }, { templateCode: 'LOA_ISSUED', description: 'Notification when Letter of Appointment is issued', subject: 'Letter of Appointment (LOA) Issued: {{applicationId}}', fileName: 'loa_issued.html', - placeholders: ['applicantName', 'applicationId', 'dealerCode', 'portalLink', 'year'] + placeholders: ['applicantName', 'applicationId', 'dealerCode'] }, { templateCode: 'DEALER_CODE_READY', description: 'Notification when SAP Dealer Codes are generated', subject: 'SAP Dealer Codes Readiness for {{applicationId}}', fileName: 'dealer_code_ready.html', - placeholders: ['applicantName', 'applicationId', 'salesCode', 'serviceCode', 'year'] + placeholders: ['applicantName', 'applicationId', 'salesCode', 'serviceCode'] + }, + { + templateCode: 'ONBOARDING_STATUS_UPDATE', + description: 'General onboarding status changes (excluding LOI/LOA/dealer-code specific mails)', + subject: 'Onboarding Status Update: {{status}} โ€” {{applicationId}}', + fileName: 'onboarding_status_update.html', + placeholders: ['applicantName', 'applicationId', 'status', 'reason', 'salesCode', 'serviceCode'] }, { templateCode: 'RESIGNATION_SUBMITTED', description: 'Notification for new Resignation submission', subject: 'New Resignation Request: {{resignationId}}', fileName: 'resignation_submitted.html', - placeholders: ['dealerName', 'resignationId', 'lwd', 'link', 'year'] + placeholders: ['dealerName', 'resignationId', 'lwd'] }, { templateCode: 'RESIGNATION_APPROVED', description: 'Notification when Resignation is approved', subject: 'Resignation Request Approved: {{resignationId}}', fileName: 'resignation_approved.html', - placeholders: ['dealerName', 'resignationId', 'lwd', 'year'] + placeholders: ['dealerName', 'resignationId', 'lwd'] }, { templateCode: 'TERMINATION_SCN_ISSUED', description: 'Notification for Show Cause Notice issuance', subject: 'URGENT: Show Cause Notice Issued: {{terminationId}}', fileName: 'termination_scn.html', - placeholders: ['dealerName', 'terminationId', 'deadline', 'link', 'year'] + placeholders: ['dealerName', 'terminationId', 'deadline'] }, { templateCode: 'WORKNOTE_NOTIFICATION', description: 'Notification for new work note or update', subject: 'New Update on Application: {{applicationId}}', fileName: 'worknote_notification.html', - placeholders: ['userName', 'applicationId', 'dealerName', 'message', 'link', 'year'] + placeholders: ['userName', 'applicationId', 'dealerName', 'message'] }, { templateCode: 'GENERIC_NOTIFICATION', description: 'Standard multi-purpose notification', subject: '{{title}}', fileName: 'generic_notification.html', - placeholders: ['title', 'message', 'year'] + placeholders: ['title', 'message'] }, { templateCode: 'QUESTIONNAIRE_REMINDER', description: 'Periodic reminder to complete assessment', subject: 'Reminder: Complete your Dealer Assessment', fileName: 'questionnaire_reminder.html', - placeholders: ['applicantName', 'location', 'link', 'year'] + placeholders: ['applicantName', 'location'] }, { templateCode: 'CONSTITUTIONAL_CHANGE_SUBMITTED', description: 'Notification for new Constitutional Change request', subject: 'New Constitutional Change Request: {{requestId}}', fileName: 'constitutional_change_submitted.html', - placeholders: ['dealerName', 'changeType', 'requestId', 'link', 'year'] + placeholders: ['dealerName', 'changeType', 'requestId'] }, { templateCode: 'RESIGNATION_UPDATE', description: 'General status update for Resignation', subject: 'Resignation Status Update: {{status}}', fileName: 'resignation_update.html', - placeholders: ['dealerName', 'status', 'remarks', 'link', 'year'] + placeholders: ['dealerName', 'status', 'remarks'] }, { templateCode: 'TERMINATION_UPDATE', description: 'General status update for Termination', subject: 'Termination Status Update: {{status}}', fileName: 'termination_update.html', - placeholders: ['dealerName', 'status', 'remarks', 'link', 'year'] + placeholders: ['dealerName', 'status', 'remarks'] }, { templateCode: 'CONSTITUTIONAL_CHANGE_UPDATE', description: 'General status update for Constitutional Change', subject: 'Constitutional Change Update: {{status}}', fileName: 'constitutional_change_update.html', - placeholders: ['dealerName', 'status', 'remarks', 'link', 'year'] + placeholders: ['dealerName', 'status', 'remarks'] + }, + { + templateCode: 'RESIGNATION_RECEIVED', + description: 'Acknowledgement to dealer when resignation request is submitted', + subject: 'We received your resignation โ€” {{resignationId}}', + fileName: 'resignation_received.html', + placeholders: ['dealerName', 'resignationId', 'lwd'] + }, + { + templateCode: 'RELOCATION_RECEIVED', + description: 'Acknowledgement to dealer when relocation request is submitted', + subject: 'Relocation request received โ€” {{requestId}}', + fileName: 'relocation_received.html', + placeholders: ['dealerName', 'requestId'] + }, + { + templateCode: 'RELOCATION_SUBMITTED', + description: 'Internal notify (e.g. ASM) when a dealer submits relocation', + subject: 'New relocation request: {{requestId}}', + fileName: 'relocation_submitted.html', + placeholders: ['dealerName', 'requestId', 'outletCode'] + }, + { + templateCode: 'RELOCATION_UPDATE', + description: 'Dealer-visible status updates during relocation workflow', + subject: 'Relocation update โ€” {{requestId}} ({{status}})', + fileName: 'relocation_update.html', + placeholders: ['dealerName', 'requestId', 'status', 'remarks'] + }, + { + templateCode: 'SLA_BREACH_WARNING', + description: 'Email when an application SLA tracking record breaches deadline', + subject: 'SLA breach: {{applicationId}} โ€” {{stageName}}', + fileName: 'sla_breach_warning.html', + placeholders: ['applicationId', 'stageName', 'currentStage'] } ]; diff --git a/src/services/ConstitutionalWorkflowService.ts b/src/services/ConstitutionalWorkflowService.ts index 3305531..658e0c9 100644 --- a/src/services/ConstitutionalWorkflowService.ts +++ b/src/services/ConstitutionalWorkflowService.ts @@ -1,17 +1,23 @@ import db from '../database/models/index.js'; -import { CONSTITUTIONAL_STAGES, AUDIT_ACTIONS, DOCUMENT_TYPES } from '../common/config/constants.js'; +import { CONSTITUTIONAL_STAGES, AUDIT_ACTIONS, DOCUMENT_TYPES, REQUEST_TYPES } from '../common/config/constants.js'; +import { NotificationService } from './NotificationService.js'; +import { writeWorkflowActivityWorknote } from '../common/utils/workflowWorknote.js'; +import { getOffboardingAuditAction, formatOffboardingAction } from '../common/utils/offboardingWorkflow.utils.js'; +import { mapConstitutionalChangeTypeToDealerProfile } from '../common/utils/constitutionalNormalize.js'; export class ConstitutionalWorkflowService { /** * Transitions a constitutional change request to a new stage */ static async transitionRequest(request: any, targetStage: string, userId: string, options: any = {}) { - const { action, status, remarks, userFullName, auditAction: explicitAuditAction } = options; + const { action, status, remarks, userFullName, metadata = {} } = options; const sourceStage = request.currentStage; const actionLower = String(action || '').toLowerCase(); - const resolvedAuditAction = - explicitAuditAction ?? - (actionLower.includes('reject') ? AUDIT_ACTIONS.REJECTED : AUDIT_ACTIONS.UPDATED); + + // Audit Action resolution + // 3. Create Audit Log using standardized mapper + const { actionType } = metadata; + const resolvedAuditAction = getOffboardingAuditAction(actionType || action || 'Approved', REQUEST_TYPES.CONSTITUTIONAL); const updatedTimeline = [ ...(request.timeline || []), @@ -43,27 +49,153 @@ export class ConstitutionalWorkflowService { updatedAt: new Date() }; + // GAP CLOSURE: Reset joint approval metadata if sent back to or before ZM/RBM review stage + // Controller uses "Sent back to โ€ฆ" (past tense). `sent` does not include substring `send`, so the old check never fired and joint-approval metadata was not cleared on send-back from ZBH. + const isSendBack = + /\b(send|sent)\s*back\b/i.test(String(action || '')) || + (actionLower.includes('send') && actionLower.includes('back')); + const resetTargetStages = [CONSTITUTIONAL_STAGES.ASM_REVIEW, CONSTITUTIONAL_STAGES.ZM_RBM_REVIEW]; + + + if (isSendBack && resetTargetStages.includes(targetStage as any)) { + const metadata = { ...(request.metadata || {}) }; + if (metadata.jointApprovals?.zmRbm) { + console.log(`[ConstitutionalWorkflowService] Resetting joint approval metadata for ${request.requestId} on send-back to ${targetStage}`); + delete metadata.jointApprovals.zmRbm; + updateData.metadata = metadata; + // Explicitly inform Sequelize that metadata has changed + request.changed('metadata', true); + } + } + + await request.update(updateData); + if (targetStage === CONSTITUTIONAL_STAGES.COMPLETED && request.dealerId) { + await ConstitutionalWorkflowService.syncDealerProfileConstitution(request, userId); + } + // Audit Log await db.ConstitutionalAudit.create({ userId, constitutionalChangeId: request.id, - action: resolvedAuditAction, + action: formatOffboardingAction(resolvedAuditAction), remarks: remarks || '', - details: { status: updateData.status, stage: sourceStage, targetStage: targetStage } + details: { status: updateData.status, stage: sourceStage, targetStage: formatOffboardingAction(targetStage) } }); + // 5. Create Worknote for standardized communication trail (always when we have a user โ€” empty modal comments still need a row) + if (userId) { + try { + const body = String(remarks ?? '').trim() || 'No remarks entered.'; + await writeWorkflowActivityWorknote({ + requestId: request.id, + requestType: 'constitutional', + userId: userId, + noteText: `${formatOffboardingAction(action || '')}: ${body}`, + noteType: isSendBack ? 'workflow' : 'internal' + }); + } catch (wnErr) { + console.error('[ConstitutionalWorkflowService] failed to write worknote:', wnErr); + } + } + + const dealerUser = await db.User.findByPk(request.dealerId, { attributes: ['id', 'email', 'fullName'] }); + if (dealerUser?.email) { + const portalBase = process.env.FRONTEND_URL || 'http://localhost:5173'; + const remarkText = String(remarks ?? '').trim() || 'N/A'; + await NotificationService.notify(dealerUser.id, dealerUser.email, { + title: `Constitutional change update: ${targetStage}`, + message: `Your constitutional change request ${request.requestId} has been updated.`, + channels: ['email', 'whatsapp', 'system'], + templateCode: 'CONSTITUTIONAL_CHANGE_UPDATE', + placeholders: { + dealerName: dealerUser.fullName || 'Dealer', + status: targetStage, + remarks: remarkText, + link: `${portalBase}/constitutional-change/${request.id}`, + ctaLabel: 'View request' + } + }).catch((err) => console.error('[ConstitutionalWorkflowService] notify failed:', err)); + } + return request; } + /** + * SRS ยง12.2 โ€” upon final approval, dealer master constitution must reflect the approved change. + * Logs to constitutional audit trail and Work Notes (workflow) โ€” important compliance record. + */ + static async syncDealerProfileConstitution(request: any, actingUserId: string | null): Promise { + const nextConstitution = mapConstitutionalChangeTypeToDealerProfile(request.changeType); + if (!nextConstitution) return; + + try { + const user = await db.User.findByPk(request.dealerId, { + include: [{ model: db.Dealer, as: 'dealerProfile' }] + }); + const dealer = (user as any)?.dealerProfile; + if (!dealer) { + console.warn('[ConstitutionalWorkflowService] Completed CC request but no dealer profile for user', request.dealerId); + return; + } + + const previous = String(dealer.constitutionType || '').trim(); + if (previous === nextConstitution) { + return; + } + + await dealer.update({ constitutionType: nextConstitution }); + + const auditRemarks = `Dealer master constitutionType updated: "${previous}" โ†’ "${nextConstitution}" (dealer profile id: ${dealer.id}). Change type on request: ${request.changeType}.`; + const auditDetails = { + dealerProfileId: dealer.id, + dealerUserId: request.dealerId, + previousConstitution: previous, + newConstitution: nextConstitution, + requestId: request.requestId, + changeType: request.changeType, + stage: CONSTITUTIONAL_STAGES.COMPLETED + }; + + try { + await db.ConstitutionalAudit.create({ + userId: actingUserId || null, + constitutionalChangeId: request.id, + action: formatOffboardingAction(AUDIT_ACTIONS.UPDATED), + remarks: auditRemarks, + details: auditDetails + }); + } catch (auditErr) { + console.error('[ConstitutionalWorkflowService] Dealer sync audit log failed:', auditErr); + } + + const worknoteUserId = actingUserId || String(request.dealerId); + if (worknoteUserId) { + try { + await writeWorkflowActivityWorknote({ + requestId: request.id, + requestType: 'constitutional', + userId: worknoteUserId, + noteText: `[Master data] Constitutional change ${request.requestId} completed: dealer constitution updated from "${previous}" to "${nextConstitution}".`, + noteType: 'workflow' + }); + } catch (wnErr) { + console.error('[ConstitutionalWorkflowService] dealer sync worknote failed:', wnErr); + } + } + } catch (err) { + console.error('[ConstitutionalWorkflowService] Failed to update dealer constitutionType:', err); + } + } + /** * Calculates progress percentage based on stage */ static calculateProgress(stage: string): number { const progress: Record = { - [CONSTITUTIONAL_STAGES.SUBMITTED]: 10, - [CONSTITUTIONAL_STAGES.ASM_REVIEW]: 20, + [CONSTITUTIONAL_STAGES.ASM_REVIEW]: 15, + [CONSTITUTIONAL_STAGES.ZM_RBM_REVIEW]: 30, [CONSTITUTIONAL_STAGES.ZBH_REVIEW]: 45, [CONSTITUTIONAL_STAGES.LEAD_REVIEW]: 60, @@ -71,8 +203,8 @@ export class ConstitutionalWorkflowService { [CONSTITUTIONAL_STAGES.NBH_APPROVAL]: 85, [CONSTITUTIONAL_STAGES.LEGAL_REVIEW]: 95, [CONSTITUTIONAL_STAGES.COMPLETED]: 100, - [CONSTITUTIONAL_STAGES.REJECTED]: 0, - [CONSTITUTIONAL_STAGES.REVOKED]: 0 + [CONSTITUTIONAL_STAGES.REJECTED]: 100, + [CONSTITUTIONAL_STAGES.REVOKED]: 100 }; return progress[stage] || 0; } diff --git a/src/services/NotificationService.ts b/src/services/NotificationService.ts index 0cec647..db88f35 100644 --- a/src/services/NotificationService.ts +++ b/src/services/NotificationService.ts @@ -61,7 +61,7 @@ export class NotificationService { for (const channel of channels) { try { if (channel === 'email' && email) { - await sendEmail(email, title, templateCode || 'generic_notification', { + await sendEmail(email, title, templateCode || 'GENERIC_NOTIFICATION', { ...placeholders, title, message @@ -110,13 +110,25 @@ export class NotificationService { /** * Specific Trigger: Questionnaire Reminder */ - static async sendQuestionnaireReminder(email: string, phone: string, applicantName: string) { + static async sendQuestionnaireReminder( + email: string, + phone: string, + applicantName: string, + options?: { location?: string; link?: string } + ) { + const base = process.env.FRONTEND_URL || 'http://localhost:5173'; await this.notify(null, email, { title: 'Action Required: Complete your Dealership Questionnaire', message: `Hi ${applicantName}, please complete the questionnaire to proceed with your application.`, channels: ['email', 'whatsapp'], templateCode: 'QUESTIONNAIRE_REMINDER', - placeholders: { applicantName, phone } + placeholders: { + applicantName, + phone, + location: options?.location ?? '', + link: options?.link ?? `${base}/login`, + ctaLabel: 'Complete Now' + } }); } } diff --git a/src/services/RelocationWorkflowService.ts b/src/services/RelocationWorkflowService.ts index c57dce7..6c6e6b8 100644 --- a/src/services/RelocationWorkflowService.ts +++ b/src/services/RelocationWorkflowService.ts @@ -1,6 +1,10 @@ import db from '../database/models/index.js'; const { RelocationRequest, AuditLog, User } = db; -import { AUDIT_ACTIONS, RELOCATION_STAGES, ROLES } from '../common/config/constants.js'; +import { AUDIT_ACTIONS, RELOCATION_STAGES, ROLES, REQUEST_TYPES } from '../common/config/constants.js'; +import { getOffboardingAuditAction, formatOffboardingAction } from '../common/utils/offboardingWorkflow.utils.js'; +import { writeWorkflowActivityWorknote } from '../common/utils/workflowWorknote.js'; +import logger from '../common/utils/logger.js'; +import { NotificationService } from './NotificationService.js'; export class RelocationWorkflowService { /** @@ -44,8 +48,9 @@ export class RelocationWorkflowService { const updatedTimeline = [...(request.timeline || []), timelineEntry]; await request.update({ timeline: updatedTimeline }); - // 3. Create Audit Log - let resolvedAuditAction: string = AUDIT_ACTIONS.APPROVED; + // 3. Create Audit Log using standardized mapper + const { actionType } = metadata; + let resolvedAuditAction = getOffboardingAuditAction(actionType || action || 'Approved', REQUEST_TYPES.RELOCATION); if (auditAction) { resolvedAuditAction = auditAction; } else if (action === 'REJECT') { @@ -59,13 +64,51 @@ export class RelocationWorkflowService { await db.RelocationAudit.create({ userId: userId, relocationRequestId: request.id, - action: resolvedAuditAction, + action: formatOffboardingAction(resolvedAuditAction), remarks: reason || '', details: { status: targetStatus, stage: sourceStage, targetStage: stage || targetStatus } }); + + // 4. Create Worknote for standardized communication trail + if (reason && userId) { + try { + // Prepend action to remarks for better context in Work Notes + const actionPrefix = action ? `${formatOffboardingAction(action)}: ` : ''; + await writeWorkflowActivityWorknote({ + requestId: request.id, + requestType: 'relocation', + userId: userId, + noteText: `${actionPrefix}${reason}`, + noteType: (action === 'SEND_BACK' || action === 'SEND_BACK_TO_ASM') ? 'workflow' : 'internal' + }); + } catch (wnErr) { + logger.error('[RelocationWorkflowService] failed to write worknote:', wnErr); + } + } console.log(`[RelocationWorkflowService] Transitioned Request ${request.requestId} to ${targetStatus}`); - + + await request.reload(); + const dealerUser = await User.findByPk(request.dealerId, { attributes: ['id', 'email', 'fullName'] }); + if (dealerUser?.email) { + const portalBase = process.env.FRONTEND_URL || 'http://localhost:5173'; + const stageLabel = request.currentStage || request.status || targetStatus; + await NotificationService.notify(dealerUser.id, dealerUser.email, { + title: `Relocation update: ${request.requestId}`, + message: `Your relocation request status changed โ€” ${stageLabel}.`, + channels: ['email', 'whatsapp', 'system'], + templateCode: 'RELOCATION_UPDATE', + placeholders: { + dealerName: dealerUser.fullName || 'Dealer', + requestId: request.requestId, + status: stageLabel, + remarks: reason || 'N/A', + link: `${portalBase}/relocation-requests/${request.id}`, + ctaLabel: 'View request' + } + }).catch((err) => logger.error('[RelocationWorkflowService] email notify failed:', err)); + } + return request; } diff --git a/src/services/ResignationWorkflowService.ts b/src/services/ResignationWorkflowService.ts index c3904f7..90ddcb4 100644 --- a/src/services/ResignationWorkflowService.ts +++ b/src/services/ResignationWorkflowService.ts @@ -1,10 +1,12 @@ import db from '../database/models/index.js'; -const { AuditLog, User } = db; -import { AUDIT_ACTIONS, RESIGNATION_STAGES, ROLES } from '../common/config/constants.js'; +const { User } = db; +import { RESIGNATION_STAGES, ROLES, REQUEST_TYPES } from '../common/config/constants.js'; import { getResignationStatusForStage } from '../common/utils/offboardingStatus.js'; import { NotificationService } from './NotificationService.js'; import { Op } from 'sequelize'; import logger from '../common/utils/logger.js'; +import { writeWorkflowActivityWorknote } from '../common/utils/workflowWorknote.js'; +import { getOffboardingAuditAction, formatOffboardingAction } from '../common/utils/offboardingWorkflow.utils.js'; export class ResignationWorkflowService { @@ -22,8 +24,10 @@ export class ResignationWorkflowService { updatedAt: new Date() }; - // 2. Update Timeline (JSON array) & Resignation Record + // 1. Resolve Actor const actor = userId ? await User.findByPk(userId) : null; + + // 2. Update Timeline (JSON array) & Resignation Record const timelineEntry = { stage: sourceStage, // Correctly Associate remark with the stage where action happened targetStage: targetStage, // Store target for reference @@ -40,20 +44,36 @@ export class ResignationWorkflowService { timeline: updatedTimeline }, transaction ? { transaction } : undefined); - // 3. Create Audit Log - let auditAction: any = AUDIT_ACTIONS.APPROVED; - if (action === 'REJECT' || action === 'Rejected') auditAction = AUDIT_ACTIONS.REJECTED; - if (action === 'WITHDRAW' || action === 'Withdrawn') auditAction = AUDIT_ACTIONS.UPDATED; - if (action === 'SENT_BACK' || action === 'Sent Back') auditAction = AUDIT_ACTIONS.UPDATED; + // 3. Create Audit Log using standardized mapper + const { actionType } = metadata; + const auditAction = getOffboardingAuditAction(actionType || action || 'Approved', REQUEST_TYPES.RESIGNATION); await db.ResignationAudit.create({ userId: userId, resignationId: resignation.id, - action: auditAction, + action: formatOffboardingAction(auditAction), remarks: remarks || '', - details: { status: updateData.status, stage: sourceStage, targetStage: targetStage } + details: { status: updateData.status, stage: sourceStage, targetStage: formatOffboardingAction(targetStage) } }, transaction ? { transaction } : undefined); + // 4. Create Worknote for standardized communication trail + if (remarks && userId) { + try { + // Prepend action to remarks for better context in Work Notes + const actionPrefix = action ? `${formatOffboardingAction(action)}: ` : ''; + await writeWorkflowActivityWorknote({ + requestId: resignation.id, + requestType: 'resignation', + userId: userId, + noteText: `${actionPrefix}${remarks}`, + noteType: (action && action.toLowerCase().includes('send back')) ? 'workflow' : 'internal' + }); + } catch (wnErr) { + logger.error('[ResignationWorkflowService] failed to write worknote:', wnErr); + } + } + + console.log(`[ResignationWorkflowService] Transitioned Resignation ${resignation.resignationId} to ${targetStage}`); // 5. Send Notifications @@ -67,6 +87,8 @@ export class ResignationWorkflowService { }); if (user) { + const portalBase = process.env.FRONTEND_URL || 'http://localhost:5173'; + await NotificationService.notify(user.id, user.email, { title: `Resignation Update: ${targetStage}`, message: `Your resignation request status has been updated to ${targetStage}. ${remarks ? 'Remarks: ' + remarks : ''}`, @@ -75,7 +97,9 @@ export class ResignationWorkflowService { placeholders: { status: targetStage, dealerName: user.fullName || 'Dealer', - remarks: remarks || 'N/A' + remarks: remarks || 'N/A', + link: `${portalBase}/dealer-resignation/${resignation.id}`, + ctaLabel: 'View request', } }); @@ -108,7 +132,7 @@ export class ResignationWorkflowService { [RESIGNATION_STAGES.LEGAL]: 85, [RESIGNATION_STAGES.FNF_INITIATED]: 95, [RESIGNATION_STAGES.COMPLETED]: 100, - [RESIGNATION_STAGES.REJECTED]: 0 + [RESIGNATION_STAGES.REJECTED]: 100 }; return progress[stage] || 0; } diff --git a/src/services/SLAService.ts b/src/services/SLAService.ts index 2bad0e2..277b71a 100644 --- a/src/services/SLAService.ts +++ b/src/services/SLAService.ts @@ -67,10 +67,19 @@ export class SLAService { }); if (application?.assignedToUser) { + const portalBase = process.env.FRONTEND_URL || 'http://localhost:5173'; await NotificationService.notify(application.assignedTo, application.assignedToUser.email, { title: `SLA BREACH: ${track.stageName}`, message: `The application ${application.applicationId} has breached the SLA for ${track.stageName}. Current stage: ${application.currentStage}.`, channels: ['email', 'system'], + templateCode: 'SLA_BREACH_WARNING', + placeholders: { + applicationId: application.applicationId || String(application.id), + stageName: track.stageName, + currentStage: application.currentStage || '', + link: `${portalBase}/applications/${application.id}`, + ctaLabel: 'Open application' + }, metadata: { type: 'error', applicationId: application.id } }); } diff --git a/src/services/TerminationWorkflowService.ts b/src/services/TerminationWorkflowService.ts index 6ef774e..1ec676c 100644 --- a/src/services/TerminationWorkflowService.ts +++ b/src/services/TerminationWorkflowService.ts @@ -1,12 +1,14 @@ import db from '../database/models/index.js'; import { Op } from 'sequelize'; -const { AuditLog, User, TerminationScnResponse, TerminationHearingRecord, Dealer, FnF, FnFLineItem, FffClearance } = db; -import { AUDIT_ACTIONS, TERMINATION_STAGES, ROLES, FNF_DEPARTMENTS } from '../common/config/constants.js'; +const { User, TerminationScnResponse, TerminationHearingRecord, Dealer, FnF, FffClearance } = db; +import { TERMINATION_STAGES, ROLES, FNF_DEPARTMENTS, REQUEST_TYPES } from '../common/config/constants.js'; import { getTerminationStatusForStage } from '../common/utils/offboardingStatus.js'; import { NotificationService } from './NotificationService.js'; import ExternalMocksService from '../common/utils/externalMocks.service.js'; import logger from '../common/utils/logger.js'; import { NomenclatureService } from '../common/utils/nomenclature.js'; +import { writeWorkflowActivityWorknote } from '../common/utils/workflowWorknote.js'; +import { getOffboardingAuditAction, formatOffboardingAction } from '../common/utils/offboardingWorkflow.utils.js'; export class TerminationWorkflowService { /** @@ -44,19 +46,35 @@ export class TerminationWorkflowService { timeline: updatedTimeline }); - // 4. Create Audit Log - let auditAction: any = AUDIT_ACTIONS.APPROVED; - if (action === 'REJECT' || action === 'Rejected') auditAction = AUDIT_ACTIONS.REJECTED; - if (action === 'SCN_SUBMITTED' || action === 'Hearing Recorded') auditAction = AUDIT_ACTIONS.UPDATED; + // 4. Create Audit Log using standardized mapper + const { actionType } = metadata; + const auditAction = getOffboardingAuditAction(actionType || action || 'Approved', REQUEST_TYPES.TERMINATION); await db.TerminationAudit.create({ userId: userId, terminationRequestId: termination.id, - action: auditAction, + action: formatOffboardingAction(auditAction), remarks: remarks || '', - details: { status: updateData.status, stage: sourceStage, targetStage: targetStage } + details: { status: updateData.status, stage: sourceStage, targetStage: formatOffboardingAction(targetStage) } }); + // 5. Create Worknote for standardized communication trail + if (remarks && userId) { + try { + // Prepend action to remarks for better context in Work Notes + const actionPrefix = action ? `${formatOffboardingAction(action)}: ` : ''; + await writeWorkflowActivityWorknote({ + requestId: termination.id, + requestType: 'termination', + userId: userId, + noteText: `${actionPrefix}${remarks}`, + noteType: (action && action.toLowerCase().includes('send back')) ? 'workflow' : 'internal' + }); + } catch (wnErr) { + logger.error('[TerminationWorkflowService] failed to write worknote:', wnErr); + } + } + // 4. Send Notifications const user = await User.findOne({ where: { @@ -68,17 +86,56 @@ export class TerminationWorkflowService { }); if (user) { - await NotificationService.notify(user.id, user.email, { - title: `Termination Status Update: ${targetStage}`, - message: `Your dealership termination request status has been updated to ${targetStage}. ${remarks ? 'Remarks: ' + remarks : ''}`, - channels: ['email', 'whatsapp', 'system'], - templateCode: 'TERMINATION_UPDATE', - placeholders: { - status: targetStage, - dealerName: user.fullName || 'Dealer', - remarks: remarks || 'N/A' + const portalBase = process.env.FRONTEND_URL || 'http://localhost:5173'; + const dealerPortalLink = `${portalBase}/termination/${termination.id}`; + const isScnIssued = targetStage === TERMINATION_STAGES.SCN_ISSUED; + + const deadlineRaw = metadata.scnDeadline ?? metadata.deadline ?? termination.proposedLwd; + let deadlineStr = ''; + try { + if (deadlineRaw instanceof Date) { + deadlineStr = deadlineRaw.toLocaleDateString('en-IN', { dateStyle: 'medium' }); + } else if (deadlineRaw) { + deadlineStr = String(deadlineRaw); + } else { + deadlineStr = new Date(Date.now() + 7 * 86400000).toLocaleDateString('en-IN', { + dateStyle: 'medium' + }); } - }); + } catch { + deadlineStr = String(deadlineRaw || ''); + } + + if (isScnIssued) { + await NotificationService.notify(user.id, user.email, { + title: `URGENT: Show Cause Notice issued โ€” ${termination.requestId}`, + message: `A Show Cause Notice has been issued for your termination request.`, + channels: ['email', 'whatsapp', 'system'], + templateCode: 'TERMINATION_SCN_ISSUED', + placeholders: { + dealerName: user.fullName || 'Dealer', + terminationId: termination.requestId, + deadline: deadlineStr, + link: dealerPortalLink, + remarks: remarks || '', + ctaLabel: 'Submit response' + } + }); + } else { + await NotificationService.notify(user.id, user.email, { + title: `Termination Status Update: ${targetStage}`, + message: `Your dealership termination request status has been updated to ${targetStage}. ${remarks ? 'Remarks: ' + remarks : ''}`, + channels: ['email', 'whatsapp', 'system'], + templateCode: 'TERMINATION_UPDATE', + placeholders: { + status: targetStage, + dealerName: user.fullName || 'Dealer', + remarks: remarks || 'N/A', + link: dealerPortalLink, + ctaLabel: 'View details' + } + }); + } // 5. Deactivate User Account on final completion stages (SRS 1.1.5 / 2.3.5) // We deactivate at Legal Letter stage to ensure access is revoked as soon as the formal letter is issued @@ -111,26 +168,18 @@ export class TerminationWorkflowService { const dealerProfile = await Dealer.findByPk(termination.dealerId); if (!dealerProfile) throw new Error('Dealer record not found for termination'); - const sapDues = await ExternalMocksService.mockGetFinancialDuesFromSap(dealerProfile.dealerCode); - - // 2. Create FnF Settlement Record with direct IDs and readable identifier + // 2. Create FnF with zero totals โ€” line items only from explicit clearance / finance entry (no mock SAP seed rows). const fnf = await FnF.create({ settlementId: NomenclatureService.generateFnFId(), terminationRequestId: termination.id, dealerId: termination.dealerId, outletId: primaryOutlet?.id || null, status: 'Initiated', - totalReceivables: sapDues.data.outstandingInvoices, - totalPayables: sapDues.data.securityDeposit, - netAmount: sapDues.data.securityDeposit - sapDues.data.outstandingInvoices + totalReceivables: 0, + totalPayables: 0, + netAmount: 0 }, { transaction }); - // 2. Initialize Line Items with SAP values - await FnFLineItem.bulkCreate([ - { fnfId: fnf.id, itemType: 'Receivable', description: 'Outstanding Invoices from SAP', department: 'Finance', amount: sapDues.data.outstandingInvoices, addedBy: userId }, - { fnfId: fnf.id, itemType: 'Payable', description: 'Security Deposit from SAP', department: 'Finance', amount: sapDues.data.securityDeposit, addedBy: userId } - ], { transaction }); - // 3. Initialize CLEARANCE JSON Structure in TerminationRequest (Matching Resignation module) const initialClearances: Record = {}; FNF_DEPARTMENTS.forEach(dept => { @@ -175,7 +224,7 @@ export class TerminationWorkflowService { [TERMINATION_STAGES.CEO_APPROVAL]: 90, [TERMINATION_STAGES.LEGAL_LETTER]: 95, [TERMINATION_STAGES.TERMINATED]: 100, - [TERMINATION_STAGES.REJECTED]: 0 + [TERMINATION_STAGES.REJECTED]: 100 }; return progress[stage] || 0; } diff --git a/src/services/WorkflowService.ts b/src/services/WorkflowService.ts index b5c00b2..3ba29ea 100644 --- a/src/services/WorkflowService.ts +++ b/src/services/WorkflowService.ts @@ -121,6 +121,12 @@ export class WorkflowService { if (targetStatus === 'LOA Issued') templateCode = 'LOA_ISSUED'; if (targetStatus === 'Dealer Code Generated') templateCode = 'DEALER_CODE_READY'; + const portalBase = process.env.FRONTEND_URL || 'http://localhost:5173'; + + let ctaLabel = 'View application'; + if (templateCode === 'LOI_ISSUED') ctaLabel = 'View LOI'; + else if (templateCode === 'LOA_ISSUED') ctaLabel = 'View LOA'; + await NotificationService.notify(targetUserId, application.email, { title: `Onboarding Update: ${targetStatus}`, message: `Hi ${application.applicantName}, your application status has been updated to ${targetStatus}. ${reason || ''}`, @@ -133,6 +139,8 @@ export class WorkflowService { reason: reason || 'N/A', salesCode: application.dealerCode?.salesCode || 'N/A', serviceCode: application.dealerCode?.serviceCode || 'N/A', + link: `${portalBase}/applications/${application.id}`, + ctaLabel, }, }); } catch (notifyErr) { diff --git a/trigger-resignation.js b/trigger-resignation.js index e081118..54a2f2b 100644 --- a/trigger-resignation.js +++ b/trigger-resignation.js @@ -174,7 +174,10 @@ async function run() { await delay(); } - // --- NEW: F&F CLEARANCE LOOP (16 DEPARTMENTS) --- + // --- F&F CLEARANCE LOOP (must match backend FNF_DEPARTMENTS + resignation updateClearance) --- + // F&F is created with no default line items; this loop is what creates DepartmentClaim / receivable & payable rows. + // Sends JSON PUT (no file). Backend uses uploadSingleIfMultipart so express.json body is preserved. + // type: 'Receivable' | 'Payable' (backend normalizes legacy 'Recovery' to Receivable). if (!SHOULD_SKIP_CLEARANCES) { console.log('[STEP 9] Starting 16-Department F&F Clearance Flow...'); } @@ -193,22 +196,22 @@ async function run() { const departments = [ - { name: 'Warranty Department', status: 'Cleared', amount: 0, type: 'Recovery', remarks: 'No pending claims.' }, - { name: 'Accessories Department', status: 'Cleared', amount: 0, type: 'Recovery', remarks: 'Stock returned and verified.' }, - { name: 'Sales Department', status: 'Cleared', amount: 0, type: 'Recovery', remarks: 'Allocations transferred.' }, - { name: 'RTO Department', status: 'Dues', amount: 1500, type: 'Recovery', remarks: 'Pending RTO tax recovery.' }, - { name: 'Service Department', status: 'Cleared', amount: 0, type: 'Recovery', remarks: 'Service tools handed over.' }, + { name: 'Warranty Department', status: 'Cleared', amount: 0, type: 'Receivable', remarks: 'No pending claims.' }, + { name: 'Accessories Department', status: 'Cleared', amount: 0, type: 'Receivable', remarks: 'Stock returned and verified.' }, + { name: 'Sales Department', status: 'Cleared', amount: 0, type: 'Receivable', remarks: 'Allocations transferred.' }, + { name: 'RTO Department', status: 'Dues', amount: 1500, type: 'Receivable', remarks: 'Pending RTO tax recovery.' }, + { name: 'Service Department', status: 'Cleared', amount: 0, type: 'Receivable', remarks: 'Service tools handed over.' }, { name: 'Parts Department', status: 'Dues', amount: 45000, type: 'Payable', remarks: 'Parts credit note adjustment.' }, - { name: 'Finance Department', status: 'Dues', amount: 25000, type: 'Recovery', remarks: 'Short-term loan interest.' }, - { name: 'Insurance Department', status: 'Cleared', amount: 0, type: 'Recovery', remarks: 'Policy renewals handled.' }, - { name: 'Inventory Department', status: 'Cleared', amount: 0, type: 'Recovery', remarks: 'Physical inventory reconciled.' }, - { name: 'Marketing Department', status: 'Dues', amount: 5000, type: 'Recovery', remarks: 'Glow-sign board removal cost.' }, - { name: 'HR Department', status: 'Cleared', amount: 0, type: 'Recovery', remarks: 'Dealer staff settlement verified.' }, - { name: 'IT Department', status: 'Dues', amount: 12000, type: 'Recovery', remarks: 'Laptop / DMS hardware dues.' }, - { name: 'Legal Department', status: 'Cleared', amount: 0, type: 'Recovery', remarks: 'Legal NOC issued.' }, - { name: 'Quality Department', status: 'Cleared', amount: 0, type: 'Recovery', remarks: 'Quality audit passed.' }, - { name: 'Logistics Department', status: 'Cleared', amount: 0, type: 'Recovery', remarks: 'Last vehicle transit clear.' }, - { name: 'Customer Relations Department', status: 'Cleared', amount: 0, type: 'Recovery', remarks: 'Customer complaints resolved.' } + { name: 'Finance Department', status: 'Dues', amount: 25000, type: 'Receivable', remarks: 'Short-term loan interest.' }, + { name: 'Insurance Department', status: 'Cleared', amount: 0, type: 'Receivable', remarks: 'Policy renewals handled.' }, + { name: 'Inventory Department', status: 'Cleared', amount: 0, type: 'Receivable', remarks: 'Physical inventory reconciled.' }, + { name: 'Marketing Department', status: 'Dues', amount: 5000, type: 'Receivable', remarks: 'Glow-sign board removal cost.' }, + { name: 'HR Department', status: 'Cleared', amount: 0, type: 'Receivable', remarks: 'Dealer staff settlement verified.' }, + { name: 'IT Department', status: 'Dues', amount: 12000, type: 'Receivable', remarks: 'Laptop / DMS hardware dues.' }, + { name: 'Legal Department', status: 'Cleared', amount: 0, type: 'Receivable', remarks: 'Legal NOC issued.' }, + { name: 'Quality Department', status: 'Cleared', amount: 0, type: 'Receivable', remarks: 'Quality audit passed.' }, + { name: 'Logistics Department', status: 'Cleared', amount: 0, type: 'Receivable', remarks: 'Last vehicle transit clear.' }, + { name: 'Customer Relations Department', status: 'Cleared', amount: 0, type: 'Receivable', remarks: 'Customer complaints resolved.' } ]; if (!SHOULD_SKIP_CLEARANCES) { diff --git a/trigger-termination.js b/trigger-termination.js index 6afd4f3..162bf8e 100644 --- a/trigger-termination.js +++ b/trigger-termination.js @@ -142,22 +142,22 @@ async function run() { log('SKIP', 'FnF Settlement not initialized for this termination case.'); } else if (!SHOULD_SKIP_CLEARANCES) { const departments = [ - { name: 'Warranty Department', status: 'Cleared', amount: 0, type: 'Recovery', remarks: 'No pending claims.' }, - { name: 'Accessories Department', status: 'Dues', amount: 15000, type: 'Recovery', remarks: 'Shortage in accessory stock.' }, - { name: 'Sales Department', status: 'Cleared', amount: 0, type: 'Recovery', remarks: 'Allocations transferred.' }, - { name: 'RTO Department', status: 'Cleared', amount: 0, type: 'Recovery', remarks: 'No dues.' }, - { name: 'Service Department', status: 'Dues', amount: 8000, type: 'Recovery', remarks: 'Loaner vehicle damange charges.' }, + { name: 'Warranty Department', status: 'Cleared', amount: 0, type: 'Receivable', remarks: 'No pending claims.' }, + { name: 'Accessories Department', status: 'Dues', amount: 15000, type: 'Receivable', remarks: 'Shortage in accessory stock.' }, + { name: 'Sales Department', status: 'Cleared', amount: 0, type: 'Receivable', remarks: 'Allocations transferred.' }, + { name: 'RTO Department', status: 'Cleared', amount: 0, type: 'Receivable', remarks: 'No dues.' }, + { name: 'Service Department', status: 'Dues', amount: 8000, type: 'Receivable', remarks: 'Loaner vehicle damange charges.' }, { name: 'Parts Department', status: 'Dues', amount: 20000, type: 'Payable', remarks: 'Return parts credit.' }, - { name: 'Finance Department', status: 'Cleared', amount: 0, type: 'Recovery', remarks: 'No interest dues.' }, - { name: 'Insurance Department', status: 'Cleared', amount: 0, type: 'Recovery', remarks: 'No dues.' }, - { name: 'Inventory Department', status: 'Cleared', amount: 0, type: 'Recovery', remarks: 'Inventory handed over.' }, - { name: 'Marketing Department', status: 'Cleared', amount: 0, type: 'Recovery', remarks: 'No dues.' }, - { name: 'HR Department', status: 'Cleared', amount: 0, type: 'Recovery', remarks: 'Staff settlement clear.' }, - { name: 'IT Department', status: 'Cleared', amount: 0, type: 'Recovery', remarks: 'Hardware recovered.' }, - { name: 'Legal Department', status: 'Dues', amount: 50000, type: 'Recovery', remarks: 'Litigation cost recovery as per agreement.' }, - { name: 'Quality Department', status: 'Cleared', amount: 0, type: 'Recovery', remarks: 'No dues.' }, - { name: 'Logistics Department', status: 'Cleared', amount: 0, type: 'Recovery', remarks: 'No dues.' }, - { name: 'Customer Relations Department', status: 'Cleared', amount: 0, type: 'Recovery', remarks: 'No dues.' } + { name: 'Finance Department', status: 'Cleared', amount: 0, type: 'Receivable', remarks: 'No interest dues.' }, + { name: 'Insurance Department', status: 'Cleared', amount: 0, type: 'Receivable', remarks: 'No dues.' }, + { name: 'Inventory Department', status: 'Cleared', amount: 0, type: 'Receivable', remarks: 'Inventory handed over.' }, + { name: 'Marketing Department', status: 'Cleared', amount: 0, type: 'Receivable', remarks: 'No dues.' }, + { name: 'HR Department', status: 'Cleared', amount: 0, type: 'Receivable', remarks: 'Staff settlement clear.' }, + { name: 'IT Department', status: 'Cleared', amount: 0, type: 'Receivable', remarks: 'Hardware recovered.' }, + { name: 'Legal Department', status: 'Dues', amount: 50000, type: 'Receivable', remarks: 'Litigation cost recovery as per agreement.' }, + { name: 'Quality Department', status: 'Cleared', amount: 0, type: 'Receivable', remarks: 'No dues.' }, + { name: 'Logistics Department', status: 'Cleared', amount: 0, type: 'Receivable', remarks: 'No dues.' }, + { name: 'Customer Relations Department', status: 'Cleared', amount: 0, type: 'Receivable', remarks: 'No dues.' } ]; for (const dept of departments) { diff --git a/trigger-workflow.js b/trigger-workflow.js index 528e241..11a529b 100644 --- a/trigger-workflow.js +++ b/trigger-workflow.js @@ -123,7 +123,7 @@ async function prospectLogin(phone) { async function mockUploadDocument(appId, token, docType) { const formData = new FormData(); - const fileBuffer = fs.readFileSync('/home/laxman-h/Pictures/Screenshots/Screenshot from 2026-03-26 10-08-00.png'); + const fileBuffer = fs.readFileSync('C:/Users/BACKPACKERS/Pictures/claim_document_type.PNG'); const blob = new Blob([fileBuffer], { type: 'image/png' }); formData.append('file', blob, 'screenshot.png'); formData.append('documentType', docType);