From 4fa1898824264535e6e1f942ee7c715f1b55a5fe Mon Sep 17 00:00:00 2001 From: laxmanhalaki Date: Sun, 19 Apr 2026 23:21:12 +0530 Subject: [PATCH] major code refactotoring is done all modules coverd also added the email 20+ email tamplates with rich text editor edit code is almost stable --- package-lock.json | 208 +++++++++++++++++- package.json | 2 + reset_db.ts | 10 +- scratch/check-models.ts | 31 +++ scripts/reset_db_stable.ts | 2 + scripts/seed-users.ts | 4 +- scripts/seed_normalized_data.ts | 2 + scripts/verify-standardized-offboarding.ts | 56 +++++ src/common/config/constants.ts | 18 ++ src/common/middleware/upload.ts | 13 ++ src/common/utils/constitutionalNormalize.ts | 29 +++ src/common/utils/email-template-sanitize.ts | 90 ++++++++ src/common/utils/email.service.ts | 51 ++--- src/common/utils/externalMocks.service.ts | 4 +- src/common/utils/handlebars-email.ts | 107 +++++++++ src/common/utils/offboardingWorkflow.utils.ts | 168 ++++++++++++++ src/common/utils/requestResolver.ts | 20 +- .../utils/workflow-email-notifications.ts | 146 ++++++++++++ src/constants/allowed-email-template-codes.ts | 38 ++++ .../admin/EmailTemplateController.ts | 69 +++++- .../models/{ => activity}/AuditLog.ts | 2 +- .../{ => activity}/WorkNoteAttachment.ts | 0 .../models/{ => activity}/WorkNoteTag.ts | 0 .../models/{ => activity}/Worknote.ts | 0 .../models/{ => application}/Application.ts | 2 +- .../{ => application}/ApplicationProgress.ts | 0 .../ApplicationStatusHistory.ts | 0 .../{ => application}/DocumentVersion.ts | 0 .../{ => application}/OnboardingDocument.ts | 2 +- .../models/{ => application}/Opportunity.ts | 0 .../{ => approval}/LoaAcknowledgement.ts | 0 .../models/{ => approval}/LoaApproval.ts | 0 .../{ => approval}/LoaDocumentGenerated.ts | 0 .../models/{ => approval}/LoaRequest.ts | 0 .../{ => approval}/LoiAcknowledgement.ts | 0 .../models/{ => approval}/LoiApproval.ts | 0 .../{ => approval}/LoiDocumentGenerated.ts | 0 .../models/{ => approval}/LoiRequest.ts | 0 .../models/{ => approval}/SecurityDeposit.ts | 0 .../{ => compliance}/DocumentStageConfig.ts | 0 .../models/{ => compliance}/EorChecklist.ts | 0 .../{ => compliance}/EorChecklistItem.ts | 0 .../{ => compliance}/RequestParticipant.ts | 0 .../models/{ => compliance}/SLABreach.ts | 0 .../{ => compliance}/SLAConfiguration.ts | 0 .../{ => compliance}/SLAEscalationConfig.ts | 0 .../models/{ => compliance}/SLAReminder.ts | 0 .../models/{ => compliance}/SLATracking.ts | 0 .../{ => compliance}/StageApprovalAction.ts | 0 .../{ => compliance}/StageApprovalPolicy.ts | 0 .../{ => compliance}/WorkflowStageConfig.ts | 0 src/database/models/{ => core}/District.ts | 0 .../models/{ => core}/EmailTemplate.ts | 0 src/database/models/{ => core}/Location.ts | 0 .../models/{ => core}/LocationHierarchy.ts | 0 .../models/{ => core}/Notification.ts | 0 src/database/models/{ => core}/Permission.ts | 0 .../models/{ => core}/PushSubscription.ts | 0 src/database/models/{ => core}/Region.ts | 0 src/database/models/{ => core}/Role.ts | 0 .../models/{ => core}/RolePermission.ts | 0 src/database/models/{ => core}/State.ts | 0 .../models/{ => core}/SystemConfiguration.ts | 0 src/database/models/{ => core}/User.ts | 2 +- src/database/models/{ => core}/UserRole.ts | 0 src/database/models/{ => core}/Zone.ts | 0 src/database/models/{ => dealer}/Dealer.ts | 0 .../models/{ => dealer}/DealerBankDetail.ts | 0 .../models/{ => dealer}/DealerCode.ts | 0 src/database/models/{ => dealer}/Outlet.ts | 2 +- .../models/{ => financial}/FffClearance.ts | 0 .../models/{ => financial}/FinancePayment.ts | 2 +- src/database/models/{ => financial}/FnF.ts | 2 +- .../models/{ => financial}/FnFAudit.ts | 0 .../models/{ => financial}/FnFLineItem.ts | 0 src/database/models/index.ts | 182 +++++++-------- .../{ => offboarding/common}/ExitFeedback.ts | 0 .../constitutional}/ConstitutionalAudit.ts | 0 .../constitutional}/ConstitutionalChange.ts | 5 +- .../constitutional}/ConstitutionalDocument.ts | 0 .../relocation}/RelocationAudit.ts | 0 .../relocation}/RelocationDocument.ts | 0 .../relocation}/RelocationRequest.ts | 2 +- .../resignation}/Resignation.ts | 5 +- .../resignation}/ResignationAudit.ts | 0 .../resignation}/ResignationDocument.ts | 0 .../termination}/TerminationAudit.ts | 0 .../termination}/TerminationDocument.ts | 0 .../termination}/TerminationHearingRecord.ts | 0 .../termination}/TerminationRequest.ts | 2 +- .../termination}/TerminationScnResponse.ts | 0 .../models/{ => verification}/AiSummary.ts | 0 .../{ => verification}/FddAssignment.ts | 0 .../models/{ => verification}/FddReport.ts | 0 .../models/{ => verification}/Interview.ts | 0 .../{ => verification}/InterviewEvaluation.ts | 0 .../{ => verification}/InterviewFeedback.ts | 0 .../InterviewParticipant.ts | 0 .../{ => verification}/KTMatrixScore.ts | 0 .../{ => verification}/Questionnaire.ts | 0 .../{ => verification}/QuestionnaireOption.ts | 0 .../QuestionnaireQuestion.ts | 0 .../QuestionnaireResponse.ts | 0 .../{ => verification}/QuestionnaireScore.ts | 0 src/emailtemplates/Re_Logo.png | Bin 0 -> 30239 bytes src/emailtemplates/applicant_shortlisted.html | 42 +--- .../constitutional_change_submitted.html | 37 +--- .../constitutional_change_update.html | 37 +--- src/emailtemplates/dealer_code_ready.html | 41 +--- src/emailtemplates/generic_notification.html | 30 +-- src/emailtemplates/interview_scheduled.html | 43 +--- src/emailtemplates/loa_issued.html | 37 +--- src/emailtemplates/loi_issued.html | 35 +-- src/emailtemplates/non_opportunity.html | 34 +-- .../onboarding_status_update.html | 9 + src/emailtemplates/opportunity.html | 40 +--- src/emailtemplates/partials/email_footer.html | 8 + src/emailtemplates/partials/email_header.html | 22 ++ src/emailtemplates/partials/primary_cta.html | 5 + .../questionnaire_reminder.html | 37 +--- .../questionnaire_submitted.html | 32 +-- src/emailtemplates/relocation_received.html | 6 + src/emailtemplates/relocation_submitted.html | 7 + src/emailtemplates/relocation_update.html | 7 + src/emailtemplates/resignation_approved.html | 34 +-- src/emailtemplates/resignation_received.html | 7 + src/emailtemplates/resignation_submitted.html | 37 +--- src/emailtemplates/resignation_update.html | 39 +--- src/emailtemplates/sla_breach_warning.html | 9 + src/emailtemplates/termination_scn.html | 38 +--- src/emailtemplates/termination_update.html | 39 +--- src/emailtemplates/user_assigned.html | 32 +-- src/emailtemplates/worknote_notification.html | 35 +-- .../assessment/assessment.controller.ts | 13 ++ src/modules/audit/audit.controller.ts | 6 +- .../collaboration/collaboration.controller.ts | 73 +++++- .../onboarding/questionnaire.controller.ts | 16 ++ .../self-service/constitutional.controller.ts | 155 ++++++------- .../self-service/relocation.controller.ts | 169 ++++++++++++-- .../self-service/resignation.controller.ts | 180 ++++++++------- .../self-service/resignation.routes.ts | 4 +- src/modules/settlement/settlement.routes.ts | 4 +- .../termination/termination.controller.ts | 86 +++++--- src/scripts/seed-master-emails.ts | 90 ++++++-- src/services/ConstitutionalWorkflowService.ts | 154 ++++++++++++- src/services/NotificationService.ts | 18 +- src/services/RelocationWorkflowService.ts | 53 ++++- src/services/ResignationWorkflowService.ts | 48 +++- src/services/SLAService.ts | 9 + src/services/TerminationWorkflowService.ts | 111 +++++++--- src/services/WorkflowService.ts | 8 + trigger-resignation.js | 35 +-- trigger-termination.js | 30 +-- trigger-workflow.js | 2 +- 154 files changed, 2275 insertions(+), 1046 deletions(-) create mode 100644 scratch/check-models.ts create mode 100644 scripts/verify-standardized-offboarding.ts create mode 100644 src/common/utils/email-template-sanitize.ts create mode 100644 src/common/utils/handlebars-email.ts create mode 100644 src/common/utils/offboardingWorkflow.utils.ts create mode 100644 src/common/utils/workflow-email-notifications.ts create mode 100644 src/constants/allowed-email-template-codes.ts rename src/database/models/{ => activity}/AuditLog.ts (96%) rename src/database/models/{ => activity}/WorkNoteAttachment.ts (100%) rename src/database/models/{ => activity}/WorkNoteTag.ts (100%) rename src/database/models/{ => activity}/Worknote.ts (100%) rename src/database/models/{ => application}/Application.ts (99%) rename src/database/models/{ => application}/ApplicationProgress.ts (100%) rename src/database/models/{ => application}/ApplicationStatusHistory.ts (100%) rename src/database/models/{ => application}/DocumentVersion.ts (100%) rename src/database/models/{ => application}/OnboardingDocument.ts (97%) rename src/database/models/{ => application}/Opportunity.ts (100%) rename src/database/models/{ => approval}/LoaAcknowledgement.ts (100%) rename src/database/models/{ => approval}/LoaApproval.ts (100%) rename src/database/models/{ => approval}/LoaDocumentGenerated.ts (100%) rename src/database/models/{ => approval}/LoaRequest.ts (100%) rename src/database/models/{ => approval}/LoiAcknowledgement.ts (100%) rename src/database/models/{ => approval}/LoiApproval.ts (100%) rename src/database/models/{ => approval}/LoiDocumentGenerated.ts (100%) rename src/database/models/{ => approval}/LoiRequest.ts (100%) rename src/database/models/{ => approval}/SecurityDeposit.ts (100%) rename src/database/models/{ => compliance}/DocumentStageConfig.ts (100%) rename src/database/models/{ => compliance}/EorChecklist.ts (100%) rename src/database/models/{ => compliance}/EorChecklistItem.ts (100%) rename src/database/models/{ => compliance}/RequestParticipant.ts (100%) rename src/database/models/{ => compliance}/SLABreach.ts (100%) rename src/database/models/{ => compliance}/SLAConfiguration.ts (100%) rename src/database/models/{ => compliance}/SLAEscalationConfig.ts (100%) rename src/database/models/{ => compliance}/SLAReminder.ts (100%) rename src/database/models/{ => compliance}/SLATracking.ts (100%) rename src/database/models/{ => compliance}/StageApprovalAction.ts (100%) rename src/database/models/{ => compliance}/StageApprovalPolicy.ts (100%) rename src/database/models/{ => compliance}/WorkflowStageConfig.ts (100%) rename src/database/models/{ => core}/District.ts (100%) rename src/database/models/{ => core}/EmailTemplate.ts (100%) rename src/database/models/{ => core}/Location.ts (100%) rename src/database/models/{ => core}/LocationHierarchy.ts (100%) rename src/database/models/{ => core}/Notification.ts (100%) rename src/database/models/{ => core}/Permission.ts (100%) rename src/database/models/{ => core}/PushSubscription.ts (100%) rename src/database/models/{ => core}/Region.ts (100%) rename src/database/models/{ => core}/Role.ts (100%) rename src/database/models/{ => core}/RolePermission.ts (100%) rename src/database/models/{ => core}/State.ts (100%) rename src/database/models/{ => core}/SystemConfiguration.ts (100%) rename src/database/models/{ => core}/User.ts (98%) rename src/database/models/{ => core}/UserRole.ts (100%) rename src/database/models/{ => core}/Zone.ts (100%) rename src/database/models/{ => dealer}/Dealer.ts (100%) rename src/database/models/{ => dealer}/DealerBankDetail.ts (100%) rename src/database/models/{ => dealer}/DealerCode.ts (100%) rename src/database/models/{ => dealer}/Outlet.ts (97%) rename src/database/models/{ => financial}/FffClearance.ts (100%) rename src/database/models/{ => financial}/FinancePayment.ts (96%) rename src/database/models/{ => financial}/FnF.ts (98%) rename src/database/models/{ => financial}/FnFAudit.ts (100%) rename src/database/models/{ => financial}/FnFLineItem.ts (100%) rename src/database/models/{ => offboarding/common}/ExitFeedback.ts (100%) rename src/database/models/{ => offboarding/constitutional}/ConstitutionalAudit.ts (100%) rename src/database/models/{ => offboarding/constitutional}/ConstitutionalChange.ts (97%) rename src/database/models/{ => offboarding/constitutional}/ConstitutionalDocument.ts (100%) rename src/database/models/{ => offboarding/relocation}/RelocationAudit.ts (100%) rename src/database/models/{ => offboarding/relocation}/RelocationDocument.ts (100%) rename src/database/models/{ => offboarding/relocation}/RelocationRequest.ts (98%) rename src/database/models/{ => offboarding/resignation}/Resignation.ts (95%) rename src/database/models/{ => offboarding/resignation}/ResignationAudit.ts (100%) rename src/database/models/{ => offboarding/resignation}/ResignationDocument.ts (100%) rename src/database/models/{ => offboarding/termination}/TerminationAudit.ts (100%) rename src/database/models/{ => offboarding/termination}/TerminationDocument.ts (100%) rename src/database/models/{ => offboarding/termination}/TerminationHearingRecord.ts (100%) rename src/database/models/{ => offboarding/termination}/TerminationRequest.ts (98%) rename src/database/models/{ => offboarding/termination}/TerminationScnResponse.ts (100%) rename src/database/models/{ => verification}/AiSummary.ts (100%) rename src/database/models/{ => verification}/FddAssignment.ts (100%) rename src/database/models/{ => verification}/FddReport.ts (100%) rename src/database/models/{ => verification}/Interview.ts (100%) rename src/database/models/{ => verification}/InterviewEvaluation.ts (100%) rename src/database/models/{ => verification}/InterviewFeedback.ts (100%) rename src/database/models/{ => verification}/InterviewParticipant.ts (100%) rename src/database/models/{ => verification}/KTMatrixScore.ts (100%) rename src/database/models/{ => verification}/Questionnaire.ts (100%) rename src/database/models/{ => verification}/QuestionnaireOption.ts (100%) rename src/database/models/{ => verification}/QuestionnaireQuestion.ts (100%) rename src/database/models/{ => verification}/QuestionnaireResponse.ts (100%) rename src/database/models/{ => verification}/QuestionnaireScore.ts (100%) create mode 100644 src/emailtemplates/Re_Logo.png create mode 100644 src/emailtemplates/onboarding_status_update.html create mode 100644 src/emailtemplates/partials/email_footer.html create mode 100644 src/emailtemplates/partials/email_header.html create mode 100644 src/emailtemplates/partials/primary_cta.html create mode 100644 src/emailtemplates/relocation_received.html create mode 100644 src/emailtemplates/relocation_submitted.html create mode 100644 src/emailtemplates/relocation_update.html create mode 100644 src/emailtemplates/resignation_received.html create mode 100644 src/emailtemplates/sla_breach_warning.html 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=)HLB~3K| zz@GsC2#o*$?qIh<{{R49TmZmyj1K55`y1W3`ig(S-chcR(M|f zlDfLXdwAzTD~4AL$gc=p2hib1UTG(v{Xh&ZCQcpXAsu8Xk;c|00MbdN=G3lJR@nde zQC`*lqy4Lk7t~LpF!$5R*B@TaUS6}Ed%>r|y0*5A8Rka#$BEXqEUZPAnhlw2-+xH% zp&59e_%Dw=79~vEWp%xF|6;d=FRy_|9}2n7Xcj$+y;h7wugv`wbRl{y$(nZGrScbA zBM?M`2YZja29A8k8u!1e|6ae`QfsmU{`ZOmQ;&Q8{kv~A8R2myOt5d2V3Hc0uP;~s z?;BAEi{;-dZS-#U`k#27y5X~WFVDs+Q+SI|{QtkTgpVr>rN<*`-VCkzRSnd3i=7|Q z%--@@)!<$+(qu(Z1mx(G$(}ii3tlhSSqC2rU%8uRQTSEgPkPJhXuUkLkIRf~wH_e! z%L&RST&D}ZSNNIO-J=ysSs1dtqEmkO-`F%!@$xKJoR`3_lF!*%PJ=)T(~HhT`j}z| z9ZzuYd7L`_4N8Gnep##B7fIiG3F)5+(hZA}!_T6@Ipyf80OlUGK4&iiK;~K-1y#_= zp-r`Xexwh)(dyM+rv_fsTq`<064U+Ny=o!lzYl-#Bhd%4C4Udz$+DYtKF8u@WGteksITofmXVZ=#wKL^Y0V#8XuTGQ;`8FK&G#}d7R#pr@+_C)3!Pc?RJOBoBTSw z4>bdjhLW=yCaFauSV9S9C19?r$6A70R>@0)|uiIpBq z6%}j&?q4S5JN)SR&p@%|Zx2U6Pw--vOM@c)GTM>t@RYz>#NbyQBsl?12)BuRq>V3b zo8cff7w0jO`k%q!C>DJ<`!M$oPN0ytBN)rK^S*5-@5WxOH@Q+rrCE|mq{w7asWp<0 zBL_YZ$yagy?Cun9PZU(HI|waSk@;saaYw5tsg_l1etkOZWqseB(y-?JgMV`-e7_zGGB%ICAr}uTsVUZ~*VZI?qKMUJ`VPDzISu+R4a~q>bQl`FRZO@0q^KRv_`i0QcQnU6AgUH%FGv3`oGTIHM3A(slu~Y^ zFGcJ$zV>ifbyuZ@KGkkq1^A%X=?&EUb1kp_&ZDf_H6f$?l@c77jx@$&AFoi zUh);_#7FgieVOX1Ev+wU3?Os-pLB-O*w+k) zKKOs#L|1@ikU+mwR4~Ejk!VuI#*?J|cI14_p?&{OdvFuxgm|eY64u}S1x6JlR|$CTeM>7s~0e^{quuJ5pwL}l+{u) zJRQBlrcvPO>g2e=VDkpSH0VEn&Y;o@A#IVIk7_qV^;MSU3Ddu3H$4m#Oz?b!FMWN| zafM^0GL!>uOPQ~*MAm^o8;JC^ohz01P)kFYT9n%_{hKd;u%6*vm{9DlFMJ3HqAUxl z^SiyP7L?a4RM44f(>raolf^58f1pTLxF=_|=KUyV!g0HdyVuE`ySGBeVC}-O(A7~1 zzPAab_i=vZM%F@=_>15|2qSByt$|$WrHc3-;1eSM&~#UdLp7*Wdzk-b`Ud8@*dkNB z2)71eeH3Gv--$Bs*gik$M6g?B0A0K!M1PDluAK&)J|~_^=f^;OGyNsLzhsAwRuma} zkvY-$EOokw5t-?J=6^9T120b=@otVUv?lY|VOWG#axn~|i62G09=!7{V_=0?BM43at&=c97$h z4B`vWQD8ac97D~=`;)2b%lw3t{I@z?nTw3E zTVkJsZu_eP6qTxc%#c=KsD}bdp(OO-W?ZXnZ}M@qZC={NC}3^fG9+ZkWG)%|o!M;EDWekqI`k~i8aK2uxlbgx}|e>?-%9M8H@Ss=1=r^EXEDA%FdbP z=lf&g6)vFA`^M(qP;s@~FdQM;G9q;#)1E{JF>#EQ!LplXZb0GnH{AO>-6VVtiM1Tb ziUNPbwL8vE-shl+ptaUfFKFj+03~6kQ`>Qq<#CndsF=CSHc0@3?k_Zpfrp!r$HycB zlAJdXE`dCgfV zLM68X5UQnyaCZmN&4v8GXw zOk#^C1lO8lHPGqD$urxkD-6hC3B++lsn5H-#n{f}57NrrU_%X!$41;Bp(EJ+BHhlx zusFYi9z!A+yW)6r!`SMfmj_n}ZQ{$efLpjX7umUJOnQsHeGDnW{S6waKZE zEWBP7xliBGduK{i(UR;M4a2d#D2?k#sWC+Ehg>)cD~9bgTCF5WEBoSI9$6oR4+0!a zFA{`n2QG(Iej2UFZV?P69>n|0J5EfzAV*J&wX$%+G4_%ybM~REgY*Ga2!sT&R zkKHmw?r-0U6KbI;$cgm(e;d&ErXMN7AdB#;MVwCD)l|k!@-VEVSNZhwtr1(Hhw6e_ zi0zVYG`6`4H2J1E^IO~XUJI#OQaXV-g=?7|?Vt!!x2hk8OemB(yiUa~|3h;m@VCd~ z+|ng4umj}#dE`+NBXG!*-aZ-kOZBQ4im~$#9P0&R_8tZ=CHK^CmibCgihpi!&M(C< z#Q=lVMZweJ&a*Nm8Q>K2Y5PI zKe$VJ`kTKB$!g3AVH%4~m@G9?T?w)MoSP4e?`#R(z12%6ji#t?K3GSo0*!qsAAoh-UCg6S$%#*2Q&c=UMHoT zKwP2qW^Az6(?Ym~o27>MH!D|&J4BoA`Wp_V!~5#Jy)Cel(ymu$OV6|nwwFCp>WaX1 zT0icg1V{e;>R0-3pd^pUMuY`iJ5Beb;39z{T+Z(Kj8EufpX{L^JnRP0YHL>AVAB7H zs6>d3eWkp3Av^dWz*PCc_`}Ld+Mwz*=v6(I#@r*p`52{YkO-oQCb%kD;i*$e_b97B za6}`uiY{N>T_?hg`Bs&}BO^>C(wdq_MemJciMK+zL!Y{QCByT0GaSVj!Vfi44F)~K z%!~#$R3C1+vK;UJu)-pYd`;o5gdhP8(eX0Aw|2T2$1$!_bu|Y~paq+ynB)ZATis6~fHe9Z*S0hS**wP4TsD&#rxou90}@2hT`)whMD#_fJRP;(Bcd|At}o z!5PCcE-mmhWjYG%@-g=O$WfBN06CXgLBj zp>(~}`Eb?rRYZ=Sn&4oii6hYz)loO}nmU@CSgLYbt%}sa_xMRy2Gbd80Mswtlb6kQ zz=IK(-vpH7+k?biTkU97`WmZQ(O8k{;=knYg_-4jkwTv~-3_*zj?!Bk3xVqvG|2&# z>85Tve!V!KDrYq=J(3mJUa6Hr#QEiocLQ=In_B;!T>YxV4~9Uo@9yt|3b#c918gYH z$apq)1lCgV>MtN;V`G&u3F4f3CSE!};YCE>aPEgv^ie5!*PiA{AS2U9z7pyLwf=B; z4^ad^vM%lJD6Pd8Ja=_HoHwl=%|glD!3IjQHGQ6uLHTgTQ;UlRpMz&de>oYo!BuD_ z)_?n5L6)y?R-QW1Sk32ZR(6#a7&265yZc17NV>cwVvB!aLx-_1YsIV1Kmk4U_8LD? zT2@hv^}imJ%H96t!8W{vmQZV2a}Le8EAzc)IJ-?tckDOS^}%!wBs?2z6!u_mSf7_T zvo1Y3OoBp#u4eTJjl2IQ1B2y12}NX{78036`zYwo8PZ`CD3PS*p$$D<8tl!>w zKcZ6~ALK0GClNSxHchVaR+qfGfBZ(=@Kg;wxMqf zL&?a!QqfZQ)P(yevDdb|eMqTJ?gFntOcBb&BZ>HM(jod(kE0Z_ll%zRI#D6lA}fIv zfA!2Fj~|hd1{WM91WMZ`dlh?X+f$ws)2LYx-sgvm6MxJ&$af_EPN=^^O^*kYSIL>J z$G+%5+JX=QoJsbmS100!fqlAcwQ}^qU(`xdO<8Y6xeXN1DJ6#%#k(0dw0dv`;mXlt zVkcvUU+2N1m`D5r&l9?s#Sa?!)?X8Dg1NQ61s4KxZFffIiFWT$icxw{+$^!5s%%{! zAC-U{${m?3zxBn3q;=3U$2gP-nhHJ9(T&lJ(I3!>s`vzoo1UFaa9A&mr}3}cPBx3f zbhxl_>CSHuy@!t2t3^W8?}GpsBNfb$FLfl%d$kKmfrKVtC#!xjMBhaC*Xo;@%h_(E>}cF5oaX5)<3>Ln_DS zJ=-PENc&_07D<;kKuoNV%|VscS=fZKYkh05bnW&ej(KoKo}4OG51hbJ9tu;o@Zq)Y zXCD@i%d50CJJZc`3#@DGWH0JPXAAa%g%8NsvKY)=AgoGKOIyiAAdQ9@PlQXZ@P2LV z;Mrz4lR1Z~qV* zOU@D!avfZlTtSc&^2ICj`&uqI`FowgMkxyslt&~aMGo(&zBUr()EZOZl-V~sVl+Gc z)^+sjMQ09hfB4B`fUu&sV^6NNvy~x}c6sL~X7KQ^*9}t66DUH;Q-kfyRTlrk@wOuh zPKmd2@1y5y$PNTCMe$TU9@Tom23xL1Y8o*L+f&nMWv_)>rK`~xrMyzs*?ADY6bc#Z z7=5o4`Qh^s9<{Bj2&a((A-cZ$j``ZFk7 z;6hbWY^fD)q*dQ=_HtH3MO8Rckg;mB`n(REiX`>qtExHFTVA zSwATu_od>FkxYZ|ijduKD5NiB5k8WLIFs2+>R@F?^;4lGMLlx;Dy0)FKa}KxQ2jj< zoMtS7YXj)NfuD(Z`PVoh+)h$}232lv?Qr%OkNSg~rU$(Cc0mmii-JpTn{}YFzPqq% z;QR!bCOd{%pXVglL>5>GdV+kaE4NC(Bu z1EPa5IUzl>Iq4Y*gcgh$Hj@5XU|xk*jfcE2FJMooy9tpmzM*7>DH`~wRQ|g{jZ`Z% z;rUj-Y|o6$$^DostN#iAD376deB5JsjpKRsnRdE`k4M9LAt-&J-QD`q1%ePh5Q6ow zEjJoPlnGbQ$Xs{;;1g`k7lh$jd|xcNeExYeQ2Q=h2f>2xD4|zG+48$&El~u!5(5Q& zFab?s4^!*M?aFAIWHkJZ5C2S?(5|jA5n;tGfstcb*E&F7!jGtRnK~~b zNQ2Ok&i@KQbxZPHl$J1=$1P}oG8?U1$6e@aEKOk+9s2nzbVL8pAYEI%Ccck&B{TJP zr#_jmkJWM~2|FwF$H2GE^a-*oxcU43mn>=ZsAy~#s0 zKVCJgmSsABkXCE^YP)DTxeCv-I|LfkfBVEM-{rHssR%aH8jI3E-WCxNL2==h@g)KwQW}p@BwK z&^Rv)(+gHN$ZmQXTUM(3VOz+^!}WE-D&6G*>~xfn)5CaU>PNcz zf&+%iTnJ(;nQzBP%ViJ-OS!*-#GO z0Y1YLIzZ2h`Q@<1vdy_7;j~+m6X!dnT}5wbdHz%*-*3bLDxXx`Otke>Q0p1e{v7+! zO>+8{LsaYIbXS*y`=P<+%W|vBS0-TSj^_Z>UR(-44 zVSe})`2)`qHVVw^_K@td`u5XDQ8iNSJu5EKX$6h+Kf<`klSRG5x_o?EmCadg>+j0Z z$HOHg@#VRHjt+Ws+5ZWz9lM>Z&U%VQ;^Fh-p*C~)p$^f(*zNMyk7N=NgxildOdK^D z6aIB5O~T~@Omu(3^lY{b9N>mnm?1fykdO$Wsw64@iIu-WHzzooI?(%BS~Q}wos%Hn z3W#HsdgMkl*)dN^`NFC<=KPH7$5F+D=p8r3n-0neZZT8cW91MD8$O@|S>R~EsQwwo zEFVlKB7_suxyP{FoS2O-d@jkdl9I|XdDQ)~tX!-BSUPG5{~0^_qjz53FIV>iR(sn5qboPHA6Pg6thQCvSXomPZJtLl{lY&F}Kj=zJSWy^=@B8ju}QvKW$tV1YyOUIl* zi3ZG!0K`0jlEelLMA&dy6;k^n&251LD_F8QMw79nWmA?*p-7W^wn3IG>R(IJhbqCd zvqH%%NQZ^w8AnGN7^bQp^260ye9yj@m46VrA%M&Kkp4ls`u%YGW0{HUAt!KhzoP|kmiuQ)oEdDO=n>SXyl8Ghksu&-OU-7Os!(rnLUpCF#bwklA z3%|-OX=Iq>S0jvZJIPT92*0jW1%84zg(o}cP>`xz6-~L&Df%plpY3;j7KIx+%4)Qg z!(D{Yb7!L?x5EY)<1)zssKd?mYG0R)xl_Bg?8ohH*g$5}eM(=&%KEaOnWmO9I9J?Z zK6<=kyT8X0DDm3%)HaR4UPJZTpi;HmQhCS3tP(W+fsW?>gPp9{Oq%TRZ;`4uDjjnO8vhIhn6z85XZn+sg9)>NJ&5si|kdf)ftq@h@)y7175Q+{B zcA=o6{6yQ-!Zhu6_r5pDRtE7-gSly&9z#x)DbZK1#iui8D8Eu^r{C?VhMxRHHv{Mk z4EU<{tinJ6Hgm7^js0}g_D>iDYPOvJR2)VO?otx9GlNL2-xG7s4ajMJx8&Qr zT0_Rp_{LiQE#SR5<7XFPj1G7q@b@Fc4zh(lgAXMYqx?bccph@D-OJl-q@gatC~I5T zDLwGp6j`kaSGe2{+OnHhAoWSN`8}LC!1Vpas}2N z5uaA8|80OG9>1L8{pWn_dHS-NGg{%b<`hzElbz|SsQhO|U`qi!**=?#T&hwRo*R)s z+$oLIQYdJfz*ZP|&KroQL3AShYG28+2mzZjgAI>Gq4Z(!>rYlS(&T8;yE_zzdf2vN z{Jsr0z?J%9;tdWZ@Q%YVV?jg@npMG_H>hybx@nTsA;|z%6zLmpY}CPvG$S^B7NPqz zW>NkoBAT~73RH_AaPtoP`l^Pb_1*XgkPRYMl=rY$n};^LZ%nniXs|G@^KduT{k%yn zcwN-V1zP@2yE*VN=rKsZkAj6l+2V5pa=}p_pr4)=e+nV+TT_#VNAbtFpywevZ!ZE^ z+p4fCEE)!z(4=Y7BUJF2=bl)9grr=lKT{vGWeK6=`waqi#N^ys(@2f#zBlVHDtlrb zTu#e|g!s+tbzd5OK5s8A-TJ$nES-pVcF-pa44@4HrzM|m$W8B2sbjP~YuxTV4FwNe zsgyy#EAQN{!ARC%dROc532;Rys?waQC}%V<$QIM(&nwrA2y)7&WS6Fu+V^Jb{0OLO zDk}~C5Z7tYwng)YASeO{uL&IG{M!tJ3VC5NjWzmMmGNPoY5cQE^#`r%Elo^y`Ayy2 zIB}#a>=W~_e?18M{9zb031<1v!$x@l=Q!dv+%e`mohs5am8T43FYG6G?=R#Ks!W@?q7G(~FPj3y?PvW4O0 zUGa^FXS6d9IqDjwhRxqR!6;|U%PJ-SRhPyARu(}^Zv~vRRUcTgA_ZjX3B8lTkC;s1 zL)`d^a=l8ZZ}|hBLbaC+O?GOtqd?hO>-F=nCqiH|ZXpf_$5m0J6@;I7{QMY{L{85R zrvBGMt#rcx23x-LL(-Az!?>pvn9zThf%9URJzOJ?&;x5JF>n?tYms1v6;_o;iOZDo?q_!J> z-J^kH>UKQ{PEykY546bcLd-ehG$RZ2p_zxG4=08nWcq5;c#8%Pio^9mJ|Npy74`6LsTH#+)wdE7 zpqyuN$+4Jvhi^Mf`#gzNg8ZN0^}T*$(3r4bd(_9y2RDLBH!%!kQ$%o!2JC@_N z4oNScJ1yfD4!kRTK2X{j_kPgr6~@yqWSO4l=R31!`qb826E$$O4*U+EXvJI39Wk|H z>t~6bSwr?oI?=y=6ETM-J05`>rCQ5?LYWYltPua84`X_hJT z)TX-bLq-Adq1dN)XtQ3gw*3y?z)76#r-VFXEu1|rat^PqVa%Vt0QL?_-^pfdiZND? zZn{cDO2jVfr5KzTzJRxpBSnqO&8#p#D~e85B5mnnE@f+B$~-b7`GD5m{R=Y@8D7$t zUYUw@0*y+~=LX(0H}i3K)Gt8@#k}rzWkW}4%aKsg&aLK-!Z&2j+jCi(x{WBz;N0Z= zNYbC*gSbEU=XR9+Y_VHL3{x|bDOXxp+fKLr2-m?`l@gzoz>9P8NwFJ#Lj4@=EL`?9 zNL9iYQX!>#%D3;BadWl&^nCQtXFThp^YhWi)7u$QU##O~030WlWwCadQ{&=TaqkF; zN*tSh)_Px>;6XD`FkIv_CPqrMPqESpmyM9q=)zHkwYk@hbW_^5pfyGzb>3tvTnvEa zR2hl^yK+slPqgoyhWJ8IZw^~C7B?DLCpx}}#pv3P2$K>>?QNSW1miZU=qaWc?^+Xi z(|tA0pIZNJ#T5isfBPVOENUrkuC#JmH?!2PlAC_Na?zQT$Gvy$T=kieI-9(76k@dz zf9qpBp#2w6xFCGCCuD~qn{w5M(YpHR!cc$LB?}eDH}5-2yKr8cr~J%4a>GLD`e?O6 zOxd04p|Ki#p91)_d>Y#Z_9yx!uK z4w)^3;Zu%<&M(P2T58@&l20Y`W)-c$1ej%tVQ;n~vM-wVE`!rDO%}cO)+PS<`|U5t z#)o#Zw_O&O0a%Te;E3J`U*iiqPo2nq8-$+*-Q4)++?OdO`eWI1Zmp$(Z_7WPapC%U z#2jC!l{^b>ny<`y(u?JzkQrJKyS+qIa02h>3YGHkS91J&+6Hdi4Ki4Jz0xf=R4X(- zrNwOGUt3tkf&;<%1=fuLFqmuyq!5bNWra_CnevWSu4?zDBri5T5vmAuXnhS zP;oyy-d((3s`;O{zZ$QmE@0N+aklwu&rHi^vId+oo2a(Kl6Bz=?#}cH z*!yM(6sM=y6D5wP*WqDDqdX*8x_NEB9M{rzdq_yd1#|}LN*}!IUD7}b>QqCJfH4y1 zrjUJWkKL%%achuYg^8Z-w{$hojqYiFThiulV@FY1#T2uOpS1D!R1I7~%Y;qfqqo+B zds4V()K-T1k&DbL{2%+Nv@c4yksKnFx+^NdIbahLT8Fv_bNUUd9tlByP;tqtjh1e$ zuBYYzQH%pK#EsLe`*qeftB1XLpS2Hn&u)DkJ`KQfDm5NPsj%|z%Lk1eHRPdvvitI2 zGamWiN{#Vpwu`_5jK4HZXWKU<;q@$mWI_|L1wEvL2~Ved`0OzaN>@(Rju1_bz^}t> zaFSx7=mLpZ|Dd5z2VyXLthdgDL*XRlXT#kZVa!)jG?~?$Tv9d&ex=EcGV#}Q0;Q{0@J^p<;O+I=`yUt- zx^>y&W>4*cf*^HGMTN}oah>te^FfF>#WbpLTi|CvA<`1ld%6*gKacG&rj0YXau2`k zbyAE8HseMRIHczY3on36hC{ZTC?K3o$Em~ncLC{2$RWFMEFm;77@&{PVZKykXlvAtV$%b1D~qKx7ly)MfQFd z6zi$})U!L1f!=%B+WbI7t9k4By-7B>wl}VnuEX>E?VV#PJq7TOcmQ7~Qey*Be6fAP z#jVhp=Cp#Z&{T`l!)v8$g>oXsNEq<=63Q4xs!AFrw#5TJ2b)s2j6-8?G0)T>e$yG} zt|A-SDW;p6m|cJew9AHb8+Z@2Wh_SKZNp$mF7)Zk5|~=$Ac^`vb&1Nyrm1E)KYgWG zmL4!sMDTd=x)w!Cv<+c_oS9KRdjp~^D7{Z%RTa%iW8R406s!q* z6jDJua$QYlFaKI5A37s)h5RLRd4I*7lNyHV3$?<^eKA#@~L&e?5g${ptudEyo z$U4{c_LnJ@j1!lmpQqr)*AprKIh&`+N+Y|16ge%whGTh))ded>p7LZTTuxH29)_CX zFx0@hR)#0dSj&jWvwDE>+}ecbY3u=8BA7Yh^P?HLW2HB9P4fjzxG_(fhIIa6i%EBe zjJ`(Q7oChk#jZ|78eW8MO0{@eBNid?22yTmAq!8V2D^x8{PwQv2OO&=f+jqnvFcd^ z$7P8rzfm`o%r7)ziB`SYKq5XTDSDv>r0;ALrarXhRLM!>doI@AAc+P3jwjp~q0Okl z>iqX5GpUnE+U}$ZSluv1GM3^g_vs?b*?i6#8`HXE(1@kJE_z1v!mj^T=i(JN1TsW< zdds+4_^_yOY8(Q;QJ$Rj%Sb5-h}URgA%V#z=#)^x2VYQd9+w6a5%dY<#C?f&+NLn4 zhJMC#WveH3#pA*hBD&#rHLXk2sjuUV=r=z1u$doRS`I6moFxA1I1KoEb$F|j?*97* zFWe%-uDFSWwAQR_Jjs5w`|$}qtG)r@^+)+X#Dz9>KMB{gjZp9uF&M6zyP$DmH!I{!RF0in{xZ z@s^+XtSfov5kd#><|9nQB43%k8pjq5Tu!00CpI^Ocn$Khn?28a7PgwUlK2vReEsD` ze6@4N3Tc8>8#k0QT=Ulr=(3_njQ#@>m_BbuI<-Sn>*vmx5G64lz|G`~vhaYH$4o4cX!$#CW0wLXPUbS8h2&bO4LA_k%c04Yyl z0D*v^ks}~SI_zlFIv-dUewGSbDlq+`pLtr!5NDf5>HTS5tS#UiH13c}7GV@R&PlBL z3}R3?z>|;tlqbvd&@*8wpI4!Ku75SQ155ZxfKmHuCO58PF^_e^#aiQsN4IHSXsSZP zntLV6xMl;*T`=3Tq&GOA%&?ll52I&moI3F}FFrB{$%KDh1;e3FK*#dGeiYEJn<>aS*wCSiDU&;$dPNkxY zUK+i-^oF5J`>ggtI|&c7-0_txP^>UfxW|O}uhH~B6(+B$4c7z;cbrnb9H&CS?v>H6 z=s7syNm)7QN!TFh;96KraOUx5!HTG}{3ez&uL*Uu_?a;Le}umqXtENKx{AuP!ccPm z!DUJMMq@Kp(svY!DIRr7=|9pN@ayXDG+H36p26JjHD&>R^Fv{7iodaCvjhzCpG7Dc6eHPIJRr@zRE1KXec^NLg2cErBM9Pj(yX)kK%Xp zDoxS0L=)5B&EAUp>PjX2q;d&hFz;M$=V$M8oB2>1^g7Sc4ebZ_MJ~_np^5*gm>}n5D}9|> zp9wnd(9f7f(F1taqHJrm1SQT%QE>NB=7FsR<Xr{WxJ78YiSmQZ{GpdZGE4A;U#XB^b5VLF`xn5iOZl^1WM%m)Zf5@5{N0dYP zt8}tsUzH_GK>7aAEcY{)6QFH8Q#N zU%Zl-Dr_@DAAAB>QY9^BQc?53OU-!V1@9$enF7JM;KI=5uz4-(y`qxdh>tOmaW?3+ zJg9L_6J)wS_k4;N2b*r#>R*qWlh83N3W#w}`P-cAf$lt91lj>x>KSJJ=69Wgfk%EZ z(Sp$p3~o13R}*h2>;}Dv+G!PtD?TSQJ!063v=#(J@FzMM&+PWcj){X0W~)$_Jp2dt zBhe{17#nIfr{?FgeB{8tYkwR+$wKcD>qBM+&@m`-nVVOQMR%U~hKAp4TcNH^!hj?q zc%Y+a(-l5Cej#zHg5`b_~s3dFAHmDdl` z@m1oB-hvQ1$j&zL#FykzFeh;CamqllbLa}a2i_Ec?4uRRi(G!wnqg|~iSEp0x}PZy zN0c}D`dbN8Srf2z;8s!@re@m03ehrUAd7(MYZgfK;lXJM*axess$7cvfCy4&^t? z6aPMO;^3%>`ZJ)D_#=e_HPjErl*W~4M7Op@W$SstrwR*UF*8(tyee^++X7-3IFmeBYjbMcq+qjBAg_B=?DmOp>caI*Vfmaa;02^!apkM zf1smd%z+s^FX$A68|JhZ zm8rm;XH<5_QkQOw(AbxX!i}Fepjh#xNS$FUoD(=;uAlAgv^I%&_2$~ro$3V7D%V$T zZ!R{{9zWl7WZrNW{^0_)Dg=SktGrz70+C+IY*L~x&vjN zqJx#Y1qV5NpD)dMJhRTXMJJ5J4zcD=Z%^2^eyWW?bFX!V3qJNcU0YCFUCG0?)yJJ2 zXwcW{@kT|Tm5kvHtvaWx(1b;$Wf^kpmB}zL>`D$s{=;shE5E49Tla}mFGm8IH*sU; zrS1)QerYeS+Z8;rpQcDGc4Vm=e`?8^vMUMS1g@_H!>tnSO@GWdFl%CWrX&m&fe@&+mP82)1y7zRN~Xq zf`1}-J1N8DMK0J#;!0F^UB(=ya56f0-pjv7uxO*jskN2Jpew8t?vjcf`#D?18V^U_ z+e4^oPF7`GHMBP6n^`Cseeu2OVw9Muc6t#L_r|I1K!(?M)3;@1n)2STr4IQ^Uf~-=$oKM(=WbYM{X|2nAY-FViTTtH-;jU( zH`vLNGMPliL#Ar_u~!oBUx+ikAfzs+8MIx3Y|ErGz$W*n<7>^c4qx zmehS4Q46ZEJ`u6){@gIw3NDgM3>HwuN>usDv=8 zaM0b}K#%!Ne)702tA;3)PcSg!KdLJwbP74%370+Z_7^;+_6NKuJmPTRFyW)K1xi#o z{^t+?T?`!yl9PE7j8&BhSWFX8Q%m5x5kEOW8vG0tDy(7@i}nfLILT9g>RA=cX#qm~ z(h#8^jXMd5c!OjP@{hT*LJ;^r(|9U4!c-}jU>11It~_%XJK$sJzSrLw`W93&* zC6kKqeW&@|!%cP@jFDfHx;SY3HP-2iMu$m7?je22 z3nw!@`OORLPXC%xBGtILTjcjQNBPv+ro?(U*RA69K8E|v7GbMP9=w9|QLH;@u350( zpA#RT@r-u_GpVy+5RUog-C~V{)v7~iyY9#*m!;4C*1WxYkA@6x_rdEh%%N*fgvgi-9=nK z+R+X{?2BM6!hh){!Zx*eLWgJ}aWAkZ+-3IPIJyvW{Sct!M&A)3x>pZQ5cG@{|c{+&ow?56c za>lcuVfCfr_10M--QF+4i4l~Mdyl=w==R9;>DPGvLiEOf*?ibLo^r6Qax6Rc-pkAT z()skyUGRY37ZP?vjv!?m6L zdpZ8;eciU%I$0MJJ%cGIkn}frF~SO|=6c*6=vuvxyTOGSD|-XWs5F}ZSdvhJo6VQG z=f^prk|Uw=p*~3X`~|V|Y28QYwDj7zHttJ4C1Aa7%no@}74jY;FdfCDf}RF)j=d#f zyjdIPHQ9f4iFU>S!Ib+$R}FvvZ+j)Lf!08d{BBlN$5VN7f^3`m)Aoi>U@Wc*So_2~ zm_Fy;Pb>5}#F9z?dDX2+AI!Y^Yd;B~$Kl}gMq?HAz3pe*36vwt13KT@LfX*BuxRW) zBldTqUffRM_pA;*%HJrUgYtE~G^$WE=LdddZ=5BZs*`BgiN4)+hGV6jev}b5iX|jh zp(*N>Fx75MEzJelHLR5V?yCM<=5QY^1VoEbv3+t9OCeL{oY)-Nw&MmPj_lPgzFpZp z_UEHXKCnX=P@J+oJi95BxVPB&x{r$N%?=A9=2epgavfZ1&YxD!!yca2ru8v2XLxKnNTW;A#GE?3pVbkKq&SybukQGKx%S2uv)k ztcUIVDp~k|L1%f{bF-!zJYx)k|&lrg~re$xK0k~0<34ZeHI~B_zk$HyYpwVp75;Z zWaBS0xY@eqcsYOkU1zp#%Lt-=!1bRW1*(MrOw{)1c@wy!CB4qiByN*`Fnai~3ghZW zi2ON-tM;S8AzuU4d$`Djh>4xz0{zb~TG})+vwH3N5ovy_z=ZH}8tGH|i>d(}?+f zq^dK^wdANqZzUd7k{Eue2qmSSdCMvLwi(33QojdGGortwMoN}xIV3G9%?)L=(ac;h zg0g~ES1^LDH{>`6D$E(pcXvM@r_;GoU5wHl8zFbtAIeMnz6RSiDFrLMhJI*BwsRwd zGx5E6qy0N^9eBzU#6z|QAVXZSTS`V!N)10lfFJ`0)V{{KqHls#bN#Wb{Lk4oiQVGt zVZ!VrrBC5l+iM~G^))^2Fc?69FZ^^6!5Z2IW>h7GnS0e80j`)z##IX3*nKJT%X0^> z4CN>`MuN&OXcT;=bG1ojC4cyk4~3mYmBI;`ky)UhS6+A(h(D=juayCTO zoh%{%SfNR&41ysxr^c~e#;j4{p@?uU5f*Kim0|l%q&ip}D{EstF}#;2Yp6->XD&xaiiP&H5iBGf zB&I&E{Y5^j2kIly*)4grWqV`!`z^r)t#o#R4xuJt-$kAW+>%;ojr;Sh!}ik%q;N8- zV0m8ST$v7|8fK&#Za^SvY};x?1R5!qQ+kQqU=?ygUH}j0_-FxcmEX6bxzy}V36wg zWHlK}64uiKn?(!ZLglymU}yQ#hqQa2)3u-QnoCZ|BUlah1c7g0=UDI2aHgHfi1}Z$ zm~wj9LS63&%x4?tu7#3pYaDY5;XjE7c&!9) z%g=xiKBolDbPTIkX0eIT|Cm0M6?qHeRG3fm{Ytz)?7-XOR%&e$f}!ZyNgbJ$yKVP`h5h-C4YGT(!3QhSUxr(p;uD-w3>J9`GuSUF6-lxT z5Q__jy5F8?>OS88h@PJ;7{$uB!od#Vq8-+?iVOO5T4y?VleuJhKA}K>>7>199_UHU zt@hGI%Ux^nhSgC?5Q?q>&j)Zm-?@+oHndxLu|Q`jEf(sg;jxV7a3X zMEr3V8nXn*MHx2pAiUAPLeG*I&Ri$ZCIzwn!(Y~rB~gzU7yz7jqJnxLJ(&O0;|@d= zbgS8~vzJEcMr%>Z-SoOj%48OLL}oaHPLWb|73sEak_Y}|iXF(P-E4ZD2m8m^#h;C8 z?B?CL+I#;s=-w(-zb|JqbdY`DB6cUbBYT#6ElstWLHxlX8rS#muS+J3NJxk+4GsE& zRCZ^NZG^h%!(`NiLNd?Vyv=@ZRXC<1Rb3Iua2JvBi&cX{oMO%1*xSW0QGg)?%9{ER z2*?Tu-QV8>%+%pUA0AG;0UPgFE3c&8@f9NhIb#j;*WnsCxrd_*NL7#F*Tmr$Ca-!( zG6al&Hze$lLtV(T@Z7Sp5NG8-kbHGX-Lnx+9Wz}=KA|_B=j#q2TU6INs22<&n5uMX zjSY~$2`c8Q5{}$XUfQ$RS6HnNu3{U13f#OV4&*yV$*mO`ayCD+7dplVxI@z555`YM zMxFt()}0tQ+1=q}48{m!hrAv(l1QC?pIk5v#zhMTT$8WFSJf?aw7SuRJZ-b} zvncsB)K`5d)^7(T44Bb%MQZ=tH%!#Nb{RZuM;vuGxdB0_NgfrQ^+F)HDhf;YUHG{a z(hf|=5&V1#XD^O#sB`YD2q!m0b&^lo$Se^DQoGrFsJB3LCrdpP@7vkfdKHv>K^>=A zMF#QKM*4>#2SBkJ;pD~aB zJ#A8|KrIbr_a4SufO+tI-^Xk7E4_v00QR9@e@}qc+Z)pyl7ra=*&!pF#hS&s3bfJ- zuOzKXaH^Ar4DtFeiDCHvTkua(dg$2-!L_#$$=)=cPE@@L?>k^@z6na?g-@+ftm$hG@6Z@>Kn%$g zU{4A{`C#&HrRV={;w(6Up&4yERV@S9cM^ zd3EZ%8bR>pV(4J1O7&#GBT_j(G*BZ^6EYT%FPVZt0hk7G0$A6hD@7v$rAgmHW1j+c zlSpmwFFY99+rsb;9N-?JP~GCw+Pk@(Z1HX**oeB z6HSpa>rtQ$=$^Qf_wnhl(!(~@;d<$O@9p4>?AtSY)shk8ykF4_AcD*C)@%LAFAzr1 zdH4qwoyV)to&{hX_5| zF@56c)YqmtPG6>1b*EU}rR#{18#STmruLgFjQZ(2^i!oXNfur`=N!bAuR;9xiyr_| z2<&oQ{j}Qmxl4R!Smuyt{ilEByff`DBf!ko>#n7D5g67@WhH=-yQR^>7CNvo*j$;% zXWCHRsniF|V=ce$F=NVYvL?V5l9`fNY-N8#37>pM_#S+f)SL!LAuoIZ>Quk`mNd)YBMo9&fa+D@ zR&;Z|uMc>I9}1y@OH_N<@<2m@WO2ld!j;Ie+bZtrn^oFXBbbpd6XrfSzZ+8at5k&5 zQ)bZvTPWgo(VfMluH~zx&l1DF~>T>rZlz-nveGmq*t@dKU*rI8SUbq7;F? zC~D&hRiV@B?*7iC^va56XG(Tt)Oq@Ng)= zk@`jwj7suc5oMpRt|e9ziE=sTpWb#zjeYGvRUmR@vyZ6} zscbE~e{S4jo&a_w8)~~f^2a{a#%WmE23$k?<;*X%M{PpeTvI>ikz|W05Qwc9mo2;R zLD0f$L`@wvez*zG?pphhvYDjoGXM3y!ms%}H^-K149n{PL>HC%{6SRh7OtxyLds&b z4%~vQigZh((qM&A&X?W1MTPG$hUkY!&#Rmg-aLd7%)k6Z8R(T(p7g0bRG@_fIK7qr z(NvFLCsrh~1r=nOKR;&OI-@i*JxKZHY!EK+l}#Pb0FT11k;;%t#ugOTR>ex_Ot8|m z!C%PsJ>+dDk+@YW;mF2LPgLgGavl$yTgBy&t;UZZ+0MyOa&pINf=N#ObhX|zOJa>% z6jUm^_Q6j28~@=Nqx8d=XyJmaI4C;Tb)gy9+Z9jU2X%v$$Tg5O@x|rH?UC0u0#RNu z4s?yc+7ffH*mh~|7O6v!aA%!vYXr0Hpv%O*27UEu<-GX&e=8=>9`9#5(0x_KuY4Xi zy=%vu4bDu0e(f}KL=|>mWNd!3*DVe?ae3aC>1XbS(p$u=f3x|=4=ZE+D~dq(Qk2bp zoi+MtE0KUxYTnE9n98OLEHaNXX7m?etV*`G9pm>*?TFW~Yc{=q;uyQMN~|iEChYsB zCL+VyNiUw`v^mKP%iv(h>8k5 zm^9mw`uJ|rp&qO^a5M}tm=yBD)D8J7fD|*$ffs^h%J;*$vdq-CslG>_fui>f+yvPa zLe6Mlr^qP$>7)eb;UQk(($T5n`XE$lrOok3st}_BosZoP>O+*0R`n9ub@o(f=a)-| z%YxeqPtm!pK#u*|qv(h$1)Xl(9Zx3x7k;D_LD1uu#)>T3>5 z2l9WKu>x%6>)nh&O2l)7>WeKyhOnVH&@@lRCNYdE)TAn`bp840m$vgSiSy+HTifI7kwzCmYx>`RHFc2*1`S7F|N$!onpS|I=A(_K*8ZP0YUFj=KW@P zW;|8-5!WDoH1C3J@bTq*J*n?2z4_hs`*CzW7X4lFJI`D@6$5NE}Y01Y*V*k$+$C@f#Y+AX=br2(bzU*X>TXHEfm^g7)v?E1qe~ro#Dt3Q2)Gog9EnttZnKQFzY(3n zZ|-=cmt^stV{Tf@!g5uVC2jnAx+r}3>K*FFcZX5QRR^YrlG4rsR{O%)QG|DsZb%P8Ka7*kix@=Lxjag$s|Xr15zXG ze9t~6WB99xa|8UA!+vk1mj)#Gd5skvst+EO97i|sXm|$VVOlEdNW9M<(9ddA4MH8u z7`SkWu3>|C|J(CRo($yRMW86R);K1GUPv$B$ha;%Td>75$Qm_larODb;+QFpL7#S1 zHWybYSOqCkaUc$a-_V$+J#G_@60Texeiemyyga>jvK>`xrUZo3LeJ(v5~H-}rC8ch zR;@_xIF(U9&dDOnrSr|hfr*PKV$@GMI@)DKb*Y$q(5YN0eSaYq_ zI$2;0-8B&|1OKo}nF#tJ_?tdF`Uji9A^GoYzIM_5a&6lFGqO6j?RZ^zB>R5`4NSud zAL`=|-oaXc)p93tEWB0o&%BH&nl{s&|5Z}SacHM{fAuusQsU~H804AQ5ww>Y_2~}8 z(Q)2W#S)t|G=smBGjJmNXv{!Y7DpNSe@DARO^BEZzHxE2F4Lk%@5f)9o-GTWQPJz- zN|9IUz_Vsh?jADY{Q7+F7h9$#&Gd6_-EfLlQ1#n$>y~O?S8>};W#n#9pT^J5zA=we zm{hI38C=R=drzSAYYZX;S0d`ji6@4R!w<-s0q?;zIFaW|XGLwQ8c3Ki@lv?#kI@An zsKyJzc#kg^z=Zaf$T);Nz&>8;wOQ%7!malE;$_36ybY7MHoN<>eJOvFXPz-@zkS34 zva%(&M2T}4oDNcw4@g(^`wtIolI#91q0{B2J z!HaOBK23AL*Jse@c3!93$_sj>DuJr|Y)J-#ukzWzZd0w-Rqr=|VNk}jR+^pSrdm(C zGYH+ojd83DGUJOhLPMyA$fmr!XyWt`~QCrDsIrPAUTnD*6v zSiiNn@0}Wz#5V~NUHKpU>t>>74ucsj)bx6>-8Cmq|J5JgJZu*Khi=r2XaJGWwcqxnmIdewlgDB?ZN_4*;5E3woXgaeccWr3s+ z_Ud(3cBVHrX8G&l3~qtisl#Gs)G`kC04A3NwnwuV&HE-^2Gi>0h`dR(%?~)5nDt8l zN>?|mwXtkMf56CZsDmj$(zHdP%8Ls#q42}@ep6PNp$*?N*RB){6H(=+47^C}c{_mF z`F-j`(}j{2W~aee>IY82g!**1RS#p__{0mPZuDcK_zL4^4rI8r18VgN+J93m(lnW{ z2Bhv>SKA<^y;dIe7n%`pN%BU5FfQHaz|xP4Ns$@G>L&8ZtEi}0y@`%##q&LjPGql+ z2$|*p+g9#(QeGxWL3H+ttKZFqta}7$8-8?PGAw0EkO7AbS-;6Gptxrvm#SNx~9wR_s@003A zfpDQff*Imoq>sl@MtOx`Q=#|3J9#Rb$0qqutzm~$(=7KPz=O5|$3 zGx+Oocg;A&o(~BL^a^1k;KjYzb&w|19T;O&rk^T!rKB>0vR_n=g&J6)VRSUEtA8LC z{S7plm9BCWK?}+YdcJ__fq`_x3NmlSXJJ+)2&#`#=K+u;qNc%&4+3N`7%k!#aV;~T z*o3XUPsZ}S2X-hHV-cx)XCNO3K+9QQjHG3k8zbg-f@aW`T`Pnk?L4P86o}U?9&YE% z$ay(`3ilr|1c<}LUt)Y>sMS<>2+OjX_XWxMxoJ==l{ME z!EnMAs-;N{dkpN>fK)r-%bd7V9zQqBUy&jsRM4_S`(r)t3#NdyhKah;E${Sz@cQ(> z<()J=8&s2(*qqcN=F+Vf?y3r?Ztc-c$7g2WuqYPaI^VuDej4rlVHEPp zcCViq(WA@XA5zY)r6!WX<6gzH$$``tr`{pc?`YC}jOBc%{weOxEJuOS;CFjV9&(c+ z^o*Tg`mkEDhU6AwoyLatnlh7!I}`Dk%E#ba= zo7yTeGS3+3PY4@4)xttJz#TFsMTuQppJVqF0Y^gvdMQWbe-; z(K`%-_yib=I)Rj9Mj*ggZ{XnuWoh9`+&C|r`PB{j0rFc?Y=4BFA?Q=N#`ZF|AoUYQ z>X=-?=LX{Muy;>5aXSUOg;6;$(_(Xy!khs0NsPGYe|5kivVwO$m#lP9Y5ixDFtX$K z_A3-5EBjIDLi!0&Z+f%LV4CsS5%}Xy4zd|Vh*Ga2B~C1@3%>nWW0KG__c|ySvO=Lr z+*UI*P0ce+Ok4IE*}j#~$)2pPgepUsyC}R-NtVpZT<}={5U;H_)};<8bsXDMtAz?j z2B>p&#r5+Pl< zINcY4KQ>VLHHJm?aw|SDaLV@Z4V7X8^3EpofHP?dbwPG$;ri- zFsu*gZ2kSPs`oq@A;e&opdr+tRe1#OZ+f5jaf5bsWa#5oAHheuQh&~!e28lX&vz~$ z%DV)6?}td|V@B&8=;Q;M&j`B8lh+4ZLpDnIEoldr ze_k`sZhOJEhQM-k^!;_dY1-5Y!VcMJA>88$;s-(O%}dTkPgf00KL7wX*tI zztBhQ8R(Yh1^@)T2AZ8b5J-@6vb3~ZCeChiGj@Zm1sxo5enpcyJ=~?}g~|_p`246{ zvI+IY|5>wso`GPq$OcvJL<(m;f-0RyCn>jdZe^}RKXy*aMB@HP%c99I z6u>7=n865CcqQdd&TW%8+9wn)jMv7tqpL7n<5o9#Yre3lK`t&0^;==2z7?q0#Ol+)9pX1vKCL}F0m&9~@N^qS(~A!HoFU_agvARw*Aw-HJaggefNYyghhv|7-*!M$44ltUZofqOu_1SmJ9)8+O zY1nw^pywAGo#YtA@IiRsWYet2ij0+JFV4&C1u7#EUXVoaBzcL$oz`{Q^n0Wb7gyJ1 zz0Y~=?VamzQE4m0iYlR)dhFV&x0f5*lDOF7AKgaZzb;2o@o;z4SM6)`eVEs&xhN+n zV?t*SeGf<<7LDrL3sw^v#&C9IS1BRuzjQuJ%Z*wG74&cayXaR|w6f7zQ~F&_Z;{GJ z^Q1#9D)XXcYnOFi97~-qQd4-)4}cT|ZoGqyvOt@^luatDv;IJBvWpfRa)PnNMta83 zRXJrlCNqgTZ`3o_@W4gWWjR<0Cq z%C{k?F_Mz$8af&tZjczfX(fY?VCFKS4x$J&)lyD+L3)%YM-jNO8`mb!=8Z(feg2ye}XV=3ali~AzHvmds&cgW%*?}IJLbh!%tHIrHy=XCdG zgDU1z| zWXLvjq8Ow99J@?&DpLLQ<`J$7q;2d@dG$Hj54SGGaGzf%=$7&rK-Itlu9Oi zj^+D~b+&`lBdf#Tj48Y(Ze_}fczTAn?)1*v2-tH%Ky90SjTG`)JUQ3KSr#j;2M%-$?HpKCM-dLHDNx4Y_ zX}*@AbH(&uyfu_5ycx;kTdU8s0s1!^EP~DhHuTyd-(Aux;N7EKnbktII~Gw@Ss)RCfQ?ew>4im5b*h0peSQ#NmCc zO;P;<$!rUd`u>ztu~I8rPy;YOXdRL!sL_(O)l}Hx$85+4RDBa}AWs(&Nw!L^a;+O5z6% zp%N$miT*kAYd|2^9<*I{S~X-22o5P4lZ{JwAs?6FFU$;{GXuo{VjQ|jPehJwLJxcb z7bi44k{z$gYmSJu%iWp~=2)a-il>t%-#^-5T@}}DSy~3&`k-N$=EAdgPvL_R%;apN zYaIz5NgW=yaF-lE-Zq!tWNJM1)`8*i`9e6EjZD`9p{4Ez#X zT7SgUI}B6Q_CWbin|m;u{>f2iQ)n%{5Qnqd5da*4wt&4u?$^>u+57*9Kko*j?46J2#sPG*Hz&b-U=#&P(8crsr54lv_T~>Y~+ujpPz8xc4% z+-O>8r5~hCi~dQxj>}p{G1EFu)9*UebCVOG+Ywh$2z;N9#v@EF0NN`-b{4hp@g?Mg z^*pF79Z1DWpsAyX0phIg_zLwGmJA68p*14{O$XPN@bYHL-`EN~xvPy~W)`S#%rsxu zqTXc~&-=3040>b&1vE+qDp=^76jC=d_4WGOdgQb^&SH;zn6@4x&pc7ql+F(;G^5D_ z2sszCYtOHZ-XvYCEE%~boMAVB+e4x!gg|Kk>*X+}KX(+_kWBmaK5Ic-1l40()$rbI z`PB_I(*pib?8w+QnLuzinF#tz!#~O7Uyj2crX(H2+;SMGd`Q-dDeZJg~R9-H` z2dt0ojdb<0GRCSakzy<7)^sV@oxK5U00~|L(o5O^MvdV2kI8@7IE>$H@<$LT+>{5l zt9YSg$Ci-AHE*0-v#3rWm(QHECWe(Ik%hc+R+_$}Cnj^s!^3tx7x@hKh6@@RA@uAE z3KQR7*Huzu1z;07qSw?><%gz&L#4;$zG*J_QiIuaYAJ6kyvZ$PxYMdB3UYSh*q@T^ zhVa%sSHw#3q@6!2qX3};s^xkZduLFF|9mM>JEhnm8Pzh*2c`?! z27r+G1Cd{`?HP`#jPab0nRo7Rug>H!XkFv#HyzSY!N{e?Z8?8VbKMFXu6JU=>9RL`@hmHG67-zs!P>v7RAtk+N z+4$+5DWHPCOYqYG0LurGN4AsI3htwsOXTUt?ja)Jdne0&;zXsQ(FMj-&jVUef709L zg>n^HTmQreJ;Pa-V^Kxz zW@{+B%hpr5G~maICd?b#g}a0?41a3`Vb5-Y$D>-!2Jorycdau#qct^Pz5Z z1Z)u_klmyi)SKDahs3R;B4;W)@FOe9Wx1L0ZA8_sZKCy>*@#zwm2je?!vfBa1@d)x zZwL5|9uTV`$rr7nob9Pk33u-04NC>UM+D^G3euolI2d25$Y$GsxN;awFJn{N43%Sn zMOrrnW0pH9*0sUFqM^I&y=K3K@5IUn)C|jo%R*u}!kC|N;pi8j`S=ghQjluEmRCW0 z+chy^+D{c<*=)+EL(T;CuDfC6*n@}q}kdEuSHUv0`MZ_tD~Wc_AQo6 zPK8lVh9v{Mu1E0@0jwDs|L?j$5g3_O=IYKD>7~EaDA8prp|>K&NzjG#48&V{>?$q6 zkH1dYKUQ~?kGG>QR0(fhmHUhQrKF@Jfz9Rd^C91Y`@l!alERgEqC@N1;Q!u6J=_`Q`2n<3pV`(F+= zHteeWLn{L=95M^_ZlD-CoS8jnSirdjmKbg;P@BxzfB=RCg98M9ySP7zM)a7HzAPS> zq*(83fuSFV?c)L}lh{!TdK@AFwV8)V?LoDqS&q}n7*&ta_zO=PR5(VjUyswl6k(q@rgTWr-K({rvrNe~tAd+6N8Pc$Bh!epYrT^e4cOKuiB27|?E*WW)r zFVr{LPwTaYcs+N=cdzu?D5ORX0XxO+%TY@Rao5RSOXH8ySglZ_e#6j5ixg1&U2Il_ zCH>Ir{ktk;z7glC0bDgB)v4%8%8J-j1zL-#Bk&>0qP94X}cSI_qcs1W_sbhzYkO!)*iwg*ub4Q|p zv@MDG5W>!%$##m*8{zO78 zpfi*}d+!s)Ky{6EOl|hc5B}<@ZWUQrZH@j~s~r>U(+8$Vp^G-8lT49y?`0+5?C!mn zTIfIGneSfYHPp1*TKm)6)* zqz;-RyU|YsKVmFSF1W6J^@TE;GfS)QrhN3EVO6Ba;Bgj)l~i#%A+cLh3`UuMug-K} zDVa7oeT%Z-+czMDivGP0?t__0^Hjr&mzm9CbQIw*ulr_)Oc%Wgw~NHuPVw}oX0~3& z%*cz8c+Z$kPl;VyU%+H8D(%rt;!ai21;e7hxYe3g>G*h8Bk;9vmzWG)e`sL~zV~sn zie4{&<}TzZ(oT3Cq8^^L+DiF%w$v|V0VkHM`Y%Z)GOtRV%ID^=icH2RrLyr9zo#)J zh~{m=l_$bmxUVr=FN*Pi*bJdG`=NgVeXUx-->>8U7b!7(z@+dMnx8iqurrh z@o}1;97mB<0u{|xK@6s+53o;Jr}IOcPVchJOST@3kbK;d``~uMRcgBHPm@g1WsXuH z$>wvkmU#~gR~2e1^w?>&`N`9jL8Ee!P=9Km6hRJWVCe*B2xGwBJbuVuY=-MTb;!aF z3xnX`p08D)57q5Gsvb~z>Gk6R%MIvF+FNnTY7v_Bw%JVI54PA{`FXffjM&$OxB&v# z4fLA!pe*Uamcq=P-4J1eX%vn7_f0y z>$1K(tMj7@4c3PTHcDYNbALc0AZ*8PrqQlFGcyyU2`6p7|GcVIIt(@ze`kU#X7`C7 zNts2MUNeRf?Z?i9hHPYQhat!n4M1=aJemOPCZmKXTHcqU|Af%iTQ6IOxFDC^CU8S*I`|d{bf4@b^(%wiGv7%T~UwjVsXE% z$?F#`B+0!KKpaRL)&|6!#3KV`P844>v^;KmWBm=BkG~@R&58zE!uhIvOsa93GNd*8`(g!P^9mgrr7?RX2ERcF4!LBFy6%!Hwy1Q8BOQe1l~JlQ9HJ?? z8M^W7*Ew`6G@&Yxo!YIOTFQ`K)tZ(Fq5N;d%KT5~E`CP<&i=@_SJ9cLVHR`&`2k%( z46Nr$6W!kDw-v|Iq37oVexwu5^!yT9ofUCE{NJi!yaLJpphJN8vO3xQZAs$C#|Pkt ztqCVm`FI%&Ox5>2&l(EXY$>-dik=!C6|!?_$2HlK|DRs`w=cjr16k1jHs?QG0vH;8 zPZ{C=`#Zdx|I^{8DF%EJ(f-e{JQ)P|jKl7H{LNH-uQFC-fa<7>&axkz%|1Ds3z<5a z1OGtWoZN3;b8)@q=GEfl72@I-;^AfE - - - - - -
-
-

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);