From bcf5f4e2a6c317db25fa7a76efe1796240a41145 Mon Sep 17 00:00:00 2001 From: Ruben Date: Fri, 12 Apr 2024 16:48:28 +0200 Subject: [PATCH 01/21] WIP: Email multi event AB#27182 --- services/API-service/.gitignore | 1 - services/API-service/package-lock.json | 233 ++++++- services/API-service/package.json | 1 + .../api/country/country-time-zone-mapping.ts | 11 + .../src/api/event/event-map-image.entity.ts | 2 +- .../dto/admin-area-notification-info.dto.ts | 4 + .../dto/content-trigger-email.dto.ts | 16 + .../dto/notification-date-per-event.dto.ts | 26 + .../email/email-template.service.ts | 527 ++++++++++++++ .../api/notification/email/email.service.ts | 657 +----------------- .../src/api/notification/email/html/base.html | 8 +- .../email/html/email-body-trigger-event.html | 132 ++++ .../email/html/email-body-warning-event.html | 120 ++++ .../email/html/email-table-event.html | 96 +++ .../email/html/email-table-trigger-row.html | 36 + .../email/html/email-table-warning-row.html | 22 + .../email/html/header-event-overview.html | 2 +- .../notification/email/html/map-image.html | 8 +- .../email/html/social-media-link.html | 4 +- .../email/html/trigger-finished.html | 10 +- .../email/html/trigger-notification.html | 30 +- .../api/notification/email/icons/trigger.png | Bin 0 -> 760 bytes .../notification/email/icons/warning-low.png | Bin 0 -> 770 bytes .../email/icons/warning-medium.png | Bin 0 -> 785 bytes .../format-action-unit-value.helper.ts | 16 + .../notification-content.module.ts | 1 + .../notification-content.service.ts | 338 +++++---- .../notification/notification.controller.ts | 2 +- .../api/notification/notification.module.ts | 3 +- .../notification/whatsapp/whatsapp.service.ts | 11 +- 30 files changed, 1462 insertions(+), 855 deletions(-) create mode 100644 services/API-service/src/api/country/country-time-zone-mapping.ts create mode 100644 services/API-service/src/api/notification/dto/admin-area-notification-info.dto.ts create mode 100644 services/API-service/src/api/notification/dto/content-trigger-email.dto.ts create mode 100644 services/API-service/src/api/notification/dto/notification-date-per-event.dto.ts create mode 100644 services/API-service/src/api/notification/email/email-template.service.ts create mode 100644 services/API-service/src/api/notification/email/html/email-body-trigger-event.html create mode 100644 services/API-service/src/api/notification/email/html/email-body-warning-event.html create mode 100644 services/API-service/src/api/notification/email/html/email-table-event.html create mode 100644 services/API-service/src/api/notification/email/html/email-table-trigger-row.html create mode 100644 services/API-service/src/api/notification/email/html/email-table-warning-row.html create mode 100644 services/API-service/src/api/notification/email/icons/trigger.png create mode 100644 services/API-service/src/api/notification/email/icons/warning-low.png create mode 100644 services/API-service/src/api/notification/email/icons/warning-medium.png create mode 100644 services/API-service/src/api/notification/helpers/format-action-unit-value.helper.ts diff --git a/services/API-service/.gitignore b/services/API-service/.gitignore index 3aa5681ba..2747035a3 100644 --- a/services/API-service/.gitignore +++ b/services/API-service/.gitignore @@ -1,5 +1,4 @@ # IDE / Editors -.vscode .idea *.sublime-project *.sublime-workspace diff --git a/services/API-service/package-lock.json b/services/API-service/package-lock.json index 1ea0e3dcf..c54608ee2 100644 --- a/services/API-service/package-lock.json +++ b/services/API-service/package-lock.json @@ -22,6 +22,7 @@ "crypto-js": "^4.0.0", "cryptr": "^6.0.2", "csv-parser": "^3.0.0", + "ejs": "^3.1.9", "jsonwebtoken": "^8.1.1", "mailchimp-api-v3": "^1.15.0", "mysql": "^2.15.0", @@ -3043,6 +3044,11 @@ "node": ">=4" } }, + "node_modules/async": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.5.tgz", + "integrity": "sha512-baNZyqaaLhyLVKm/DlvdW051MSgO6b8eVfIezl9E5PqWxFgzLm/wQntEW4zOytVburDEr0JlALEpdOFwvErLsg==" + }, "node_modules/async-limiter": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/async-limiter/-/async-limiter-1.0.1.tgz", @@ -3453,7 +3459,6 @@ "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -3959,8 +3964,7 @@ "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", - "dev": true + "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=" }, "node_modules/concat-stream": { "version": "1.6.2", @@ -4587,6 +4591,20 @@ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" }, + "node_modules/ejs": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.9.tgz", + "integrity": "sha512-rC+QVNMJWv+MtPgkt0y+0rVEIdbtxVADApW9JXrUVlzHetgcyczP/E7DJmWJ4fJCZF2cPcBk0laWO9ZHMG3DmQ==", + "dependencies": { + "jake": "^10.8.5" + }, + "bin": { + "ejs": "bin/cli.js" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -5667,6 +5685,33 @@ "dev": true, "optional": true }, + "node_modules/filelist": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz", + "integrity": "sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==", + "dependencies": { + "minimatch": "^5.0.1" + } + }, + "node_modules/filelist/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/filelist/node_modules/minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/fill-range": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", @@ -6870,6 +6915,87 @@ "node": ">=6" } }, + "node_modules/jake": { + "version": "10.8.7", + "resolved": "https://registry.npmjs.org/jake/-/jake-10.8.7.tgz", + "integrity": "sha512-ZDi3aP+fG/LchyBzUM804VjddnwfSfsdeYkwt8NcbKRvo4rFkjhs456iLFn3k2ZUWvNe4i48WACDbza8fhq2+w==", + "dependencies": { + "async": "^3.2.3", + "chalk": "^4.0.2", + "filelist": "^1.0.4", + "minimatch": "^3.1.2" + }, + "bin": { + "jake": "bin/cli.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jake/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jake/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/jake/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/jake/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + }, + "node_modules/jake/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/jake/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/jest": { "version": "24.9.0", "resolved": "https://registry.npmjs.org/jest/-/jest-24.9.0.tgz", @@ -9815,7 +9941,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, "dependencies": { "brace-expansion": "^1.1.7" }, @@ -16138,6 +16263,11 @@ "integrity": "sha512-+Ryf6g3BKoRc7jfp7ad8tM4TtMiaWvbF/1/sQcZPkkS7ag3D5nMBCe2UfOTONtAkaG0tO0ij3C5Lwmf1EiyjHg==", "dev": true }, + "async": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.5.tgz", + "integrity": "sha512-baNZyqaaLhyLVKm/DlvdW051MSgO6b8eVfIezl9E5PqWxFgzLm/wQntEW4zOytVburDEr0JlALEpdOFwvErLsg==" + }, "async-limiter": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/async-limiter/-/async-limiter-1.0.1.tgz", @@ -16479,7 +16609,6 @@ "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, "requires": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -16889,8 +17018,7 @@ "concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", - "dev": true + "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=" }, "concat-stream": { "version": "1.6.2", @@ -17390,6 +17518,14 @@ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" }, + "ejs": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.9.tgz", + "integrity": "sha512-rC+QVNMJWv+MtPgkt0y+0rVEIdbtxVADApW9JXrUVlzHetgcyczP/E7DJmWJ4fJCZF2cPcBk0laWO9ZHMG3DmQ==", + "requires": { + "jake": "^10.8.5" + } + }, "emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -18211,6 +18347,32 @@ "dev": true, "optional": true }, + "filelist": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz", + "integrity": "sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==", + "requires": { + "minimatch": "^5.0.1" + }, + "dependencies": { + "brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "requires": { + "balanced-match": "^1.0.0" + } + }, + "minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "requires": { + "brace-expansion": "^2.0.1" + } + } + } + }, "fill-range": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", @@ -19154,6 +19316,62 @@ "resolved": "https://registry.npmjs.org/iterare/-/iterare-1.2.1.tgz", "integrity": "sha512-RKYVTCjAnRthyJes037NX/IiqeidgN1xc3j1RjFfECFp28A1GVwK9nA+i0rJPaHqSZwygLzRnFlzUuHFoWWy+Q==" }, + "jake": { + "version": "10.8.7", + "resolved": "https://registry.npmjs.org/jake/-/jake-10.8.7.tgz", + "integrity": "sha512-ZDi3aP+fG/LchyBzUM804VjddnwfSfsdeYkwt8NcbKRvo4rFkjhs456iLFn3k2ZUWvNe4i48WACDbza8fhq2+w==", + "requires": { + "async": "^3.2.3", + "chalk": "^4.0.2", + "filelist": "^1.0.4", + "minimatch": "^3.1.2" + }, + "dependencies": { + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "requires": { + "color-convert": "^2.0.1" + } + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==" + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "requires": { + "has-flag": "^4.0.0" + } + } + } + }, "jest": { "version": "24.9.0", "resolved": "https://registry.npmjs.org/jest/-/jest-24.9.0.tgz", @@ -21654,7 +21872,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, "requires": { "brace-expansion": "^1.1.7" } diff --git a/services/API-service/package.json b/services/API-service/package.json index cbacf60be..74d68ab0d 100644 --- a/services/API-service/package.json +++ b/services/API-service/package.json @@ -40,6 +40,7 @@ "crypto-js": "^4.0.0", "cryptr": "^6.0.2", "csv-parser": "^3.0.0", + "ejs": "^3.1.9", "jsonwebtoken": "^8.1.1", "mailchimp-api-v3": "^1.15.0", "mysql": "^2.15.0", diff --git a/services/API-service/src/api/country/country-time-zone-mapping.ts b/services/API-service/src/api/country/country-time-zone-mapping.ts new file mode 100644 index 000000000..67cf4657c --- /dev/null +++ b/services/API-service/src/api/country/country-time-zone-mapping.ts @@ -0,0 +1,11 @@ +export const CountryTimeZoneMapping: { [key: string]: string } = { + UGA: 'Africa/Kampala', + KEN: 'Africa/Nairobi', + ETH: 'Africa/Addis_Ababa', + ZMB: 'Africa/Lusaka', + MWI: 'Africa/Blantyre', + ZWE: 'Africa/Harare', + EGY: 'Africa/Cairo', + PHL: 'Asia/Manila', + SSD: 'Africa/Juba', +}; diff --git a/services/API-service/src/api/event/event-map-image.entity.ts b/services/API-service/src/api/event/event-map-image.entity.ts index 9cc6b9a15..36c0f1587 100644 --- a/services/API-service/src/api/event/event-map-image.entity.ts +++ b/services/API-service/src/api/event/event-map-image.entity.ts @@ -16,7 +16,7 @@ export class EventMapImageEntity { public id: string; @Column({ type: 'bytea' }) - public image: any; + public image: Buffer; @ApiProperty({ example: 'SSD' }) @ManyToOne((): typeof CountryEntity => CountryEntity) diff --git a/services/API-service/src/api/notification/dto/admin-area-notification-info.dto.ts b/services/API-service/src/api/notification/dto/admin-area-notification-info.dto.ts new file mode 100644 index 000000000..c5afda2a9 --- /dev/null +++ b/services/API-service/src/api/notification/dto/admin-area-notification-info.dto.ts @@ -0,0 +1,4 @@ +export class AdminAreaLabel { + singular: string; + plural: string; +} diff --git a/services/API-service/src/api/notification/dto/content-trigger-email.dto.ts b/services/API-service/src/api/notification/dto/content-trigger-email.dto.ts new file mode 100644 index 000000000..e5bda8d48 --- /dev/null +++ b/services/API-service/src/api/notification/dto/content-trigger-email.dto.ts @@ -0,0 +1,16 @@ +import { CountryEntity } from '../../country/country.entity'; +import { DisasterType } from '../../disaster/disaster-type.enum'; +import { IndicatorMetadataEntity } from '../../metadata/indicator-metadata.entity'; +import { AdminAreaLabel } from './admin-area-notification-info.dto'; +import { NotificationDataPerEventDto } from './notification-date-per-event.dto'; + +export class ContentTriggerEmail { + public disasterType: DisasterType; + public disasterTypeLabel: string; + public indicatorMetadata: IndicatorMetadataEntity; + public dataPerEvent: NotificationDataPerEventDto[]; + public mapImageData: any[]; + public defaultAdminLevel: number; + public defaultAdminAreaLabel: AdminAreaLabel; + public country: CountryEntity; // Ensure that is has the following relations 'disasterTypes', 'notificationInfo','countryDisasterSettings','countryDisasterSettings.activeLeadTimes', +} diff --git a/services/API-service/src/api/notification/dto/notification-date-per-event.dto.ts b/services/API-service/src/api/notification/dto/notification-date-per-event.dto.ts new file mode 100644 index 000000000..96baf5862 --- /dev/null +++ b/services/API-service/src/api/notification/dto/notification-date-per-event.dto.ts @@ -0,0 +1,26 @@ +import { TriggeredArea } from '../../../shared/data.model'; +import { LeadTime } from '../../admin-area-dynamic-data/enum/lead-time.enum'; + +export class NotificationDataPerEventDto { + triggerStatusLabel: TriggerStatusLabelEnum; + eventName: string; + disasterSpecificCopy: DisasterSpecificCopy; + firstLeadTime: LeadTime; + triggeredAreas: TriggeredArea[]; + nrOfTriggeredAreas: number; + startDateEventString: string; + totalAffectectedOfIndicator: number; + mapImage?: Buffer; +} + +export enum TriggerStatusLabelEnum { + Trigger = 'Trigger', + Warning = 'Warning', +} + +export class DisasterSpecificCopy { + eventStatus: string; + extraInfo: string; + leadTimeString?: string; + timestamp?: string; +} diff --git a/services/API-service/src/api/notification/email/email-template.service.ts b/services/API-service/src/api/notification/email/email-template.service.ts new file mode 100644 index 000000000..103489e2b --- /dev/null +++ b/services/API-service/src/api/notification/email/email-template.service.ts @@ -0,0 +1,527 @@ +import { Injectable } from '@nestjs/common'; +import { ContentTriggerEmail } from '../dto/content-trigger-email.dto'; +import { LeadTime } from '../../admin-area-dynamic-data/enum/lead-time.enum'; +import { + NotificationDataPerEventDto, + TriggerStatusLabelEnum, +} from '../dto/notification-date-per-event.dto'; +import * as ejs from 'ejs'; +import * as fs from 'fs'; +import { CountryTimeZoneMapping } from '../../country/country-time-zone-mapping'; +import { DisasterType } from '../../disaster/disaster-type.enum'; +import { EventSummaryCountry } from '../../../shared/data.model'; +import { CountryEntity } from '../../country/country.entity'; + +const emailFolder = './src/api/notification/email'; +const emailTemplateFolder = `${emailFolder}/html`; +const emailIconFolder = `${emailFolder}/icons`; + +class ReplaceKeyValue { + replaceKey: string; + replaceValue: string; +} + +@Injectable() +export class EmailTemplateService { + private placeholderToday = '(TODAY)'; + + public createHtmlForTriggerEmail( + contentForEmail: ContentTriggerEmail, + date: Date, + ): string { + const replaceKeyValues = this.createReplaceKeyValuesTrigger( + contentForEmail, + date, + ); + return this.formatEmail(replaceKeyValues); + } + + // TODO REFACTOR this to use a DTO (ContentTriggerFinishedEmail) instead of multiple parameters + public createHtmlForTriggerFinishedEmail( + country: CountryEntity, + disasterType: DisasterType, + finishedEvent: EventSummaryCountry, + disasterTypeLabel: string, + date: Date, + ): string { + const replaceKeyValues = this.createReplaceKeyValuesTriggerFinished( + country, + disasterType, + finishedEvent, + disasterTypeLabel, + date, + ); + return this.formatEmail(replaceKeyValues); + } + + private createReplaceKeyValuesTrigger( + contentForEmail: ContentTriggerEmail, + date: Date, + ): ReplaceKeyValue[] { + const country = contentForEmail.country; + const disasterType = contentForEmail.disasterType; + const keyValueReplaceList = [ + { + replaceKey: 'emailBody', + replaceValue: this.getEmailBody(false), + }, + { + replaceKey: 'headerEventOverview', + replaceValue: this.getHeaderEventOverview(contentForEmail.dataPerEvent), + }, + { + replaceKey: 'socialMediaPart', + replaceValue: this.getSocialMediaHtml(contentForEmail.country), + }, + { + replaceKey: 'tablesStacked', + replaceValue: this.getTablesForEvents(contentForEmail), + }, + { + replaceKey: this.placeholderToday, + replaceValue: date.toLocaleDateString('default', { + day: '2-digit', + month: 'short', + year: 'numeric', + }), + }, + { + replaceKey: 'eventListBody', + replaceValue: this.getEventListBody(contentForEmail), + }, + { + replaceKey: 'imgLogo', + replaceValue: country.notificationInfo.logo[disasterType], + }, + { + replaceKey: 'triggerStatement', + replaceValue: country.notificationInfo.triggerStatement[disasterType], + }, + { + replaceKey: 'mapImagePart', + replaceValue: this.getMapImageHtml(contentForEmail), + }, + { + replaceKey: 'linkDashboard', + replaceValue: process.env.DASHBOARD_URL, + }, + { + replaceKey: 'linkEapSop', + replaceValue: country.countryDisasterSettings.find( + (s) => s.disasterType === disasterType, + ).eapLink, + }, + { + replaceKey: 'socialMediaLink', + replaceValue: country.notificationInfo.linkSocialMediaUrl, + }, + { + replaceKey: 'socialMediaType', + replaceValue: country.notificationInfo.linkSocialMediaType, + }, + { + replaceKey: 'disasterType', + replaceValue: contentForEmail.disasterTypeLabel, + }, + { + replaceKey: 'videoPdfLinks', + replaceValue: this.getVideoPdfLinks( + country.notificationInfo.linkVideo, + country.notificationInfo.linkPdf, + ), + }, + ]; + return keyValueReplaceList; + } + + private createReplaceKeyValuesTriggerFinished( + country: CountryEntity, + disasterType: DisasterType, + event: EventSummaryCountry, + disasterTypeLabel: string, + date: Date, + ): ReplaceKeyValue[] { + const keyValueReplaceList = [ + { + replaceKey: 'emailBody', + replaceValue: this.getEmailBody(true), + }, + { + replaceKey: 'headerEventOverview', + replaceValue: '', + }, + { + replaceKey: 'imgLogo', + replaceValue: country.notificationInfo.logo[disasterType], + }, + { + replaceKey: 'startDate', + replaceValue: event.startDate, + }, + { + replaceKey: 'linkDashboard', + replaceValue: process.env.DASHBOARD_URL, + }, + { + replaceKey: 'socialMediaPart', + replaceValue: this.getSocialMediaHtml(country), + }, + { + replaceKey: 'linkEapSop', + replaceValue: country.countryDisasterSettings.find( + (s) => s.disasterType === disasterType, + ).eapLink, + }, + { + replaceKey: 'socialMediaLink', + replaceValue: country.notificationInfo.linkSocialMediaUrl, + }, + { + replaceKey: 'socialMediaType', + replaceValue: country.notificationInfo.linkSocialMediaType, + }, + { + replaceKey: 'videoPdfLinks', + replaceValue: this.getVideoPdfLinks( + country.notificationInfo.linkVideo, + country.notificationInfo.linkPdf, + ), + }, + { + replaceKey: 'disasterType', + replaceValue: disasterTypeLabel, + }, + { + replaceKey: this.placeholderToday, + replaceValue: date.toLocaleDateString('default', { + day: '2-digit', + month: 'short', + year: 'numeric', + }), + }, + ]; + return keyValueReplaceList; + } + + private getEmailBody(triggerFinished: boolean): string { + if (triggerFinished) { + return fs.readFileSync( + './src/api/notification/email/html/trigger-finished.html', + 'utf8', + ); + } else { + return fs.readFileSync( + './src/api/notification/email/html/trigger-notification.html', + 'utf8', + ); + } + } + + private getHeaderEventOverview( + eventsData: NotificationDataPerEventDto[], + ): string { + const leadTimeListShort = this.getEventListShort(eventsData); + let headerEventOverview = fs.readFileSync( + './src/api/notification/email/html/header-event-overview.html', + 'utf8', + ); + headerEventOverview = ejs.render(headerEventOverview, { + eventListHeader: leadTimeListShort, + }); + return headerEventOverview; + } + + private getVideoPdfLinks(videoLink: string, pdfLink: string) { + // TODO: Use ejs template + const linkVideoHTML = ` + video`; + + const linkPdfHTML = `PDF`; + let videoStr = ''; + if (videoLink) { + videoStr = ' ' + linkVideoHTML; + } + let pdfStr = ''; + if (pdfLink) { + pdfStr = ' ' + linkPdfHTML; + } + let orStr = ''; + if (videoStr && pdfStr) { + orStr = ' or'; + } + if (videoStr || pdfStr) { + return `See instructions for the IBF-portal in${videoStr}${orStr}${pdfStr}.`; + } + } + + private getSocialMediaHtml(country: CountryEntity): string { + if (country.notificationInfo.linkSocialMediaType) { + return fs.readFileSync( + './src/api/notification/email/html/social-media-link.html', + 'utf8', + ); + } else { + return ''; + } + } + + private getMapImageHtml(contentForEmail: ContentTriggerEmail): string { + let html = ''; + for (const event of contentForEmail.dataPerEvent) { + const mapImage = event.mapImage; + if (mapImage) { + let eventHtml = fs.readFileSync( + './src/api/notification/email/html/map-image.html', + 'utf8', + ); + const replacements = { + mapImgSrc: this.getMapImgSrc( + contentForEmail.country.countryCodeISO3, + contentForEmail.disasterType, + event.eventName, + ), + mapImgDescription: this.getMapImageDescription( + contentForEmail.disasterType, + ), + eventName: event.eventName ? ` for '${event.eventName}'` : '', + }; + eventHtml = ejs.render(eventHtml, replacements); + html += eventHtml; + } + } + return html; + } + + private getMapImgSrc( + countryCodeISO3: string, + disasterType: DisasterType, + eventName: string, + ): string { + const src = `${ + process.env.NG_API_URL + }/event/event-map-image/${countryCodeISO3}/${disasterType}/${ + eventName || 'no-name' + }`; + + return src; + } + + private getMapImageDescription(disasterType: DisasterType): string { + switch (disasterType) { + case DisasterType.Floods: + return 'The triggered areas are outlined in purple. The potential flood extent is shown in red.
'; + default: + return ''; + } + } + + private formatEmail(emailKeyValueReplaceList: ReplaceKeyValue[]): string { + let template = fs.readFileSync( + './src/api/notification/email/html/base.html', + 'utf8', + ); + const replacements = emailKeyValueReplaceList.reduce( + (acc, { replaceKey, replaceValue }) => { + acc[replaceKey] = replaceValue; + return acc; + }, + {}, + ); + + let emailHtml = template; + let previousHtml = null; + + // This loop is needed to handle nested EJS tags. It repeatedly renders the template + // until there are no more EJS tags left to render. This is necessary because EJS + // doesn't render nested tags in one pass. + while (emailHtml !== previousHtml) { + previousHtml = emailHtml; + emailHtml = ejs.render(previousHtml, replacements); + } + + return emailHtml; + } + + // TODO refactor this to use ejs package to render the html + private getEventListShort( + dataPerEvent: NotificationDataPerEventDto[], + ): string { + let text = ''; + for (const event of dataPerEvent) { + const leadTimeString = event.disasterSpecificCopy.leadTimeString + ? event.disasterSpecificCopy.leadTimeString + : event.firstLeadTime; + const timestamp = event.disasterSpecificCopy.timestamp + ? ` at ${event.disasterSpecificCopy.timestamp}` + : ''; + text += `${event.triggerStatusLabel} for ${event.eventName}: ${ + event.disasterSpecificCopy.extraInfo || + event.firstLeadTime === LeadTime.hour0 + ? leadTimeString + : `${event.firstLeadTime}${timestamp}` + }
`; + } + return text; + } + + private getTablesForEvents(emailContent: ContentTriggerEmail): string { + const adminAreaLabelsParent = + emailContent.country.adminRegionLabels[ + String(emailContent.defaultAdminLevel - 1) + ]; + + return emailContent.dataPerEvent + .map((event) => { + const data = { + hazard: emailContent.disasterTypeLabel, + triggerStatusLabel: event.triggerStatusLabel, + eventName: event.eventName, + expectedTriggerDate: event.firstLeadTime, + expectedExposedAdminBoundary: event.nrOfTriggeredAreas, + defaulAdminAreaLabelSingular: + emailContent.defaultAdminAreaLabel.singular, + defaulAdminAreaLabelPlural: + emailContent.defaultAdminAreaLabel.plural.toLocaleLowerCase(), + defaultAdminAreaLabelParent: adminAreaLabelsParent.singular, + indicatorLabel: emailContent.indicatorMetadata.label, + indicatorUnit: emailContent.indicatorMetadata.unit, + triangleIcon: this.getTriangleIcon(event.triggerStatusLabel), + tableRows: this.getTablesRows(event), + color: + event.triggerStatusLabel === TriggerStatusLabelEnum.Trigger + ? '#940000' + : '#da7c00', + }; + + const templatePath = `${emailTemplateFolder}/email-table-event.html`; + + let template = fs.readFileSync(templatePath, 'utf8'); + + const result = ejs.render(template, data); + return result; + }) + .join(''); + } + + private getTablesRows(event: NotificationDataPerEventDto) { + return event.triggeredAreas + .map((area) => { + const areaTemplatePath = + TriggerStatusLabelEnum.Trigger === event.triggerStatusLabel + ? `${emailTemplateFolder}/email-table-trigger-row.html` + : `${emailTemplateFolder}//email-table-warning-row.html`; + const areaTemplate = fs.readFileSync(areaTemplatePath, 'utf8'); + const areaData = { + affectectedOfIndicator: area.actionsValue, + adminBoundary: area.displayName ? area.displayName : area.name, + higherAdminBoundary: area.nameParent, + }; + + return ejs.render(areaTemplate, areaData); + }) + .join(''); + } + + private getEventListBody(emailContent: ContentTriggerEmail): string { + return emailContent.dataPerEvent + .map((event) => { + const data = { + hazard: emailContent.disasterTypeLabel, + triggerStatusLabel: event.triggerStatusLabel, + eventName: event.eventName, + level: event.disasterSpecificCopy.eventStatus, + expectedWarningDate: event.disasterSpecificCopy.leadTimeString, + nrOfTriggeredAreas: event.nrOfTriggeredAreas, + expectedTriggerDate: event.firstLeadTime, + expectedExposedAdminBoundary: event.nrOfTriggeredAreas, + issuedDate: event.disasterSpecificCopy.timestamp, + startDateEventString: event.startDateEventString, + defaulAdminAreaLabel: + emailContent.defaultAdminAreaLabel.plural.toLocaleLowerCase(), + indicatorLabel: emailContent.indicatorMetadata.label, + totalAffectectedOfIndicator: event.totalAffectectedOfIndicator, + indicatorUnit: emailContent.indicatorMetadata.unit, + currentDate: this.getCurrentDateTimeString( + emailContent.country.countryCodeISO3, + ), + timezone: + CountryTimeZoneMapping[emailContent.country.countryCodeISO3], + triangleIcon: this.getTriangleIcon(event.triggerStatusLabel), + leadTime: event.firstLeadTime.replace('-', ' '), + }; + + const templatePath = + TriggerStatusLabelEnum.Trigger === event.triggerStatusLabel + ? `${emailTemplateFolder}/email-body-trigger-event.html` + : `${emailTemplateFolder}/email-body-warning-event.html`; + + let template = fs.readFileSync(templatePath, 'utf8'); + + return ejs.render(template, data); + }) + .join(''); + } + + private getCurrentDateTimeString(countryCodeISO3: string): string { + const date = new Date(); + + const timeZone = CountryTimeZoneMapping[countryCodeISO3]; + + const options: Intl.DateTimeFormatOptions = { + weekday: 'long', + day: '2-digit', + month: 'long', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + timeZone: timeZone, + }; + + return date.toLocaleString('default', options); + } + + private getTriangleIcon(triggerStatusLabel) { + let fileName = ''; + // Still need implement the difference between medium and low warning + if (triggerStatusLabel === TriggerStatusLabelEnum.Trigger) { + fileName = 'trigger.png'; + } else { + fileName = 'warning-medium.png'; + } + const filePath = `${emailIconFolder}/${fileName}`; + const imageDataURL = this.getPngImageAsDataURL(filePath); + return imageDataURL; + } + + private getPngImageAsDataURL(relativePath) { + const imageBuffer = fs.readFileSync(relativePath); + const imageDataURL = `data:image/png;base64,${imageBuffer.toString( + 'base64', + )}`; + + return imageDataURL; + } +} diff --git a/services/API-service/src/api/notification/email/email.service.ts b/services/API-service/src/api/notification/email/email.service.ts index e88a2caf0..ca10269a8 100644 --- a/services/API-service/src/api/notification/email/email.service.ts +++ b/services/API-service/src/api/notification/email/email.service.ts @@ -1,34 +1,23 @@ -import { AdminAreaDynamicDataService } from './../../admin-area-dynamic-data/admin-area-dynamic-data.service'; -import { LeadTimeEntity } from './../../lead-time/lead-time.entity'; + import { CountryEntity } from './../../country/country.entity'; import { Injectable } from '@nestjs/common'; -import { EventService } from '../../event/event.service'; -import fs from 'fs'; import Mailchimp from 'mailchimp-api-v3'; -import { IndicatorMetadataEntity } from '../../metadata/indicator-metadata.entity'; -import { LeadTime } from '../../admin-area-dynamic-data/enum/lead-time.enum'; -import { DynamicIndicator } from '../../admin-area-dynamic-data/enum/dynamic-data-unit'; + import { DisasterType } from '../../disaster/disaster-type.enum'; import { EventSummaryCountry } from '../../../shared/data.model'; import { NotificationContentService } from './../notification-content/notification-content.service'; - -class ReplaceKeyValue { - replaceKey: string; - replaceValue: string; -} +import { EmailTemplateService } from './email-template.service'; @Injectable() export class EmailService { - private placeholderToday = '(TODAY)'; private fromEmail = process.env.SUPPORT_EMAIL_ADDRESS; private fromEmailName = 'IBF portal'; private mailchimp = new Mailchimp(process.env.MC_API); public constructor( - private readonly eventService: EventService, - private readonly adminAreaDynamicDataService: AdminAreaDynamicDataService, private readonly notificationContentService: NotificationContentService, + private readonly emailTemplateService: EmailTemplateService, ) {} private async getSegmentId( @@ -56,13 +45,18 @@ export class EmailService { activeEvents: EventSummaryCountry[], date?: Date, ): Promise { - const replaceKeyValues = await this.createReplaceKeyValuesTrigger( - country, - disasterType, - activeEvents, - date ? new Date(date) : new Date(), + date = date ? new Date(date) : new Date(); + + const contentForEmail = + await this.notificationContentService.getContentTriggerNotification( + country, + disasterType, + activeEvents, + ); + const emailHtml = this.emailTemplateService.createHtmlForTriggerEmail( + contentForEmail, + date, ); - const emailHtml = this.formatEmail(replaceKeyValues); const emailSubject = `IBF ${( await this.notificationContentService.getDisasterTypeLabel(disasterType) ).toLowerCase()} notification`; @@ -80,16 +74,17 @@ export class EmailService { finishedEvent: EventSummaryCountry, date?: Date, ): Promise { - const replaceKeyValues = await this.createReplaceKeyValuesTriggerFinished( - country, - disasterType, - finishedEvent, - date ? new Date(date) : new Date(), - ); - const emailHtml = this.formatEmail(replaceKeyValues); - const emailSubject = `IBF ${( - await this.notificationContentService.getDisasterTypeLabel(disasterType) - ).toLowerCase()} trigger is now below threshold`; + const disasterTypeLabel = + await this.notificationContentService.getDisasterTypeLabel(disasterType); + const emailHtml = + this.emailTemplateService.createHtmlForTriggerFinishedEmail( + country, + disasterType, + finishedEvent, + disasterTypeLabel, + date ? new Date(date) : new Date(), + ); + const emailSubject = `IBF ${disasterTypeLabel.toLowerCase()} trigger is now below threshold`; this.sendEmail( emailSubject, emailHtml, @@ -134,604 +129,4 @@ export class EmailService { ); await this.mailchimp.post(`/campaigns/${createResult.id}/actions/send`); } - - private async createReplaceKeyValuesTrigger( - country: CountryEntity, - disasterType: DisasterType, - events: EventSummaryCountry[], - date: Date, - ): Promise { - const keyValueReplaceList = [ - { - replaceKey: '(EMAIL-BODY)', - replaceValue: this.getEmailBody(false), - }, - { - replaceKey: '(HEADER-EVENT-OVERVIEW)', - replaceValue: await this.getHeaderEventOverview( - country, - disasterType, - events, - date, - ), - }, - { - replaceKey: '(SOCIAL-MEDIA-PART)', - replaceValue: this.getSocialMediaHtml(country), - }, - { - replaceKey: '(TABLES-stacked)', - replaceValue: await this.getTriggerOverviewTables( - country, - disasterType, - events, - date, - ), - }, - { - replaceKey: this.placeholderToday, - replaceValue: date.toLocaleDateString('default', { - day: '2-digit', - month: 'short', - year: 'numeric', - }), - }, - { - replaceKey: '(EVENT-LIST-BODY)', - replaceValue: ( - await this.getLeadTimeList(country, disasterType, events, date) - )['leadTimeListLong'], - }, - { - replaceKey: '(IMG-LOGO)', - replaceValue: country.notificationInfo.logo[disasterType], - }, - { - replaceKey: '(TRIGGER-STATEMENT)', - replaceValue: country.notificationInfo.triggerStatement[disasterType], - }, - { - replaceKey: '(MAP-IMAGE-PART)', - replaceValue: await this.getMapImageHtml(country, disasterType, events), - }, - { - replaceKey: '(LINK-DASHBOARD)', - replaceValue: process.env.DASHBOARD_URL, - }, - { - replaceKey: '(LINK-EAP-SOP)', - replaceValue: country.countryDisasterSettings.find( - (s) => s.disasterType === disasterType, - ).eapLink, - }, - { - replaceKey: '(SOCIAL-MEDIA-LINK)', - replaceValue: country.notificationInfo.linkSocialMediaUrl, - }, - { - replaceKey: '(SOCIAL-MEDIA-TYPE)', - replaceValue: country.notificationInfo.linkSocialMediaType, - }, - { - replaceKey: '(ADMIN-AREA-PLURAL)', - replaceValue: - country.adminRegionLabels[ - String( - country.countryDisasterSettings.find( - (s) => s.disasterType === disasterType, - ).defaultAdminLevel, - ) - ].plural.toLowerCase(), - }, - { - replaceKey: '(ADMIN-AREA-SINGULAR)', - replaceValue: - country.adminRegionLabels[ - String( - country.countryDisasterSettings.find( - (s) => s.disasterType === disasterType, - ).defaultAdminLevel, - ) - ].singular.toLowerCase(), - }, - { - replaceKey: '(DISASTER-TYPE)', - replaceValue: - await this.notificationContentService.getDisasterTypeLabel( - disasterType, - ), - }, - { - replaceKey: '(VIDEO-PDF-LINKS)', - replaceValue: this.getVideoPdfLinks( - country.notificationInfo.linkVideo, - country.notificationInfo.linkPdf, - ), - }, - { - replaceKey: '(EXPOSURE-UNIT)', - replaceValue: ( - await this.notificationContentService.getActionUnit(disasterType) - ).label.toLocaleLowerCase(), - }, - ]; - return keyValueReplaceList; - } - - private async createReplaceKeyValuesTriggerFinished( - country: CountryEntity, - disasterType: DisasterType, - event: EventSummaryCountry, - date: Date, - ): Promise { - const keyValueReplaceList = [ - { - replaceKey: '(EMAIL-BODY)', - replaceValue: this.getEmailBody(true), - }, - { - replaceKey: '(HEADER-EVENT-OVERVIEW)', - replaceValue: '', - }, - { - replaceKey: '(IMG-LOGO)', - replaceValue: country.notificationInfo.logo[disasterType], - }, - { - replaceKey: '(START-DATE)', - replaceValue: event.startDate, - }, - { - replaceKey: '(LINK-DASHBOARD)', - replaceValue: process.env.DASHBOARD_URL, - }, - { - replaceKey: '(SOCIAL-MEDIA-PART)', - replaceValue: this.getSocialMediaHtml(country), - }, - { - replaceKey: '(LINK-EAP-SOP)', - replaceValue: country.countryDisasterSettings.find( - (s) => s.disasterType === disasterType, - ).eapLink, - }, - { - replaceKey: '(SOCIAL-MEDIA-LINK)', - replaceValue: country.notificationInfo.linkSocialMediaUrl, - }, - { - replaceKey: '(SOCIAL-MEDIA-TYPE)', - replaceValue: country.notificationInfo.linkSocialMediaType, - }, - { - replaceKey: '(VIDEO-PDF-LINKS)', - replaceValue: this.getVideoPdfLinks( - country.notificationInfo.linkVideo, - country.notificationInfo.linkPdf, - ), - }, - { - replaceKey: '(DISASTER-TYPE)', - replaceValue: - await this.notificationContentService.getDisasterTypeLabel( - disasterType, - ), - }, - { - replaceKey: this.placeholderToday, - replaceValue: date.toLocaleDateString('default', { - day: '2-digit', - month: 'short', - year: 'numeric', - }), - }, - ]; - return keyValueReplaceList; - } - - private getEmailBody(triggerFinished: boolean): string { - if (triggerFinished) { - return fs.readFileSync( - './src/api/notification/email/html/trigger-finished.html', - 'utf8', - ); - } else { - return fs.readFileSync( - './src/api/notification/email/html/trigger-notification.html', - 'utf8', - ); - } - } - - private async getHeaderEventOverview( - country: CountryEntity, - disasterType: DisasterType, - activeEvents: EventSummaryCountry[], - date?: Date, - ): Promise { - const leadTimeListShort = ( - await this.getLeadTimeList( - country, - disasterType, - this.sortEventsByLeadTime(activeEvents), - date, - ) - )['leadTimeListShort']; - return fs - .readFileSync( - './src/api/notification/email/html/header-event-overview.html', - 'utf8', - ) - .replace('(EVENT-LIST-HEADER)', leadTimeListShort); - } - - private sortEventsByLeadTime( - arr: EventSummaryCountry[], - ): EventSummaryCountry[] { - const leadTimeValue = (leadTime: LeadTime): number => - Number(leadTime.split('-')[0]); - - return arr.sort((a, b) => { - if (leadTimeValue(a.firstLeadTime) < leadTimeValue(b.firstLeadTime)) { - return -1; - } - if (leadTimeValue(a.firstLeadTime) > leadTimeValue(b.firstLeadTime)) { - return 1; - } - - return 0; - }); - } - - private getVideoPdfLinks(videoLink: string, pdfLink: string) { - const linkVideoHTML = ` - video`; - - const linkPdfHTML = `PDF`; - let videoStr = ''; - if (videoLink) { - videoStr = ' ' + linkVideoHTML; - } - let pdfStr = ''; - if (pdfLink) { - pdfStr = ' ' + linkPdfHTML; - } - let orStr = ''; - if (videoStr && pdfStr) { - orStr = ' or'; - } - if (videoStr || pdfStr) { - return `See instructions for the IBF-portal in${videoStr}${orStr}${pdfStr}.`; - } - } - - private async getLeadTimeList( - country: CountryEntity, - disasterType: DisasterType, - events: EventSummaryCountry[], - date?: Date, - ): Promise { - const triggeredLeadTimes = - await this.notificationContentService.getLeadTimesAcrossEvents( - country.countryCodeISO3, - disasterType, - events, - ); - - let leadTimeListShort = ''; - let leadTimeListLong = ''; - for (const leadTime of country.countryDisasterSettings.find( - (s) => s.disasterType === disasterType, - ).activeLeadTimes) { - if (triggeredLeadTimes[leadTime.leadTimeName] === '1') { - for await (const event of events) { - // for each event .. - const triggeredLeadTimes = - await this.eventService.getTriggerPerLeadtime( - country.countryCodeISO3, - disasterType, - event.eventName, - ); - if (triggeredLeadTimes[leadTime.leadTimeName] === '1') { - const leadTimeListEvent = - await this.notificationContentService.getLeadTimeListEvent( - country, - event, - disasterType, - leadTime.leadTimeName as LeadTime, - date, - ); - - // We are hack-misusing 'extraInfo' being filled as a proxy for typhoonNoLandfallYet-boolean - leadTimeListShort = `${leadTimeListShort}${leadTimeListEvent.short}`; - leadTimeListLong = `${leadTimeListLong}${leadTimeListEvent.long}`; - } - } - } - } - return { leadTimeListShort, leadTimeListLong }; - } - - private async getTriggerOverviewTables( - country: CountryEntity, - disasterType: DisasterType, - events: EventSummaryCountry[], - date: Date, - ): Promise { - const triggeredLeadTimes = - await this.notificationContentService.getLeadTimesAcrossEvents( - country.countryCodeISO3, - disasterType, - events, - ); - - let leadTimeTables = ''; - for (const leadTime of country.countryDisasterSettings.find( - (s) => s.disasterType === disasterType, - ).activeLeadTimes) { - if (triggeredLeadTimes[leadTime.leadTimeName] === '1') { - for await (const event of events) { - // for each event .. - const triggeredLeadTimes = - await this.eventService.getTriggerPerLeadtime( - country.countryCodeISO3, - disasterType, - event.eventName, - ); - - if (triggeredLeadTimes[leadTime.leadTimeName] === '1') { - // .. find the right leadtime - const tableForLeadTime = await this.getTableForLeadTime( - country, - disasterType, - leadTime, - event, - date, - ); - leadTimeTables = leadTimeTables + tableForLeadTime; - } - } - } - } - return leadTimeTables; - } - - private getSocialMediaHtml(country: CountryEntity): string { - if (country.notificationInfo.linkSocialMediaType) { - return fs.readFileSync( - './src/api/notification/email/html/social-media-link.html', - 'utf8', - ); - } else { - return ''; - } - } - - private async getMapImageHtml( - country: CountryEntity, - disasterType: DisasterType, - events: EventSummaryCountry[], - ): Promise { - let html = ''; - for await (const event of events) { - const mapImage = await this.eventService.getEventMapImage( - country.countryCodeISO3, - disasterType, - event.eventName || 'no-name', - ); - if (mapImage) { - let eventHtml = fs.readFileSync( - './src/api/notification/email/html/map-image.html', - 'utf8', - ); - eventHtml = eventHtml - .replace( - '(MAP-IMG-SRC)', - this.getMapImgSrc( - country.countryCodeISO3, - disasterType, - event.eventName, - ), - ) - .replace( - '(MAP-IMG-DESCRIPTION)', - this.getMapImageDescription(disasterType), - ); - eventHtml = eventHtml.replace( - '(EVENT-NAME)', - event.eventName ? ` for '${event.eventName}'` : '', - ); - html += eventHtml; - } - } - return html; - } - - private getMapImgSrc( - countryCodeISO3: string, - disasterType: DisasterType, - eventName: string, - ): string { - const src = `${ - process.env.NG_API_URL - }/event/event-map-image/${countryCodeISO3}/${disasterType}/${ - eventName || 'no-name' - }`; - - return src; - } - - private getMapImageDescription(disasterType: DisasterType): string { - switch (disasterType) { - case DisasterType.Floods: - return 'The triggered areas are outlined in purple. The potential flood extent is shown in red.
'; - default: - return ''; - } - } - - private async getTableForLeadTime( - country: CountryEntity, - disasterType: DisasterType, - leadTime: LeadTimeEntity, - event: EventSummaryCountry, - date: Date, - ): Promise { - const adminLevel = country.countryDisasterSettings.find( - (s) => s.disasterType === disasterType, - ).defaultAdminLevel; - const adminAreaLabels = country.adminRegionLabels[String(adminLevel)]; - const adminAreaLabelsParent = - country.adminRegionLabels[String(adminLevel - 1)]; - - const actionsUnit = await this.notificationContentService.getActionUnit( - disasterType, - ); - - const tableForLeadTimeStart = `
- ${ - ( - await this.notificationContentService.getLeadTimeListEvent( - country, - event, - disasterType, - leadTime.leadTimeName as LeadTime, - date, - ) - ).short - } -
- - - - - - - - - - -
`; - const tableForLeadTimeMiddle = await this.getAreaTables( - country, - disasterType, - leadTime, - event.eventName, - actionsUnit, - ); - const tableForLeadTimeEnd = '
This table lists the potentially exposed ${adminAreaLabels.plural.toLowerCase()} in order of ${actionsUnit.label.toLowerCase()}:
Alert class${adminAreaLabels.singular}${ - adminAreaLabelsParent ? ' (' + adminAreaLabelsParent.singular + ')' : '' - }Predicted ${actionsUnit.label}


'; - const tableForLeadTime = - tableForLeadTimeStart + tableForLeadTimeMiddle + tableForLeadTimeEnd; - return tableForLeadTime; - } - - private async getAreaTables( - country: CountryEntity, - disasterType: DisasterType, - leadTime: LeadTimeEntity, - eventName: string, - actionsUnit: IndicatorMetadataEntity, - ): Promise { - const triggeredAreas = await this.eventService.getTriggeredAreas( - country.countryCodeISO3, - disasterType, - country.countryDisasterSettings.find( - (s) => s.disasterType === disasterType, - ).defaultAdminLevel, - leadTime.leadTimeName, - eventName, - ); - triggeredAreas.sort((a, b) => (a.triggerValue > b.triggerValue ? -1 : 1)); - const disaster = await this.notificationContentService.getDisaster( - disasterType, - ); - const hasEap = this.hasEap(disasterType); - let areaTableString = ''; - for (const area of triggeredAreas) { - const actionsUnitValue = - await this.adminAreaDynamicDataService.getDynamicAdminAreaDataPerPcode( - disaster.actionsUnit as DynamicIndicator, - area.placeCode, - leadTime.leadTimeName as LeadTime, - eventName, - ); - - const areaTable = ` - - ${this.mapTriggerValueToAlertClass( - area.triggerValue, - hasEap, - )} - ${area.name}${ - area.nameParent ? ' (' + area.nameParent + ')' : '' - } - ${this.notificationContentService.formatActionUnitValue( - actionsUnitValue, - actionsUnit, - )} - `; - areaTableString = areaTableString + areaTable; - } - return areaTableString; - } - - // TODO merge this with the front-end instance of this to some generic place in back-end - public hasEap(disasterType: DisasterType): boolean { - const eapDisasterTypes = [ - DisasterType.Floods, - DisasterType.Drought, - DisasterType.Typhoon, - DisasterType.FlashFloods, - ]; - return eapDisasterTypes.includes(disasterType); - } - - private mapTriggerValueToAlertClass( - triggerValue: number, - hasEap: boolean, - ): string { - if (triggerValue === 1) { - return hasEap ? 'Trigger issued' : 'Alert issued'; - } else if (triggerValue === 0.7) { - return 'Medium warning issued'; - } else if (triggerValue === 0.3) { - return 'Low warning issued'; - } - } - - private formatEmail(emailKeyValueReplaceList: ReplaceKeyValue[]): string { - let emailHtml = fs.readFileSync( - './src/api/notification/email/html/base.html', - 'utf8', - ); - for (const entry of emailKeyValueReplaceList) { - emailHtml = emailHtml.split(entry.replaceKey).join(entry.replaceValue); - } - return emailHtml; - } } diff --git a/services/API-service/src/api/notification/email/html/base.html b/services/API-service/src/api/notification/email/html/base.html index a73656531..4337a8c73 100644 --- a/services/API-service/src/api/notification/email/html/base.html +++ b/services/API-service/src/api/notification/email/html/base.html @@ -882,7 +882,7 @@ >

- (DISASTER-TYPE) IBF Notification + <%= disasterType %> IBF Notification

- (HEADER-EVENT-OVERVIEW) + <%- headerEventOverview %> @@ -922,7 +922,7 @@

- (EMAIL-BODY) + <%- emailBody %> + + Warning icon + <%= hazard %> <%= eventName %>
Trigger expected on: + <%= startDateEventString %>, <%= leadTime %>s from now.
Expected exposed <%= defaulAdminAreaLabel %>: + <%= nrOfTriggeredAreas %> (see list below)
+ <%= indicatorLabel %>: <%= totalAffectectedOfIndicator %> <%= indicatorUnit %>
Advisory: Activate + Early Action Protocol

This trigger was issued by the IBF portal on <%= currentDate %> (<%= + timezone %>) + +
diff --git a/services/API-service/src/api/notification/email/html/email-body-warning-event.html b/services/API-service/src/api/notification/email/html/email-body-warning-event.html new file mode 100644 index 000000000..77c9345f2 --- /dev/null +++ b/services/API-service/src/api/notification/email/html/email-body-warning-event.html @@ -0,0 +1,120 @@ +
+ + Warning icon + <%= hazard %> <%= eventName %>
Warning expected on: + <%= startDateEventString %>, <%= leadTime %>s from now.
Expected exposed <%= defaulAdminAreaLabel %>: + <%= nrOfTriggeredAreas %> (see list below)
+ <%= indicatorLabel %>: Information regarding exposed population not available for warnings
Advisory: + Inform all potentially exposed districts

This warning was issued by the IBF portal on <%= currentDate %> (<%= + timezone %>) +
diff --git a/services/API-service/src/api/notification/email/html/email-table-event.html b/services/API-service/src/api/notification/email/html/email-table-event.html new file mode 100644 index 000000000..e25ad4c7f --- /dev/null +++ b/services/API-service/src/api/notification/email/html/email-table-event.html @@ -0,0 +1,96 @@ +
+
+ Warning icon + <%= triggerStatusLabel %> <%= hazard %>: <%= eventName %> +
+
+ Expected exposed <%= defaulAdminAreaLabelPlural %><% if (triggerStatusLabel + === 'Trigger') { %> in order of <%= indicatorLabel.toLowerCase() %><% } %>: +
+
+
+ + + <% if (triggerStatusLabel === 'Trigger') { %> + + <% } %> + + + <%- tableRows %> +
+ <%= indicatorLabel %> + + <%= defaulAdminAreaLabelSingular %> (<%= defaultAdminAreaLabelParent + %>) +
+
+
+
diff --git a/services/API-service/src/api/notification/email/html/email-table-trigger-row.html b/services/API-service/src/api/notification/email/html/email-table-trigger-row.html new file mode 100644 index 000000000..38428b98e --- /dev/null +++ b/services/API-service/src/api/notification/email/html/email-table-trigger-row.html @@ -0,0 +1,36 @@ + + + <%= affectectedOfIndicator %> + + + <%= adminBoundary %> (<%= higherAdminBoundary %>) + + diff --git a/services/API-service/src/api/notification/email/html/email-table-warning-row.html b/services/API-service/src/api/notification/email/html/email-table-warning-row.html new file mode 100644 index 000000000..85d41824e --- /dev/null +++ b/services/API-service/src/api/notification/email/html/email-table-warning-row.html @@ -0,0 +1,22 @@ + + + <%= adminBoundary %> (<%= higherAdminBoundary %>) + + diff --git a/services/API-service/src/api/notification/email/html/header-event-overview.html b/services/API-service/src/api/notification/email/html/header-event-overview.html index db14059be..e5795b900 100644 --- a/services/API-service/src/api/notification/email/html/header-event-overview.html +++ b/services/API-service/src/api/notification/email/html/header-event-overview.html @@ -1,5 +1,5 @@ -

(EVENT-LIST-HEADER)

+

<%- eventListHeader %>

diff --git a/services/API-service/src/api/notification/email/html/map-image.html b/services/API-service/src/api/notification/email/html/map-image.html index 876034e1e..a64b1e9f5 100644 --- a/services/API-service/src/api/notification/email/html/map-image.html +++ b/services/API-service/src/api/notification/email/html/map-image.html @@ -1,6 +1,6 @@
-Map of the triggered area(EVENT-NAME): (click 'Download pictures' if it -does not show) +Map of the triggered area(<%= eventName %>): (click 'Download pictures' +if it does not show)
-(MAP-IMG-DESCRIPTION) - +<%= mapImgDescription %> + diff --git a/services/API-service/src/api/notification/email/html/social-media-link.html b/services/API-service/src/api/notification/email/html/social-media-link.html index b36c482e4..61a111b12 100644 --- a/services/API-service/src/api/notification/email/html/social-media-link.html +++ b/services/API-service/src/api/notification/email/html/social-media-link.html @@ -9,7 +9,7 @@ Join (SOCIAL-MEDIA-TYPE) groupJoin <%- socialMediaType %> group diff --git a/services/API-service/src/api/notification/email/html/trigger-finished.html b/services/API-service/src/api/notification/email/html/trigger-finished.html index 0c0ac46a9..301518ea0 100644 --- a/services/API-service/src/api/notification/email/html/trigger-finished.html +++ b/services/API-service/src/api/notification/email/html/trigger-finished.html @@ -86,7 +86,7 @@ Dear reader,

The trigger notification formerly activated on - (START-DATE) is now + <%= startDate %> is now below threshold.

The event will close in the IBF-portal when the forecast stays below threshold for 7 days in a @@ -112,7 +112,7 @@ align="center" >
- Dear Reader,

- (EVENT-LIST-BODY) +
+ Dear Reader, +
+

+ <%- eventListBody %> @@ -98,7 +110,7 @@ diff --git a/services/API-service/src/api/notification/email/html/trigger-finished.html b/services/API-service/src/api/notification/email/html/trigger-finished.html index 301518ea0..5df98542b 100644 --- a/services/API-service/src/api/notification/email/html/trigger-finished.html +++ b/services/API-service/src/api/notification/email/html/trigger-finished.html @@ -85,13 +85,8 @@
Dear reader,

- The trigger notification formerly activated on - <%= startDate %> is now - below threshold.

- The event will close in the IBF-portal when the - forecast stays below threshold for 7 days in a - row. Until that time, anticipatory actions can - still be managed in the IBF-portal. + + <%- eventOverview %>
{ + const countryAdminAreaIds = await this.getCountryAdminAreaIds( + countryCodeISO3, + ); - for await (const event of eventSummary) { + // I spend quite some time on trying to figure out what is the right query to get the event finished summary for the trigger closed email + // I came up with the following query but I am not sure if it is correct and how to test it properly + const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); + const sixDaysAgo = new Date(Date.now() - 6 * 24 * 60 * 60 * 1000); + const whereFilters = { + endDate: Between(sevenDaysAgo, sixDaysAgo), + adminArea: In(countryAdminAreaIds), + disasterType: disasterType, + closed: true, + }; + + const eventSummaryQueryBuilder = + this.createEventSummaryQueryBuilder(countryCodeISO3).andWhere( + whereFilters, + ); + + const rawEventSummaryTriggerFinished = + await eventSummaryQueryBuilder.getRawMany(); + const eventSummaryTriggerFinished = await this.populateEventsDetails( + rawEventSummaryTriggerFinished, + countryCodeISO3, + disasterType, + ); + return eventSummaryTriggerFinished; + } + + private async populateEventsDetails( + events: EventSummaryCountry[], + countryCodeISO3: string, + disasterType: DisasterType, + ) { + for (const event of events) { event.firstLeadTime = await this.getFirstLeadTime( countryCodeISO3, disasterType, @@ -106,7 +142,26 @@ export class EventService { ); } } - return eventSummary; + return events; + } + + private createEventSummaryQueryBuilder( + countryCodeISO3: string, + ): SelectQueryBuilder { + return this.eventPlaceCodeRepo + .createQueryBuilder('event') + .select(['area."countryCodeISO3"', 'event."eventName"']) + .leftJoin('event.adminArea', 'area') + .groupBy('area."countryCodeISO3"') + .addGroupBy('event."eventName"') + .addSelect([ + 'to_char(MIN("startDate") , \'yyyy-mm-dd\') AS "startDate"', + 'to_char(MAX("endDate") , \'yyyy-mm-dd\') AS "endDate"', + 'MAX(event."thresholdReached"::int)::boolean AS "thresholdReached"', + ]) + .andWhere('area."countryCodeISO3" = :countryCodeISO3', { + countryCodeISO3: countryCodeISO3, + }); } public async getRecentDate( @@ -887,7 +942,8 @@ export class EventService { where: whereFilters, }); - //Below threshold events can be removed from this table after closing + // Below threshold events can be removed from this table after closing + // Below threshold events are warnings an not triggered. I do not know why they are removed here const belowThresholdEvents = expiredEventAreas.filter( (a) => !a.thresholdReached, ); diff --git a/services/API-service/src/api/notification/dto/content-trigger-email.dto.ts b/services/API-service/src/api/notification/dto/content-trigger-email.dto.ts index e5bda8d48..f617ba219 100644 --- a/services/API-service/src/api/notification/dto/content-trigger-email.dto.ts +++ b/services/API-service/src/api/notification/dto/content-trigger-email.dto.ts @@ -4,7 +4,7 @@ import { IndicatorMetadataEntity } from '../../metadata/indicator-metadata.entit import { AdminAreaLabel } from './admin-area-notification-info.dto'; import { NotificationDataPerEventDto } from './notification-date-per-event.dto'; -export class ContentTriggerEmail { +export class ContentEventEmail { public disasterType: DisasterType; public disasterTypeLabel: string; public indicatorMetadata: IndicatorMetadataEntity; diff --git a/services/API-service/src/api/notification/dto/notification-date-per-event.dto.ts b/services/API-service/src/api/notification/dto/notification-date-per-event.dto.ts index 96baf5862..33f17e713 100644 --- a/services/API-service/src/api/notification/dto/notification-date-per-event.dto.ts +++ b/services/API-service/src/api/notification/dto/notification-date-per-event.dto.ts @@ -8,9 +8,10 @@ export class NotificationDataPerEventDto { firstLeadTime: LeadTime; triggeredAreas: TriggeredArea[]; nrOfTriggeredAreas: number; - startDateEventString: string; + startDateDisasterString: string; totalAffectectedOfIndicator: number; mapImage?: Buffer; + issuedDate: Date; } export enum TriggerStatusLabelEnum { diff --git a/services/API-service/src/api/notification/email/email-template.service.ts b/services/API-service/src/api/notification/email/email-template.service.ts index 103489e2b..ab6eeb013 100644 --- a/services/API-service/src/api/notification/email/email-template.service.ts +++ b/services/API-service/src/api/notification/email/email-template.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { ContentTriggerEmail } from '../dto/content-trigger-email.dto'; +import { ContentEventEmail } from '../dto/content-trigger-email.dto'; import { LeadTime } from '../../admin-area-dynamic-data/enum/lead-time.enum'; import { NotificationDataPerEventDto, @@ -26,11 +26,11 @@ export class EmailTemplateService { private placeholderToday = '(TODAY)'; public createHtmlForTriggerEmail( - contentForEmail: ContentTriggerEmail, + emailContent: ContentEventEmail, date: Date, ): string { const replaceKeyValues = this.createReplaceKeyValuesTrigger( - contentForEmail, + emailContent, date, ); return this.formatEmail(replaceKeyValues); @@ -40,14 +40,14 @@ export class EmailTemplateService { public createHtmlForTriggerFinishedEmail( country: CountryEntity, disasterType: DisasterType, - finishedEvent: EventSummaryCountry, + finishedEvents: EventSummaryCountry[], disasterTypeLabel: string, date: Date, ): string { const replaceKeyValues = this.createReplaceKeyValuesTriggerFinished( country, disasterType, - finishedEvent, + finishedEvents, disasterTypeLabel, date, ); @@ -55,11 +55,11 @@ export class EmailTemplateService { } private createReplaceKeyValuesTrigger( - contentForEmail: ContentTriggerEmail, + emailContent: ContentEventEmail, date: Date, ): ReplaceKeyValue[] { - const country = contentForEmail.country; - const disasterType = contentForEmail.disasterType; + const country = emailContent.country; + const disasterType = emailContent.disasterType; const keyValueReplaceList = [ { replaceKey: 'emailBody', @@ -67,15 +67,15 @@ export class EmailTemplateService { }, { replaceKey: 'headerEventOverview', - replaceValue: this.getHeaderEventOverview(contentForEmail.dataPerEvent), + replaceValue: this.getHeaderEventStarted(emailContent), }, { replaceKey: 'socialMediaPart', - replaceValue: this.getSocialMediaHtml(contentForEmail.country), + replaceValue: this.getSocialMediaHtml(emailContent.country), }, { replaceKey: 'tablesStacked', - replaceValue: this.getTablesForEvents(contentForEmail), + replaceValue: this.getTablesForEvents(emailContent), }, { replaceKey: this.placeholderToday, @@ -87,7 +87,7 @@ export class EmailTemplateService { }, { replaceKey: 'eventListBody', - replaceValue: this.getEventListBody(contentForEmail), + replaceValue: this.getEventListBody(emailContent), }, { replaceKey: 'imgLogo', @@ -99,7 +99,7 @@ export class EmailTemplateService { }, { replaceKey: 'mapImagePart', - replaceValue: this.getMapImageHtml(contentForEmail), + replaceValue: this.getMapImageHtml(emailContent), }, { replaceKey: 'linkDashboard', @@ -121,7 +121,7 @@ export class EmailTemplateService { }, { replaceKey: 'disasterType', - replaceValue: contentForEmail.disasterTypeLabel, + replaceValue: emailContent.disasterTypeLabel, }, { replaceKey: 'videoPdfLinks', @@ -137,7 +137,7 @@ export class EmailTemplateService { private createReplaceKeyValuesTriggerFinished( country: CountryEntity, disasterType: DisasterType, - event: EventSummaryCountry, + events: EventSummaryCountry[], disasterTypeLabel: string, date: Date, ): ReplaceKeyValue[] { @@ -151,12 +151,16 @@ export class EmailTemplateService { replaceValue: '', }, { - replaceKey: 'imgLogo', - replaceValue: country.notificationInfo.logo[disasterType], + replaceKey: 'eventOverview', + replaceValue: this.geEventsFinishedOverview( + country, + events, + disasterTypeLabel, + ), }, { - replaceKey: 'startDate', - replaceValue: event.startDate, + replaceKey: 'imgLogo', + replaceValue: country.notificationInfo.logo[disasterType], }, { replaceKey: 'linkDashboard', @@ -217,16 +221,42 @@ export class EmailTemplateService { } } - private getHeaderEventOverview( - eventsData: NotificationDataPerEventDto[], + private geEventsFinishedOverview( + country: CountryEntity, + events: EventSummaryCountry[], + disasterTypeLabel: string, ): string { - const leadTimeListShort = this.getEventListShort(eventsData); + let html = ''; + + const template = fs.readFileSync( + './src/api/notification/email/html/event-finished.html', + 'utf-8', + ); + + for (const event of events) { + const eventFinshedHtml = ejs.render(template, { + disasterTypeLabel: disasterTypeLabel, + eventName: event.eventName, + issuedDate: 'yo', + timezone: CountryTimeZoneMapping[country.countryCodeISO3], + }); + html += eventFinshedHtml; + } + return html; + } + + private getHeaderEventStarted(emailContent: ContentEventEmail): string { let headerEventOverview = fs.readFileSync( './src/api/notification/email/html/header-event-overview.html', 'utf8', ); headerEventOverview = ejs.render(headerEventOverview, { - eventListHeader: leadTimeListShort, + sentOnDate: this.getCurrentDateTimeString( + emailContent.country.countryCodeISO3, + ), + disasterLabel: emailContent.disasterTypeLabel, + nrOfEvents: emailContent.dataPerEvent.length, + timezone: CountryTimeZoneMapping[emailContent.country.countryCodeISO3], }); return headerEventOverview; } @@ -288,9 +318,9 @@ export class EmailTemplateService { } } - private getMapImageHtml(contentForEmail: ContentTriggerEmail): string { + private getMapImageHtml(emailContent: ContentEventEmail): string { let html = ''; - for (const event of contentForEmail.dataPerEvent) { + for (const event of emailContent.dataPerEvent) { const mapImage = event.mapImage; if (mapImage) { let eventHtml = fs.readFileSync( @@ -299,12 +329,12 @@ export class EmailTemplateService { ); const replacements = { mapImgSrc: this.getMapImgSrc( - contentForEmail.country.countryCodeISO3, - contentForEmail.disasterType, + emailContent.country.countryCodeISO3, + emailContent.disasterType, event.eventName, ), mapImgDescription: this.getMapImageDescription( - contentForEmail.disasterType, + emailContent.disasterType, ), eventName: event.eventName ? ` for '${event.eventName}'` : '', }; @@ -387,7 +417,7 @@ export class EmailTemplateService { return text; } - private getTablesForEvents(emailContent: ContentTriggerEmail): string { + private getTablesForEvents(emailContent: ContentEventEmail): string { const adminAreaLabelsParent = emailContent.country.adminRegionLabels[ String(emailContent.defaultAdminLevel - 1) @@ -445,7 +475,7 @@ export class EmailTemplateService { .join(''); } - private getEventListBody(emailContent: ContentTriggerEmail): string { + private getEventListBody(emailContent: ContentEventEmail): string { return emailContent.dataPerEvent .map((event) => { const data = { @@ -457,8 +487,11 @@ export class EmailTemplateService { nrOfTriggeredAreas: event.nrOfTriggeredAreas, expectedTriggerDate: event.firstLeadTime, expectedExposedAdminBoundary: event.nrOfTriggeredAreas, - issuedDate: event.disasterSpecificCopy.timestamp, - startDateEventString: event.startDateEventString, + issuedDate: this.dateObjectToDateTimeString( + event.issuedDate, + emailContent.country.countryCodeISO3, + ), + startDateEventString: event.startDateDisasterString, defaulAdminAreaLabel: emailContent.defaultAdminAreaLabel.plural.toLocaleLowerCase(), indicatorLabel: emailContent.indicatorMetadata.label, @@ -487,9 +520,14 @@ export class EmailTemplateService { private getCurrentDateTimeString(countryCodeISO3: string): string { const date = new Date(); + return this.dateObjectToDateTimeString(date, countryCodeISO3); + } + private dateObjectToDateTimeString( + date: Date, + countryCodeISO3: string, + ): string { const timeZone = CountryTimeZoneMapping[countryCodeISO3]; - const options: Intl.DateTimeFormatOptions = { weekday: 'long', day: '2-digit', @@ -499,7 +537,6 @@ export class EmailTemplateService { minute: '2-digit', timeZone: timeZone, }; - return date.toLocaleString('default', options); } diff --git a/services/API-service/src/api/notification/email/email.service.ts b/services/API-service/src/api/notification/email/email.service.ts index ca10269a8..8ec3ea611 100644 --- a/services/API-service/src/api/notification/email/email.service.ts +++ b/services/API-service/src/api/notification/email/email.service.ts @@ -1,4 +1,3 @@ - import { CountryEntity } from './../../country/country.entity'; import { Injectable } from '@nestjs/common'; import Mailchimp from 'mailchimp-api-v3'; @@ -47,14 +46,14 @@ export class EmailService { ): Promise { date = date ? new Date(date) : new Date(); - const contentForEmail = + const emailContent = await this.notificationContentService.getContentTriggerNotification( country, disasterType, activeEvents, ); const emailHtml = this.emailTemplateService.createHtmlForTriggerEmail( - contentForEmail, + emailContent, date, ); const emailSubject = `IBF ${( @@ -71,7 +70,7 @@ export class EmailService { public async sendTriggerFinishedEmail( country: CountryEntity, disasterType: DisasterType, - finishedEvent: EventSummaryCountry, + finishedEvents: EventSummaryCountry[], date?: Date, ): Promise { const disasterTypeLabel = @@ -80,7 +79,7 @@ export class EmailService { this.emailTemplateService.createHtmlForTriggerFinishedEmail( country, disasterType, - finishedEvent, + finishedEvents, disasterTypeLabel, date ? new Date(date) : new Date(), ); diff --git a/services/API-service/src/api/notification/email/html/base.html b/services/API-service/src/api/notification/email/html/base.html index 4337a8c73..ca6072f8f 100644 --- a/services/API-service/src/api/notification/email/html/base.html +++ b/services/API-service/src/api/notification/email/html/base.html @@ -28,6 +28,9 @@ .notification-sub-title-left { color: white; text-align: center; + display: flex; + justify-content: center; + align-items: center; } .notification-content { diff --git a/services/API-service/src/api/notification/email/html/email-body-trigger-event.html b/services/API-service/src/api/notification/email/html/email-body-trigger-event.html index a052140f0..456687ad8 100644 --- a/services/API-service/src/api/notification/email/html/email-body-trigger-event.html +++ b/services/API-service/src/api/notification/email/html/email-body-trigger-event.html @@ -125,8 +125,8 @@ line-height: 19.2px; word-wrap: break-word; " - >This trigger was issued by the IBF portal on <%= currentDate %> (<%= + >This trigger was issued by the IBF portal on <%= issuedDate %> (<%= timezone %>) +
-
diff --git a/services/API-service/src/api/notification/email/html/email-body-warning-event.html b/services/API-service/src/api/notification/email/html/email-body-warning-event.html index 77c9345f2..ea20abf86 100644 --- a/services/API-service/src/api/notification/email/html/email-body-warning-event.html +++ b/services/API-service/src/api/notification/email/html/email-body-warning-event.html @@ -114,7 +114,8 @@ line-height: 19.2px; word-wrap: break-word; " - >This warning was issued by the IBF portal on <%= currentDate %> (<%= + >This warning was issued by the IBF portal on <%= issuedDate %> (<%= timezone %>) +
diff --git a/services/API-service/src/api/notification/email/html/event-finished.html b/services/API-service/src/api/notification/email/html/event-finished.html new file mode 100644 index 000000000..7a607dbe3 --- /dev/null +++ b/services/API-service/src/api/notification/email/html/event-finished.html @@ -0,0 +1,39 @@ +
+ + <%= disasterTypeLabel %>: <%= eventName %> is now below threshold
+ The events actions will continue to show on the IBF portal and can still be + managed.
+
This warning was issued by the IBF portal on <%= issuedDate %> (<%= + timezone %>) +
diff --git a/services/API-service/src/api/notification/email/html/header-event-overview.html b/services/API-service/src/api/notification/email/html/header-event-overview.html index e5795b900..5a5a87a43 100644 --- a/services/API-service/src/api/notification/email/html/header-event-overview.html +++ b/services/API-service/src/api/notification/email/html/header-event-overview.html @@ -1,5 +1,18 @@
-

<%- eventListHeader %>

+

+ <%= nrOfEvents %> <%= disasterLabel %> alerts +

+ IBF alert send on <%= sentOnDate %> (<%= timezone %>)
diff --git a/services/API-service/src/api/notification/notification-content/notification-content.service.ts b/services/API-service/src/api/notification/notification-content/notification-content.service.ts index 9cf9bd2ae..93c41add4 100644 --- a/services/API-service/src/api/notification/notification-content/notification-content.service.ts +++ b/services/API-service/src/api/notification/notification-content/notification-content.service.ts @@ -6,19 +6,16 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { IndicatorMetadataEntity } from '../../metadata/indicator-metadata.entity'; import { LeadTime } from '../../admin-area-dynamic-data/enum/lead-time.enum'; -import { DynamicIndicator } from '../../admin-area-dynamic-data/enum/dynamic-data-unit'; import { DisasterType } from '../../disaster/disaster-type.enum'; import { DisasterEntity } from '../../disaster/disaster.entity'; import { EventSummaryCountry, TriggeredArea } from '../../../shared/data.model'; -import { AdminAreaDataService } from '../../admin-area-data/admin-area-data.service'; -import { AdminAreaService } from '../../admin-area/admin-area.service'; import { HelperService } from '../../../shared/helper.service'; import { NotificationDataPerEventDto, TriggerStatusLabelEnum, } from '../dto/notification-date-per-event.dto'; import { AdminAreaLabel } from '../dto/admin-area-notification-info.dto'; -import { ContentTriggerEmail } from '../dto/content-trigger-email.dto'; +import { ContentEventEmail } from '../dto/content-trigger-email.dto'; @Injectable() export class NotificationContentService { @@ -38,8 +35,8 @@ export class NotificationContentService { country: CountryEntity, disasterType: DisasterType, activeEvents: EventSummaryCountry[], - ): Promise { - const content = new ContentTriggerEmail(); + ): Promise { + const content = new ContentEventEmail(); content.disasterType = disasterType; content.disasterTypeLabel = await this.getDisasterTypeLabel(disasterType); content.dataPerEvent = await this.getNotificationDataForEvents( @@ -169,17 +166,16 @@ export class NotificationContentService { data.nrOfTriggeredAreas = await this.getNrOfTriggeredAreas( data.triggeredAreas, ); - - data.startDateEventString = await this.getFirstLeadTimeDate( - Number(event.firstLeadTime.split('-')[0]), - event.firstLeadTime.split('-')[1], + // This looks weird, but as far as I understand the startDate of the event is the day it was first issued + data.issuedDate = new Date(event.startDate); + data.startDateDisasterString = await this.getFirstLeadTimeString( + event, event.countryCodeISO3, disasterType, ); data.totalAffectectedOfIndicator = this.getTotalAffectedPerEvent( data.triggeredAreas, ); - data.mapImage = await this.eventService.getEventMapImage( country.countryCodeISO3, disasterType, @@ -270,7 +266,7 @@ export class NotificationContentService { }); } - public async getStartTimeEvent( + public async getFirstLeadTimeString( event: EventSummaryCountry, countryCodeISO3: string, disasterType: DisasterType, diff --git a/services/API-service/src/api/notification/notification.service.ts b/services/API-service/src/api/notification/notification.service.ts index 1352a256f..5cdf65f6c 100644 --- a/services/API-service/src/api/notification/notification.service.ts +++ b/services/API-service/src/api/notification/notification.service.ts @@ -23,94 +23,103 @@ export class NotificationService { disasterType: DisasterType, date?: Date, ): Promise { + await this.sendNotiFicationsActiveEvents( + disasterType, + countryCodeISO3, + date, + ); + + if (disasterType === DisasterType.Floods) { + // Sending finished events is now for floods only + await this.sendNotificationsFinishedEvents( + countryCodeISO3, + disasterType, + date, + ); + } + // REFACTOR: First close finished events. This is ideally done through separate endpoint called at end of pipeline, but that would require all pipelines to be updated. // Instead, making use of this endpoint which is already called at the end of every pipeline await this.eventService.closeEventsAutomatic(countryCodeISO3, disasterType); + } + private async sendNotiFicationsActiveEvents( + disasterType: DisasterType, + countryCodeISO3: string, + date?: Date, + ): Promise { const events = await this.eventService.getEventSummary( countryCodeISO3, disasterType, ); - - const activeEvents: EventSummaryCountry[] = []; - let finishedEvent: EventSummaryCountry; // This is now for floods only, so can only be 1 event, so not an array + const activeNotifiableEvents: EventSummaryCountry[] = []; for await (const event of events) { if ( - await this.getNotifiableActiveEvent( - event, - disasterType, - countryCodeISO3, - ) + await this.isNotifiableActiveEvent(event, disasterType, countryCodeISO3) ) { - activeEvents.push(event); - } else if (this.getFinishedEvent(event, disasterType, date)) { - finishedEvent = event; + activeNotifiableEvents.push(event); } } - if (activeEvents.length) { + + if (activeNotifiableEvents.length) { const country = await this.notificationContentService.getCountryNotificationInfo( countryCodeISO3, ); - this.emailService.sendTriggerEmail( + await this.emailService.sendTriggerEmail( country, disasterType, - activeEvents, + activeNotifiableEvents, date, ); - if (country.notificationInfo.useWhatsapp[disasterType]) { this.whatsappService.sendTriggerWhatsapp( country, - activeEvents, + activeNotifiableEvents, disasterType, ); } } + } - if (finishedEvent) { + private async sendNotificationsFinishedEvents( + countryCodeISO3: string, + disasterType: DisasterType, + date?: Date, + ): Promise { + const finishedNotifiableEvents = + await this.eventService.getEventsSummaryTriggerFinishedMail( + countryCodeISO3, + disasterType, + ); + + if (finishedNotifiableEvents.length > 0) { const country = await this.notificationContentService.getCountryNotificationInfo( countryCodeISO3, ); - this.emailService.sendTriggerFinishedEmail( + await this.emailService.sendTriggerFinishedEmail( country, disasterType, - finishedEvent, + finishedNotifiableEvents, date, ); if (country.notificationInfo.useWhatsapp[disasterType]) { - this.whatsappService.sendTriggerFinishedWhatsapp( - country, - finishedEvent, - disasterType, - ); - } - } - } - - private getFinishedEvent( - event: EventSummaryCountry, - disasterType: DisasterType, - uploadDate?: Date, - ) { - // For now only do this for floods - if (disasterType === DisasterType.Floods) { - const date = uploadDate ? new Date(uploadDate) : new Date(); - const yesterdayActiveDate = new Date(date.setDate(date.getDate() + 6)); // determine yesterday still active events by endDate lying (7 - 1) days in the future - if ( - new Date(event.endDate) >= - new Date(yesterdayActiveDate.setHours(0, 0, 0, 0)) - ) { - return true; + // TODO: Send one whatsapp message for all closing events + for (const event of finishedNotifiableEvents) { + await this.whatsappService.sendTriggerFinishedWhatsapp( + country, + event, + disasterType, + ); + } } } - return false; } - private async getNotifiableActiveEvent( + private async isNotifiableActiveEvent( event: EventSummaryCountry, disasterType: DisasterType, countryCodeISO3: string, diff --git a/services/API-service/src/api/notification/whatsapp/whatsapp.service.ts b/services/API-service/src/api/notification/whatsapp/whatsapp.service.ts index 7bbc72ec0..b71ea3f2d 100644 --- a/services/API-service/src/api/notification/whatsapp/whatsapp.service.ts +++ b/services/API-service/src/api/notification/whatsapp/whatsapp.service.ts @@ -16,7 +16,6 @@ import { TwilioStatusCallbackDto, } from './twilio.dto'; import { NotificationType, TwilioMessageEntity } from './twilio.entity'; -import { EmailTemplateService } from '../email/email-template.service'; import { formatActionUnitValue } from '../helpers/format-action-unit-value.helper'; @Injectable() @@ -89,7 +88,7 @@ export class WhatsappService { ? 'trigger' : 'warning'; const startTimeEvent = - await this.notificationContentService.getStartTimeEvent( + await this.notificationContentService.getFirstLeadTimeString( activeEvents[0], country.countryCodeISO3, disasterType, @@ -107,7 +106,7 @@ export class WhatsappService { ]; const startTimeFirstEvent = - await this.notificationContentService.getStartTimeEvent( + await this.notificationContentService.getFirstLeadTimeString( activeEvents[0], country.countryCodeISO3, disasterType, @@ -385,7 +384,7 @@ export class WhatsappService { } const startTimeEvent = - await this.notificationContentService.getStartTimeEvent( + await this.notificationContentService.getFirstLeadTimeString( event, country.countryCodeISO3, disasterType, From 3ff034156776e18321ffb3714df01b6538fe959e Mon Sep 17 00:00:00 2001 From: Ruben Date: Thu, 25 Apr 2024 17:11:09 +0200 Subject: [PATCH 03/21] AB#27285 Implement severity of warning --- .../dto/station-forecast.dto.ts | 3 +- .../dto/notification-date-per-event.dto.ts | 3 +- .../email/email-template.service.ts | 90 ++++++++----------- .../email/html/email-body-trigger-event.html | 4 +- .../email/html/email-body-warning-event.html | 4 +- .../email/html/email-table-event.html | 3 +- .../notification-content.service.ts | 2 +- services/API-service/src/shared/data.model.ts | 24 +++-- 8 files changed, 66 insertions(+), 67 deletions(-) diff --git a/services/API-service/src/api/glofas-station/dto/station-forecast.dto.ts b/services/API-service/src/api/glofas-station/dto/station-forecast.dto.ts index fc3e96d6e..1cdc7b59c 100644 --- a/services/API-service/src/api/glofas-station/dto/station-forecast.dto.ts +++ b/services/API-service/src/api/glofas-station/dto/station-forecast.dto.ts @@ -6,6 +6,7 @@ import { IsString, } from 'class-validator'; import { ApiProperty } from '@nestjs/swagger'; +import { EapAlertClassKeyEnum } from '../../../shared/data.model'; export class GlofasStationForecastDto { @ApiProperty({ example: 'G1374' }) @@ -20,7 +21,7 @@ export class GlofasStationForecastDto { @ApiProperty({ example: 1 }) @IsNotEmpty() - @IsIn(['no', 'min', 'med', 'max']) + @IsIn(Object.values(EapAlertClassKeyEnum)) public eapAlertClass: string; @ApiProperty({ example: 10 }) diff --git a/services/API-service/src/api/notification/dto/notification-date-per-event.dto.ts b/services/API-service/src/api/notification/dto/notification-date-per-event.dto.ts index 33f17e713..4fde261a4 100644 --- a/services/API-service/src/api/notification/dto/notification-date-per-event.dto.ts +++ b/services/API-service/src/api/notification/dto/notification-date-per-event.dto.ts @@ -1,4 +1,4 @@ -import { TriggeredArea } from '../../../shared/data.model'; +import { EapAlertClass, TriggeredArea } from '../../../shared/data.model'; import { LeadTime } from '../../admin-area-dynamic-data/enum/lead-time.enum'; export class NotificationDataPerEventDto { @@ -12,6 +12,7 @@ export class NotificationDataPerEventDto { totalAffectectedOfIndicator: number; mapImage?: Buffer; issuedDate: Date; + eapAlertClass: EapAlertClass; } export enum TriggerStatusLabelEnum { diff --git a/services/API-service/src/api/notification/email/email-template.service.ts b/services/API-service/src/api/notification/email/email-template.service.ts index ab6eeb013..405428817 100644 --- a/services/API-service/src/api/notification/email/email-template.service.ts +++ b/services/API-service/src/api/notification/email/email-template.service.ts @@ -9,7 +9,10 @@ import * as ejs from 'ejs'; import * as fs from 'fs'; import { CountryTimeZoneMapping } from '../../country/country-time-zone-mapping'; import { DisasterType } from '../../disaster/disaster-type.enum'; -import { EventSummaryCountry } from '../../../shared/data.model'; +import { + EapAlertClassKeyEnum, + EventSummaryCountry, +} from '../../../shared/data.model'; import { CountryEntity } from '../../country/country.entity'; const emailFolder = './src/api/notification/email'; @@ -23,8 +26,6 @@ class ReplaceKeyValue { @Injectable() export class EmailTemplateService { - private placeholderToday = '(TODAY)'; - public createHtmlForTriggerEmail( emailContent: ContentEventEmail, date: Date, @@ -77,14 +78,6 @@ export class EmailTemplateService { replaceKey: 'tablesStacked', replaceValue: this.getTablesForEvents(emailContent), }, - { - replaceKey: this.placeholderToday, - replaceValue: date.toLocaleDateString('default', { - day: '2-digit', - month: 'short', - year: 'numeric', - }), - }, { replaceKey: 'eventListBody', replaceValue: this.getEventListBody(emailContent), @@ -195,14 +188,6 @@ export class EmailTemplateService { replaceKey: 'disasterType', replaceValue: disasterTypeLabel, }, - { - replaceKey: this.placeholderToday, - replaceValue: date.toLocaleDateString('default', { - day: '2-digit', - month: 'short', - year: 'numeric', - }), - }, ]; return keyValueReplaceList; } @@ -395,34 +380,11 @@ export class EmailTemplateService { return emailHtml; } - // TODO refactor this to use ejs package to render the html - private getEventListShort( - dataPerEvent: NotificationDataPerEventDto[], - ): string { - let text = ''; - for (const event of dataPerEvent) { - const leadTimeString = event.disasterSpecificCopy.leadTimeString - ? event.disasterSpecificCopy.leadTimeString - : event.firstLeadTime; - const timestamp = event.disasterSpecificCopy.timestamp - ? ` at ${event.disasterSpecificCopy.timestamp}` - : ''; - text += `${event.triggerStatusLabel} for ${event.eventName}: ${ - event.disasterSpecificCopy.extraInfo || - event.firstLeadTime === LeadTime.hour0 - ? leadTimeString - : `${event.firstLeadTime}${timestamp}` - }
`; - } - return text; - } - private getTablesForEvents(emailContent: ContentEventEmail): string { const adminAreaLabelsParent = emailContent.country.adminRegionLabels[ String(emailContent.defaultAdminLevel - 1) ]; - return emailContent.dataPerEvent .map((event) => { const data = { @@ -438,12 +400,10 @@ export class EmailTemplateService { defaultAdminAreaLabelParent: adminAreaLabelsParent.singular, indicatorLabel: emailContent.indicatorMetadata.label, indicatorUnit: emailContent.indicatorMetadata.unit, - triangleIcon: this.getTriangleIcon(event.triggerStatusLabel), + triangleIcon: this.getTriangleIcon(event.eapAlertClass?.key), tableRows: this.getTablesRows(event), - color: - event.triggerStatusLabel === TriggerStatusLabelEnum.Trigger - ? '#940000' - : '#da7c00', + color: this.ibfColorToHex(event.eapAlertClass?.color), + severityLabel: this.getEventSeverityLabel(event.eapAlertClass?.key), }; const templatePath = `${emailTemplateFolder}/email-table-event.html`; @@ -456,6 +416,16 @@ export class EmailTemplateService { .join(''); } + private getEventSeverityLabel(eapAlertClassKey: EapAlertClassKeyEnum) { + if (eapAlertClassKey === EapAlertClassKeyEnum.med) { + return 'Medium'; + } else if (eapAlertClassKey === EapAlertClassKeyEnum.min) { + return 'Low'; + } else { + return ''; + } + } + private getTablesRows(event: NotificationDataPerEventDto) { return event.triggeredAreas .map((area) => { @@ -502,8 +472,10 @@ export class EmailTemplateService { ), timezone: CountryTimeZoneMapping[emailContent.country.countryCodeISO3], - triangleIcon: this.getTriangleIcon(event.triggerStatusLabel), + triangleIcon: this.getTriangleIcon(event.eapAlertClass?.key), leadTime: event.firstLeadTime.replace('-', ' '), + disasterIssuedLabel: event.eapAlertClass.label, + color: this.ibfColorToHex(event.eapAlertClass?.color), }; const templatePath = @@ -518,6 +490,18 @@ export class EmailTemplateService { .join(''); } + private ibfColorToHex(color: string): string { + // TODO: Define in a place where FrontEnd and Backend can share this + switch (color) { + case 'ibf-orange': + return '#f0890d'; + case 'ibf-yellow': + return '#fff500'; + default: + return '#c70000'; + } + } + private getCurrentDateTimeString(countryCodeISO3: string): string { const date = new Date(); return this.dateObjectToDateTimeString(date, countryCodeISO3); @@ -540,13 +524,15 @@ export class EmailTemplateService { return date.toLocaleString('default', options); } - private getTriangleIcon(triggerStatusLabel) { + private getTriangleIcon(eapAlertClassKey: EapAlertClassKeyEnum) { let fileName = ''; // Still need implement the difference between medium and low warning - if (triggerStatusLabel === TriggerStatusLabelEnum.Trigger) { - fileName = 'trigger.png'; - } else { + if (eapAlertClassKey === EapAlertClassKeyEnum.med) { fileName = 'warning-medium.png'; + } else if (eapAlertClassKey === EapAlertClassKeyEnum.min) { + fileName = 'warning-low.png'; + } else { + fileName = 'trigger.png'; } const filePath = `${emailIconFolder}/${fileName}`; const imageDataURL = this.getPngImageAsDataURL(filePath); diff --git a/services/API-service/src/api/notification/email/html/email-body-trigger-event.html b/services/API-service/src/api/notification/email/html/email-body-trigger-event.html index 456687ad8..084bb3cc9 100644 --- a/services/API-service/src/api/notification/email/html/email-body-trigger-event.html +++ b/services/API-service/src/api/notification/email/html/email-body-trigger-event.html @@ -1,7 +1,7 @@
Trigger expected on:<%= disasterIssuedLabel %>: Warning expected on:<%= disasterIssuedLabel %> Warning icon - <%= triggerStatusLabel %> <%= hazard %>: <%= eventName %> + <%= severityLabel %> <%= triggerStatusLabel %> <%= hazard %>: <%= eventName + %>
Early Action Protocol diff --git a/services/API-service/src/api/notification/email/html/advisory-warning.html b/services/API-service/src/api/notification/email/html/advisory-warning.html new file mode 100644 index 000000000..90204931d --- /dev/null +++ b/services/API-service/src/api/notification/email/html/advisory-warning.html @@ -0,0 +1 @@ +Inform all potentially exposed districts diff --git a/services/API-service/src/api/notification/email/html/body-warning-event.html b/services/API-service/src/api/notification/email/html/body-event.html similarity index 68% rename from services/API-service/src/api/notification/email/html/body-warning-event.html rename to services/API-service/src/api/notification/email/html/body-event.html index ca5203753..b03ae3396 100644 --- a/services/API-service/src/api/notification/email/html/body-warning-event.html +++ b/services/API-service/src/api/notification/email/html/body-event.html @@ -9,9 +9,10 @@ word-wrap: break-word; " > - Warning icon - <%= hazard %> <%= eventName %>
+ <%= hazard %> <%= eventName %>
+
+ <%= disasterIssuedLabel %> + <%= disasterIssuedLabel %> + + - <%= startDateEventString %>, <%= leadTime %>s from now.
, <%= leadTime %>s from now.
+
+ Expected exposed <%= defaulAdminAreaLabel %>: + Expected exposed <%= defaulAdminAreaLabel %>: + + - <%= nrOfTriggeredAreas %> (see list below)
(see list below)
+
+ - <%= indicatorLabel %>: : + + Information regarding exposed population not available for warnings
+ <%- totalAffected %> + + Advisory: - Inform all potentially exposed districts
+
+ <%- advisory %>
+
+ This warning was issued by the IBF portal on <%= issuedDate %> (<%= - timezone %>) -
+
+ This <%= triggerStatusLabel %> was issued by the IBF portal on <%= + issuedDate %> (<%= timezone %>)
+
diff --git a/services/API-service/src/api/notification/email/html/body-total-affected-trigger.html b/services/API-service/src/api/notification/email/html/body-total-affected-trigger.html new file mode 100644 index 000000000..8849930fe --- /dev/null +++ b/services/API-service/src/api/notification/email/html/body-total-affected-trigger.html @@ -0,0 +1,11 @@ +<%= totalAffectectedOfIndicator %> <%= indicatorUnit %>
diff --git a/services/API-service/src/api/notification/email/html/body-total-affected-warning.html b/services/API-service/src/api/notification/email/html/body-total-affected-warning.html new file mode 100644 index 000000000..7ab140eb2 --- /dev/null +++ b/services/API-service/src/api/notification/email/html/body-total-affected-warning.html @@ -0,0 +1 @@ +Information regarding exposed population not available for warnings
diff --git a/services/API-service/src/api/notification/email/html/body-trigger-event.html b/services/API-service/src/api/notification/email/html/body-trigger-event.html deleted file mode 100644 index 084bb3cc9..000000000 --- a/services/API-service/src/api/notification/email/html/body-trigger-event.html +++ /dev/null @@ -1,132 +0,0 @@ -
- - Warning icon - <%= hazard %> <%= eventName %>
<%= disasterIssuedLabel %>: - <%= startDateEventString %>, <%= leadTime %>s from now.
Expected exposed <%= defaulAdminAreaLabel %>: - <%= nrOfTriggeredAreas %> (see list below)
- <%= indicatorLabel %>: <%= totalAffectectedOfIndicator %> <%= indicatorUnit %>
Advisory: Activate - Early Action Protocol

This trigger was issued by the IBF portal on <%= issuedDate %> (<%= - timezone %>) -
-
From 05acf46310c287b4e2ae561c9e84126a19586683 Mon Sep 17 00:00:00 2001 From: Ruben Date: Thu, 2 May 2024 14:04:46 +0200 Subject: [PATCH 06/21] AB#27182 Do not filter out action value 0 of warning flood email --- .../notification-content.service.ts | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/services/API-service/src/api/notification/notification-content/notification-content.service.ts b/services/API-service/src/api/notification/notification-content/notification-content.service.ts index f2d4bb1d0..59c2d335f 100644 --- a/services/API-service/src/api/notification/notification-content/notification-content.service.ts +++ b/services/API-service/src/api/notification/notification-content/notification-content.service.ts @@ -165,6 +165,8 @@ export class NotificationContentService { ); data.nrOfTriggeredAreas = await this.getNrOfTriggeredAreas( data.triggeredAreas, + data.triggerStatusLabel, + disasterType, ); // This looks weird, but as far as I understand the startDate of the event is the day it was first issued data.issuedDate = new Date(event.startDate); @@ -187,14 +189,24 @@ export class NotificationContentService { private async getNrOfTriggeredAreas( triggeredAreas: TriggeredArea[], + statusLabel: TriggerStatusLabelEnum, + disasterType: DisasterType, ): Promise { // This filters out the areas that are affected by the event but do not have any affect action units // Affected action units are for example people_affected, houses_affected, etc (differs per disaster type) // We are not sure why this is done, but it is done in the original code - const triggeredAreaWithAffectedActionUnit = triggeredAreas.filter( - (a) => a.actionsValue > 0, - ); - return triggeredAreaWithAffectedActionUnit.length; + // For warning flood events this is not done, because there are no flood extens for warning events so we do not know any actions values + if ( + disasterType === DisasterType.Floods && + statusLabel === TriggerStatusLabelEnum.Warning + ) { + return triggeredAreas.length; + } else { + const triggeredAreasWithoutActionValue = triggeredAreas.filter( + (a) => a.actionsValue > 0, + ); + return triggeredAreasWithoutActionValue.length; + } } private async getSortedTriggeredAreas( From 1a1ad1bfa0d50e0eec583525c7cfa332443d6733 Mon Sep 17 00:00:00 2001 From: Ruben Date: Thu, 2 May 2024 14:59:20 +0200 Subject: [PATCH 07/21] Improved styling and moved to seperate file (styles.ejs) AB#27502 --- .../email/email-template.service.ts | 14 +- .../email/html/advisory-trigger.html | 6 +- .../src/api/notification/email/html/base.html | 4 +- .../notification/email/html/body-event.html | 138 +++--------------- .../html/body-total-affected-trigger.html | 15 +- .../email/html/event-finished.html | 48 ++---- .../api/notification/email/html/header.html | 9 +- .../api/notification/email/html/styles.ejs | 83 +++++++++++ .../notification/email/html/table-event.html | 96 +++--------- .../email/html/table-trigger-row.html | 34 +---- .../email/html/table-warning-row.html | 21 +-- .../email/html/trigger-finished.html | 2 +- .../email/html/trigger-notification.html | 15 +- 13 files changed, 170 insertions(+), 315 deletions(-) create mode 100644 services/API-service/src/api/notification/email/html/styles.ejs diff --git a/services/API-service/src/api/notification/email/email-template.service.ts b/services/API-service/src/api/notification/email/email-template.service.ts index 083ca869b..ac54718ff 100644 --- a/services/API-service/src/api/notification/email/email-template.service.ts +++ b/services/API-service/src/api/notification/email/email-template.service.ts @@ -190,6 +190,7 @@ export class EmailTemplateService { } private getEmailBody(triggerFinished: boolean): string { + // TODO: Also apply new EJS style templating to these files if (triggerFinished) { return this.readHtmlFile('trigger-finished.html'); } else { @@ -335,7 +336,10 @@ export class EmailTemplateService { } private formatEmail(emailKeyValueReplaceList: ReplaceKeyValue[]): string { + // TODO REFACTOR: Apply styles in a septerate file also for the base.html const template = this.readHtmlFile('base.html'); + const styles = this.readHtmlFile('styles.ejs'); + const templateWithStyle = styles + template; const replacements = emailKeyValueReplaceList.reduce( (acc, { replaceKey, replaceValue }) => { acc[replaceKey] = replaceValue; @@ -344,7 +348,7 @@ export class EmailTemplateService { {}, ); - let emailHtml = template; + let emailHtml = templateWithStyle; let previousHtml = null; // This loop is needed to handle nested EJS tags. It repeatedly renders the template @@ -428,7 +432,6 @@ export class EmailTemplateService { eventName: event.eventName, nrOfTriggeredAreas: event.nrOfTriggeredAreas, expectedTriggerDate: event.firstLeadTime, - expectedExposedAdminBoundary: event.nrOfTriggeredAreas, issuedDate: this.dateObjectToDateTimeString( event.issuedDate, emailContent.country.countryCodeISO3, @@ -454,7 +457,6 @@ export class EmailTemplateService { const templateFileName = 'body-event.html'; let template = this.readHtmlFile(templateFileName); - return ejs.render(template, data); }) .join(''); @@ -485,11 +487,11 @@ export class EmailTemplateService { // TODO: Define in a place where FrontEnd and Backend can share this switch (color) { case 'ibf-orange': - return '#f0890d'; + return '#aa6009'; case 'ibf-yellow': - return '#fff500'; + return '#7d6906'; default: - return '#c70000'; + return '#8a0f32'; } } diff --git a/services/API-service/src/api/notification/email/html/advisory-trigger.html b/services/API-service/src/api/notification/email/html/advisory-trigger.html index 40fdbbdd8..568dee3c8 100644 --- a/services/API-service/src/api/notification/email/html/advisory-trigger.html +++ b/services/API-service/src/api/notification/email/html/advisory-trigger.html @@ -1,2 +1,4 @@ -Activate Early Action Protocol +Activate + + Early Action Protocol diff --git a/services/API-service/src/api/notification/email/html/base.html b/services/API-service/src/api/notification/email/html/base.html index ca6072f8f..0a21277ce 100644 --- a/services/API-service/src/api/notification/email/html/base.html +++ b/services/API-service/src/api/notification/email/html/base.html @@ -397,7 +397,7 @@ #templateBody { /*@editable*/ - background-color: white; + background-color: #f5f5f594; /*@editable*/ background-image: none; /*@editable*/ @@ -564,7 +564,7 @@ @media only screen and (min-width: 768px) { .templateContainer { - width: 600px !important; + width: 800px !important; } } diff --git a/services/API-service/src/api/notification/email/html/body-event.html b/services/API-service/src/api/notification/email/html/body-event.html index b03ae3396..c5a33daec 100644 --- a/services/API-service/src/api/notification/email/html/body-event.html +++ b/services/API-service/src/api/notification/email/html/body-event.html @@ -1,125 +1,33 @@
- - triangleIcon - <%= hazard %> <%= eventName %>
+ + triangleIcon + <%= hazard %> <%= eventName %> + - - <%= disasterIssuedLabel %> + <%= disasterIssuedLabel %> + + <%= startDateEventString %>, <%= leadTime %>s from now. + - - <%= startDateEventString %>, <%= leadTime %>s from now.
-
- + Expected exposed <%= defaulAdminAreaLabel %>: - - <%= nrOfTriggeredAreas %> (see list below)
-
- - <%= indicatorLabel %>: - - - <%- totalAffected %> - - - Advisory: + + <%= nrOfTriggeredAreas %> (see list below) + - - <%- advisory %>
+ <%= indicatorLabel %>: + <%- totalAffected %> + Advisory: + + <%- advisory %> + - -
+
+ This <%= triggerStatusLabel %> was issued by the IBF portal on <%= - issuedDate %> (<%= timezone %>)
+ issuedDate %> (<%= timezone %>) +
+
diff --git a/services/API-service/src/api/notification/email/html/body-total-affected-trigger.html b/services/API-service/src/api/notification/email/html/body-total-affected-trigger.html index 8849930fe..f5e2bbe56 100644 --- a/services/API-service/src/api/notification/email/html/body-total-affected-trigger.html +++ b/services/API-service/src/api/notification/email/html/body-total-affected-trigger.html @@ -1,11 +1,4 @@ -<%= totalAffectectedOfIndicator %> <%= indicatorUnit %>
+ + <%= totalAffectectedOfIndicator %> <%= indicatorUnit %> + + diff --git a/services/API-service/src/api/notification/email/html/event-finished.html b/services/API-service/src/api/notification/email/html/event-finished.html index 7a607dbe3..12e17e678 100644 --- a/services/API-service/src/api/notification/email/html/event-finished.html +++ b/services/API-service/src/api/notification/email/html/event-finished.html @@ -1,39 +1,15 @@
- - <%= disasterTypeLabel %>: <%= eventName %> is now below threshold
+ + <%= disasterTypeLabel %>: <%= eventName %> is now below threshold + + + The events actions will continue to show on the IBF portal and can still be - managed.
-
This warning was issued by the IBF portal on <%= issuedDate %> (<%= - timezone %>) + managed. + + + + This warning was issued by the IBF portal on <%= issuedDate %> (<%= timezone + %>) +
diff --git a/services/API-service/src/api/notification/email/html/header.html b/services/API-service/src/api/notification/email/html/header.html index 5a5a87a43..1e75a6a6e 100644 --- a/services/API-service/src/api/notification/email/html/header.html +++ b/services/API-service/src/api/notification/email/html/header.html @@ -4,14 +4,7 @@

<%= nrOfEvents %> <%= disasterLabel %> alerts

IBF alert send on <%= sentOnDate %> (<%= timezone %>) diff --git a/services/API-service/src/api/notification/email/html/styles.ejs b/services/API-service/src/api/notification/email/html/styles.ejs new file mode 100644 index 000000000..cd532590e --- /dev/null +++ b/services/API-service/src/api/notification/email/html/styles.ejs @@ -0,0 +1,83 @@ + diff --git a/services/API-service/src/api/notification/email/html/table-event.html b/services/API-service/src/api/notification/email/html/table-event.html index 6c0cb02a5..6f87e4fdd 100644 --- a/services/API-service/src/api/notification/email/html/table-event.html +++ b/services/API-service/src/api/notification/email/html/table-event.html @@ -1,91 +1,34 @@ -
+
- Warning icon + Warning icon <%= severityLabel %> <%= triggerStatusLabel %> <%= hazard %>: <%= eventName %>
Expected exposed <%= defaulAdminAreaLabelPlural %><% if (triggerStatusLabel === 'Trigger') { %> in order of <%= indicatorLabel.toLowerCase() %><% } %>:
-
+
- + <% if (triggerStatusLabel === 'Trigger') { %> - <% } %> - @@ -94,4 +37,13 @@
+ <%= indicatorLabel %> + <%= defaulAdminAreaLabelSingular %> (<%= defaultAdminAreaLabelParent %>)
+ <% if (triggerStatusLabel === 'Warning') { %> +
+ Please note: Information regarding exposed population not available for + medium warning level. +
+ <% } %> +
diff --git a/services/API-service/src/api/notification/email/html/table-trigger-row.html b/services/API-service/src/api/notification/email/html/table-trigger-row.html index 38428b98e..b2881de88 100644 --- a/services/API-service/src/api/notification/email/html/table-trigger-row.html +++ b/services/API-service/src/api/notification/email/html/table-trigger-row.html @@ -1,36 +1,8 @@ - - + + <%= affectectedOfIndicator %> - + <%= adminBoundary %> (<%= higherAdminBoundary %>) diff --git a/services/API-service/src/api/notification/email/html/table-warning-row.html b/services/API-service/src/api/notification/email/html/table-warning-row.html index 85d41824e..a26f8d8ea 100644 --- a/services/API-service/src/api/notification/email/html/table-warning-row.html +++ b/services/API-service/src/api/notification/email/html/table-warning-row.html @@ -1,22 +1,5 @@ - - + + <%= adminBoundary %> (<%= higherAdminBoundary %>) diff --git a/services/API-service/src/api/notification/email/html/trigger-finished.html b/services/API-service/src/api/notification/email/html/trigger-finished.html index 5df98542b..0fd4f6d22 100644 --- a/services/API-service/src/api/notification/email/html/trigger-finished.html +++ b/services/API-service/src/api/notification/email/html/trigger-finished.html @@ -83,7 +83,7 @@ id="Editable_Content_Areas" >
- Dear reader, +
Dear Reader,


<%- eventOverview %> diff --git a/services/API-service/src/api/notification/email/html/trigger-notification.html b/services/API-service/src/api/notification/email/html/trigger-notification.html index d494e470c..f168c01cb 100644 --- a/services/API-service/src/api/notification/email/html/trigger-notification.html +++ b/services/API-service/src/api/notification/email/html/trigger-notification.html @@ -83,18 +83,7 @@ id="Editable_Content_Areas" >
-
- Dear Reader, -
+
Dear Reader,


<%- eventListBody %> @@ -140,6 +129,7 @@ border-radius: 3px; " align="left" + class="body-text" > Find more info on the potentially exposed areas on a map and manage @@ -192,6 +182,7 @@ border-radius: 3px; " align="left" + class="body-text" > Read about the trigger methodology and the anticipatory actions. From e9620159c2a5d33bd03efde64eae9bce09d8c226 Mon Sep 17 00:00:00 2001 From: Ruben Date: Thu, 2 May 2024 15:05:09 +0200 Subject: [PATCH 08/21] Tad of padding AB#27502 --- .../src/api/notification/email/html/table-event.html | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/services/API-service/src/api/notification/email/html/table-event.html b/services/API-service/src/api/notification/email/html/table-event.html index 6f87e4fdd..00790d217 100644 --- a/services/API-service/src/api/notification/email/html/table-event.html +++ b/services/API-service/src/api/notification/email/html/table-event.html @@ -13,12 +13,14 @@ <%= severityLabel %> <%= triggerStatusLabel %> <%= hazard %>: <%= eventName %> +
Expected exposed <%= defaulAdminAreaLabelPlural %><% if (triggerStatusLabel === 'Trigger') { %> in order of <%= indicatorLabel.toLowerCase() %><% } %>:
+
From 52188714455a0ac965be6838ab1d43ad469eea2b Mon Sep 17 00:00:00 2001 From: Ruben Date: Fri, 3 May 2024 14:16:11 +0200 Subject: [PATCH 09/21] AB#27502 Added juice to convert styles to inline html to support different email clients and browsers --- services/API-service/package-lock.json | 616 ++++++++++++++++++ services/API-service/package.json | 1 + .../email/email-template.service.ts | 29 +- .../api/notification/email/email.service.ts | 4 +- .../src/api/notification/email/html/base.html | 2 +- .../api/notification/email/html/styles.ejs | 10 +- .../notification/email/html/table-event.html | 106 +-- 7 files changed, 701 insertions(+), 67 deletions(-) diff --git a/services/API-service/package-lock.json b/services/API-service/package-lock.json index c54608ee2..000e24b72 100644 --- a/services/API-service/package-lock.json +++ b/services/API-service/package-lock.json @@ -24,6 +24,7 @@ "csv-parser": "^3.0.0", "ejs": "^3.1.9", "jsonwebtoken": "^8.1.1", + "juice": "^10.0.0", "mailchimp-api-v3": "^1.15.0", "mysql": "^2.15.0", "passport": "^0.4.1", @@ -2887,6 +2888,14 @@ "node": ">=6" } }, + "node_modules/ansi-colors": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "engines": { + "node": ">=6" + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -3354,6 +3363,11 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==" + }, "node_modules/boxen": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/boxen/-/boxen-4.2.0.tgz", @@ -3683,6 +3697,65 @@ "node": ">=16" } }, + "node_modules/cheerio": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0-rc.12.tgz", + "integrity": "sha512-VqR8m68vM46BNnuZ5NtnGBKIE/DfN0cRIzg9n40EIq9NOv90ayxLBXA8fXC5gquFRGJSTRqBq25Jt2ECLR431Q==", + "dependencies": { + "cheerio-select": "^2.1.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1", + "htmlparser2": "^8.0.1", + "parse5": "^7.0.0", + "parse5-htmlparser2-tree-adapter": "^7.0.0" + }, + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/cheeriojs/cheerio?sponsor=1" + } + }, + "node_modules/cheerio-select": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz", + "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", + "dependencies": { + "boolbase": "^1.0.0", + "css-select": "^5.1.0", + "css-what": "^6.1.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/cheerio/node_modules/parse5": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz", + "integrity": "sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==", + "dependencies": { + "entities": "^4.4.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/cheerio/node_modules/parse5-htmlparser2-tree-adapter": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.0.0.tgz", + "integrity": "sha512-B77tOZrqqfUfnVcOrUvfdLbz4pu4RopLD/4vmu3HUPswwTA8OH0EMW9BlWR2B0RCoiZRAHEUu7IxeP1Pd1UU+g==", + "dependencies": { + "domhandler": "^5.0.2", + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/chokidar": { "version": "3.4.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.4.3.tgz", @@ -3955,6 +4028,14 @@ "node": ">= 0.8" } }, + "node_modules/commander": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.1.tgz", + "integrity": "sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==", + "engines": { + "node": ">= 6" + } + }, "node_modules/component-emitter": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.0.tgz", @@ -4245,6 +4326,32 @@ "resolved": "https://registry.npmjs.org/cryptr/-/cryptr-6.0.2.tgz", "integrity": "sha512-1TRHI4bmuLIB8WgkH9eeYXzhEg1T4tonO4vVaMBKKde8Dre51J68nAgTVXTwMYXAf7+mV2gBCkm/9wksjSb2sA==" }, + "node_modules/css-select": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.1.0.tgz", + "integrity": "sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-what": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz", + "integrity": "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, "node_modules/cssom": { "version": "0.3.8", "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.3.8.tgz", @@ -4525,6 +4632,30 @@ "node": ">=6.0.0" } }, + "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==", + "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/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" + } + ] + }, "node_modules/domexception": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/domexception/-/domexception-1.0.1.tgz", @@ -4534,6 +4665,33 @@ "webidl-conversions": "^4.0.2" } }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz", + "integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==", + "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/dot-prop": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz", @@ -4627,6 +4785,17 @@ "once": "^1.4.0" } }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/error-ex": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", @@ -6248,6 +6417,24 @@ "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", "dev": true }, + "node_modules/htmlparser2": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", + "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1", + "entities": "^4.4.0" + } + }, "node_modules/http-cache-semantics": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.0.tgz", @@ -9543,6 +9730,24 @@ "verror": "1.10.0" } }, + "node_modules/juice": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/juice/-/juice-10.0.0.tgz", + "integrity": "sha512-9f68xmhGrnIi6DBkiiP3rUrQN33SEuaKu1+njX6VgMP+jwZAsnT33WIzlrWICL9matkhYu3OyrqSUP55YTIdGg==", + "dependencies": { + "cheerio": "^1.0.0-rc.12", + "commander": "^6.1.0", + "mensch": "^0.3.4", + "slick": "^1.12.2", + "web-resource-inliner": "^6.0.1" + }, + "bin": { + "juice": "bin/juice" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/jwa": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", @@ -9857,6 +10062,11 @@ "node": ">= 0.6" } }, + "node_modules/mensch": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/mensch/-/mensch-0.3.4.tgz", + "integrity": "sha512-IAeFvcOnV9V0Yk+bFhYR07O3yNina9ANIN5MoXBKYJ/RLYPurd2d0yw14MDhpr9/momp0WofT1bPUh3hkzdi/g==" + }, "node_modules/merge-descriptors": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", @@ -10324,6 +10534,17 @@ "node": ">=8" } }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, "node_modules/nwsapi": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.0.tgz", @@ -11980,6 +12201,14 @@ "node": ">=8" } }, + "node_modules/slick": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/slick/-/slick-1.12.2.tgz", + "integrity": "sha512-4qdtOGcBjral6YIBCWJ0ljFSKNLz9KkhbWtuGvUyRowl1kxfuE1x/Z/aJcaiilpb3do9bl5K7/1h9XC5wWpY/A==", + "engines": { + "node": "*" + } + }, "node_modules/slug": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/slug/-/slug-4.0.2.tgz", @@ -13684,6 +13913,14 @@ "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", "devOptional": true }, + "node_modules/valid-data-url": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/valid-data-url/-/valid-data-url-3.0.1.tgz", + "integrity": "sha512-jOWVmzVceKlVVdwjNSenT4PbGghU0SBIizAev8ofZVgivk/TVHXSbNL8LP6M3spZvkR9/QolkyJavGSX5Cs0UA==", + "engines": { + "node": ">=10" + } + }, "node_modules/validate-npm-package-license": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", @@ -13746,6 +13983,134 @@ "resolved": "https://registry.npmjs.org/weak-map/-/weak-map-1.0.8.tgz", "integrity": "sha512-lNR9aAefbGPpHO7AEnY0hCFjz1eTkWCXYvkTRrTHs9qv8zJp+SkVYpzfLIFXQQiG3tVvbNFQgVg2bQS8YGgxyw==" }, + "node_modules/web-resource-inliner": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/web-resource-inliner/-/web-resource-inliner-6.0.1.tgz", + "integrity": "sha512-kfqDxt5dTB1JhqsCUQVFDj0rmY+4HLwGQIsLPbyrsN9y9WV/1oFDSx3BQ4GfCv9X+jVeQ7rouTqwK53rA/7t8A==", + "dependencies": { + "ansi-colors": "^4.1.1", + "escape-goat": "^3.0.0", + "htmlparser2": "^5.0.0", + "mime": "^2.4.6", + "node-fetch": "^2.6.0", + "valid-data-url": "^3.0.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/web-resource-inliner/node_modules/dom-serializer": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz", + "integrity": "sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==", + "dependencies": { + "domelementtype": "^2.0.1", + "domhandler": "^4.2.0", + "entities": "^2.0.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/web-resource-inliner/node_modules/dom-serializer/node_modules/domhandler": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-4.3.1.tgz", + "integrity": "sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==", + "dependencies": { + "domelementtype": "^2.2.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/web-resource-inliner/node_modules/domhandler": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-3.3.0.tgz", + "integrity": "sha512-J1C5rIANUbuYK+FuFL98650rihynUOEzRLxW+90bKZRWB6A1X1Tf82GxR1qAWLyfNPRvjqfip3Q5tdYlmAa9lA==", + "dependencies": { + "domelementtype": "^2.0.1" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/web-resource-inliner/node_modules/domutils": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz", + "integrity": "sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==", + "dependencies": { + "dom-serializer": "^1.0.1", + "domelementtype": "^2.2.0", + "domhandler": "^4.2.0" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/web-resource-inliner/node_modules/domutils/node_modules/domhandler": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-4.3.1.tgz", + "integrity": "sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==", + "dependencies": { + "domelementtype": "^2.2.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/web-resource-inliner/node_modules/entities": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz", + "integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==", + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/web-resource-inliner/node_modules/escape-goat": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/escape-goat/-/escape-goat-3.0.0.tgz", + "integrity": "sha512-w3PwNZJwRxlp47QGzhuEBldEqVHHhh8/tIPcl6ecf2Bou99cdAt0knihBV0Ecc7CGxYduXVBDheH1K2oADRlvw==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/web-resource-inliner/node_modules/htmlparser2": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-5.0.1.tgz", + "integrity": "sha512-vKZZra6CSe9qsJzh0BjBGXo8dvzNsq/oGvsjfRdOrrryfeD9UOBEEQdeoqCRmKZchF5h2zOBMQ6YuQ0uRUmdbQ==", + "dependencies": { + "domelementtype": "^2.0.1", + "domhandler": "^3.3.0", + "domutils": "^2.4.2", + "entities": "^2.0.0" + }, + "funding": { + "url": "https://github.com/fb55/htmlparser2?sponsor=1" + } + }, + "node_modules/web-resource-inliner/node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/webidl-conversions": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz", @@ -16142,6 +16507,11 @@ } } }, + "ansi-colors": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==" + }, "ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -16526,6 +16896,11 @@ "unpipe": "1.0.0" } }, + "boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==" + }, "boxen": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/boxen/-/boxen-4.2.0.tgz", @@ -16792,6 +17167,52 @@ "resolved": "https://registry.npmjs.org/check-disk-space/-/check-disk-space-3.4.0.tgz", "integrity": "sha512-drVkSqfwA+TvuEhFipiR1OC9boEGZL5RrWvVsOthdcvQNXyCCuKkEiTOTXZ7qxSf/GLwq4GvzfrQD/Wz325hgw==" }, + "cheerio": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0-rc.12.tgz", + "integrity": "sha512-VqR8m68vM46BNnuZ5NtnGBKIE/DfN0cRIzg9n40EIq9NOv90ayxLBXA8fXC5gquFRGJSTRqBq25Jt2ECLR431Q==", + "requires": { + "cheerio-select": "^2.1.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1", + "htmlparser2": "^8.0.1", + "parse5": "^7.0.0", + "parse5-htmlparser2-tree-adapter": "^7.0.0" + }, + "dependencies": { + "parse5": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz", + "integrity": "sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==", + "requires": { + "entities": "^4.4.0" + } + }, + "parse5-htmlparser2-tree-adapter": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.0.0.tgz", + "integrity": "sha512-B77tOZrqqfUfnVcOrUvfdLbz4pu4RopLD/4vmu3HUPswwTA8OH0EMW9BlWR2B0RCoiZRAHEUu7IxeP1Pd1UU+g==", + "requires": { + "domhandler": "^5.0.2", + "parse5": "^7.0.0" + } + } + } + }, + "cheerio-select": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz", + "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", + "requires": { + "boolbase": "^1.0.0", + "css-select": "^5.1.0", + "css-what": "^6.1.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1" + } + }, "chokidar": { "version": "3.4.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.4.3.tgz", @@ -17009,6 +17430,11 @@ "delayed-stream": "~1.0.0" } }, + "commander": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.1.tgz", + "integrity": "sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==" + }, "component-emitter": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.0.tgz", @@ -17245,6 +17671,23 @@ "resolved": "https://registry.npmjs.org/cryptr/-/cryptr-6.0.2.tgz", "integrity": "sha512-1TRHI4bmuLIB8WgkH9eeYXzhEg1T4tonO4vVaMBKKde8Dre51J68nAgTVXTwMYXAf7+mV2gBCkm/9wksjSb2sA==" }, + "css-select": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.1.0.tgz", + "integrity": "sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==", + "requires": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + } + }, + "css-what": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz", + "integrity": "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==" + }, "cssom": { "version": "0.3.8", "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.3.8.tgz", @@ -17461,6 +17904,21 @@ "esutils": "^2.0.2" } }, + "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==", + "requires": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + } + }, + "domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==" + }, "domexception": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/domexception/-/domexception-1.0.1.tgz", @@ -17470,6 +17928,24 @@ "webidl-conversions": "^4.0.2" } }, + "domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "requires": { + "domelementtype": "^2.3.0" + } + }, + "domutils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz", + "integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==", + "requires": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + } + }, "dot-prop": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz", @@ -17545,6 +18021,11 @@ "once": "^1.4.0" } }, + "entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==" + }, "error-ex": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", @@ -18792,6 +19273,17 @@ "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", "dev": true }, + "htmlparser2": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", + "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==", + "requires": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1", + "entities": "^4.4.0" + } + }, "http-cache-semantics": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.0.tgz", @@ -21545,6 +22037,18 @@ "verror": "1.10.0" } }, + "juice": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/juice/-/juice-10.0.0.tgz", + "integrity": "sha512-9f68xmhGrnIi6DBkiiP3rUrQN33SEuaKu1+njX6VgMP+jwZAsnT33WIzlrWICL9matkhYu3OyrqSUP55YTIdGg==", + "requires": { + "cheerio": "^1.0.0-rc.12", + "commander": "^6.1.0", + "mensch": "^0.3.4", + "slick": "^1.12.2", + "web-resource-inliner": "^6.0.1" + } + }, "jwa": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", @@ -21812,6 +22316,11 @@ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==" }, + "mensch": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/mensch/-/mensch-0.3.4.tgz", + "integrity": "sha512-IAeFvcOnV9V0Yk+bFhYR07O3yNina9ANIN5MoXBKYJ/RLYPurd2d0yw14MDhpr9/momp0WofT1bPUh3hkzdi/g==" + }, "merge-descriptors": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", @@ -22207,6 +22716,14 @@ "integrity": "sha512-9UZCFRHQdNrfTpGg8+1INIg93B6zE0aXMVFkw1WFwvO4SlZywU6aLg5Of0Ap/PgcbSw4LNxvMWXMeugwMCX0AA==", "dev": true }, + "nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "requires": { + "boolbase": "^1.0.0" + } + }, "nwsapi": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.0.tgz", @@ -23513,6 +24030,11 @@ "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", "dev": true }, + "slick": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/slick/-/slick-1.12.2.tgz", + "integrity": "sha512-4qdtOGcBjral6YIBCWJ0ljFSKNLz9KkhbWtuGvUyRowl1kxfuE1x/Z/aJcaiilpb3do9bl5K7/1h9XC5wWpY/A==" + }, "slug": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/slug/-/slug-4.0.2.tgz", @@ -24789,6 +25311,11 @@ "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", "devOptional": true }, + "valid-data-url": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/valid-data-url/-/valid-data-url-3.0.1.tgz", + "integrity": "sha512-jOWVmzVceKlVVdwjNSenT4PbGghU0SBIizAev8ofZVgivk/TVHXSbNL8LP6M3spZvkR9/QolkyJavGSX5Cs0UA==" + }, "validate-npm-package-license": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", @@ -24842,6 +25369,95 @@ "resolved": "https://registry.npmjs.org/weak-map/-/weak-map-1.0.8.tgz", "integrity": "sha512-lNR9aAefbGPpHO7AEnY0hCFjz1eTkWCXYvkTRrTHs9qv8zJp+SkVYpzfLIFXQQiG3tVvbNFQgVg2bQS8YGgxyw==" }, + "web-resource-inliner": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/web-resource-inliner/-/web-resource-inliner-6.0.1.tgz", + "integrity": "sha512-kfqDxt5dTB1JhqsCUQVFDj0rmY+4HLwGQIsLPbyrsN9y9WV/1oFDSx3BQ4GfCv9X+jVeQ7rouTqwK53rA/7t8A==", + "requires": { + "ansi-colors": "^4.1.1", + "escape-goat": "^3.0.0", + "htmlparser2": "^5.0.0", + "mime": "^2.4.6", + "node-fetch": "^2.6.0", + "valid-data-url": "^3.0.0" + }, + "dependencies": { + "dom-serializer": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz", + "integrity": "sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==", + "requires": { + "domelementtype": "^2.0.1", + "domhandler": "^4.2.0", + "entities": "^2.0.0" + }, + "dependencies": { + "domhandler": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-4.3.1.tgz", + "integrity": "sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==", + "requires": { + "domelementtype": "^2.2.0" + } + } + } + }, + "domhandler": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-3.3.0.tgz", + "integrity": "sha512-J1C5rIANUbuYK+FuFL98650rihynUOEzRLxW+90bKZRWB6A1X1Tf82GxR1qAWLyfNPRvjqfip3Q5tdYlmAa9lA==", + "requires": { + "domelementtype": "^2.0.1" + } + }, + "domutils": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz", + "integrity": "sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==", + "requires": { + "dom-serializer": "^1.0.1", + "domelementtype": "^2.2.0", + "domhandler": "^4.2.0" + }, + "dependencies": { + "domhandler": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-4.3.1.tgz", + "integrity": "sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==", + "requires": { + "domelementtype": "^2.2.0" + } + } + } + }, + "entities": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz", + "integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==" + }, + "escape-goat": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/escape-goat/-/escape-goat-3.0.0.tgz", + "integrity": "sha512-w3PwNZJwRxlp47QGzhuEBldEqVHHhh8/tIPcl6ecf2Bou99cdAt0knihBV0Ecc7CGxYduXVBDheH1K2oADRlvw==" + }, + "htmlparser2": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-5.0.1.tgz", + "integrity": "sha512-vKZZra6CSe9qsJzh0BjBGXo8dvzNsq/oGvsjfRdOrrryfeD9UOBEEQdeoqCRmKZchF5h2zOBMQ6YuQ0uRUmdbQ==", + "requires": { + "domelementtype": "^2.0.1", + "domhandler": "^3.3.0", + "domutils": "^2.4.2", + "entities": "^2.0.0" + } + }, + "mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==" + } + } + }, "webidl-conversions": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz", diff --git a/services/API-service/package.json b/services/API-service/package.json index 74d68ab0d..3984b8b7d 100644 --- a/services/API-service/package.json +++ b/services/API-service/package.json @@ -42,6 +42,7 @@ "csv-parser": "^3.0.0", "ejs": "^3.1.9", "jsonwebtoken": "^8.1.1", + "juice": "^10.0.0", "mailchimp-api-v3": "^1.15.0", "mysql": "^2.15.0", "passport": "^0.4.1", diff --git a/services/API-service/src/api/notification/email/email-template.service.ts b/services/API-service/src/api/notification/email/email-template.service.ts index ac54718ff..ee8f1f33d 100644 --- a/services/API-service/src/api/notification/email/email-template.service.ts +++ b/services/API-service/src/api/notification/email/email-template.service.ts @@ -13,6 +13,7 @@ import { EventSummaryCountry, } from '../../../shared/data.model'; import { CountryEntity } from '../../country/country.entity'; +import * as juice from 'juice'; const emailFolder = './src/api/notification/email'; const emailTemplateFolder = `${emailFolder}/html`; @@ -25,10 +26,10 @@ class ReplaceKeyValue { @Injectable() export class EmailTemplateService { - public createHtmlForTriggerEmail( + public async createHtmlForTriggerEmail( emailContent: ContentEventEmail, date: Date, - ): string { + ): Promise { const replaceKeyValues = this.createReplaceKeyValuesTrigger( emailContent, date, @@ -37,25 +38,24 @@ export class EmailTemplateService { } // TODO REFACTOR this to use a DTO (ContentTriggerFinishedEmail) instead of multiple parameters - public createHtmlForTriggerFinishedEmail( + public async createHtmlForTriggerFinishedEmail( country: CountryEntity, disasterType: DisasterType, finishedEvents: EventSummaryCountry[], disasterTypeLabel: string, _date: Date, // I am not sure if this is needed and for what it was used before - ): string { + ): Promise { const replaceKeyValues = this.createReplaceKeyValuesTriggerFinished( country, disasterType, finishedEvents, disasterTypeLabel, ); - return this.formatEmail(replaceKeyValues); + return await this.formatEmail(replaceKeyValues); } private createReplaceKeyValuesTrigger( emailContent: ContentEventEmail, - date: Date, ): ReplaceKeyValue[] { const country = emailContent.country; const disasterType = emailContent.disasterType; @@ -335,7 +335,9 @@ export class EmailTemplateService { } } - private formatEmail(emailKeyValueReplaceList: ReplaceKeyValue[]): string { + private async formatEmail( + emailKeyValueReplaceList: ReplaceKeyValue[], + ): Promise { // TODO REFACTOR: Apply styles in a septerate file also for the base.html const template = this.readHtmlFile('base.html'); const styles = this.readHtmlFile('styles.ejs'); @@ -358,8 +360,19 @@ export class EmailTemplateService { previousHtml = emailHtml; emailHtml = ejs.render(previousHtml, replacements); } + // Inline the CSS + const inlinedHtml = await new Promise((resolve, reject) => { + juice.juiceResources(emailHtml, { webResources: {} }, (err, html) => { + if (err) reject(err); + else resolve(html); + }); + }); + fs.writeFile('output.html', inlinedHtml as string, (err) => { + if (err) throw err; + console.log('The file has been saved!'); + }); - return emailHtml; + return inlinedHtml as string; } private getTablesForEvents(emailContent: ContentEventEmail): string { diff --git a/services/API-service/src/api/notification/email/email.service.ts b/services/API-service/src/api/notification/email/email.service.ts index 8ec3ea611..2149f6773 100644 --- a/services/API-service/src/api/notification/email/email.service.ts +++ b/services/API-service/src/api/notification/email/email.service.ts @@ -52,7 +52,7 @@ export class EmailService { disasterType, activeEvents, ); - const emailHtml = this.emailTemplateService.createHtmlForTriggerEmail( + const emailHtml = await this.emailTemplateService.createHtmlForTriggerEmail( emailContent, date, ); @@ -76,7 +76,7 @@ export class EmailService { const disasterTypeLabel = await this.notificationContentService.getDisasterTypeLabel(disasterType); const emailHtml = - this.emailTemplateService.createHtmlForTriggerFinishedEmail( + await this.emailTemplateService.createHtmlForTriggerFinishedEmail( country, disasterType, finishedEvents, diff --git a/services/API-service/src/api/notification/email/html/base.html b/services/API-service/src/api/notification/email/html/base.html index 0a21277ce..4980390c6 100644 --- a/services/API-service/src/api/notification/email/html/base.html +++ b/services/API-service/src/api/notification/email/html/base.html @@ -397,7 +397,7 @@ #templateBody { /*@editable*/ - background-color: #f5f5f594; + background-color: #f4f5f8; /*@editable*/ background-image: none; /*@editable*/ diff --git a/services/API-service/src/api/notification/email/html/styles.ejs b/services/API-service/src/api/notification/email/html/styles.ejs index cd532590e..8dc314a29 100644 --- a/services/API-service/src/api/notification/email/html/styles.ejs +++ b/services/API-service/src/api/notification/email/html/styles.ejs @@ -50,16 +50,16 @@ padding-bottom: 4px; border: 1px #f5f6f9 solid; } - .header-row { - justify-content: flex-start; - align-items: center; - gap: 40px; - display: inline-flex; + .header-row td { + vertical-align: middle; + padding-right: 20px; /* adjust as needed */ + padding-left: 20px; /* adjust as needed */ } .table-cell { height: 19px; padding: 2px 10px; width: 211px; + text-align: left; } /* Event table styles */ diff --git a/services/API-service/src/api/notification/email/html/table-event.html b/services/API-service/src/api/notification/email/html/table-event.html index 00790d217..cad642c59 100644 --- a/services/API-service/src/api/notification/email/html/table-event.html +++ b/services/API-service/src/api/notification/email/html/table-event.html @@ -1,51 +1,55 @@ -
-
- Warning icon - <%= severityLabel %> <%= triggerStatusLabel %> <%= hazard %>: <%= eventName - %> -
-
-
- Expected exposed <%= defaulAdminAreaLabelPlural %><% if (triggerStatusLabel - === 'Trigger') { %> in order of <%= indicatorLabel.toLowerCase() %><% } %>: -
-
-
-
-
- - <% if (triggerStatusLabel === 'Trigger') { %> - - <% } %> - - - <%- tableRows %> -
- <%= indicatorLabel %> - - <%= defaulAdminAreaLabelSingular %> (<%= defaultAdminAreaLabelParent - %>) -
-
-
- <% if (triggerStatusLabel === 'Warning') { %> -
- Please note: Information regarding exposed population not available for - medium warning level. -
- <% } %> - -
+ + + + +
+
+
+ Warning icon + <%= severityLabel %> <%= triggerStatusLabel %> <%= hazard %>: <%= + eventName %> +
+
+
+ Expected exposed <%= defaulAdminAreaLabelPlural %><% if + (triggerStatusLabel === 'Trigger') { %> in order of <%= + indicatorLabel.toLowerCase() %><% } %>: +
+
+
+ + + <% if (triggerStatusLabel === 'Trigger') { %> + + <% } %> + + + <%- tableRows %> +
+ <%= indicatorLabel %> + + <%= defaulAdminAreaLabelSingular %> (<%= defaultAdminAreaLabelParent %>) +
+
+ <% if (triggerStatusLabel === 'Warning') { %> +
+ Please note: Information regarding exposed population not available + for medium warning level. +
+ <% } %> +
+
+
From 9e4693c739e4a0c1734c74cee0ac54bafc00dc47 Mon Sep 17 00:00:00 2001 From: Ruben Date: Fri, 3 May 2024 14:17:29 +0200 Subject: [PATCH 10/21] remove write to file --- .../src/api/notification/email/email-template.service.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/services/API-service/src/api/notification/email/email-template.service.ts b/services/API-service/src/api/notification/email/email-template.service.ts index ee8f1f33d..7c671ed6d 100644 --- a/services/API-service/src/api/notification/email/email-template.service.ts +++ b/services/API-service/src/api/notification/email/email-template.service.ts @@ -56,6 +56,7 @@ export class EmailTemplateService { private createReplaceKeyValuesTrigger( emailContent: ContentEventEmail, + _date: Date, ): ReplaceKeyValue[] { const country = emailContent.country; const disasterType = emailContent.disasterType; @@ -367,10 +368,6 @@ export class EmailTemplateService { else resolve(html); }); }); - fs.writeFile('output.html', inlinedHtml as string, (err) => { - if (err) throw err; - console.log('The file has been saved!'); - }); return inlinedHtml as string; } From dac4d6210e2767dff028741238fd7d0dae87803d Mon Sep 17 00:00:00 2001 From: Ruben Date: Mon, 13 May 2024 13:58:03 +0200 Subject: [PATCH 11/21] Styling details and links to ibf portal AB#27502 --- .../email/email-template.service.ts | 143 ++++++++---------- .../email/html/advisory-trigger.html | 7 +- .../src/api/notification/email/html/base.html | 11 -- .../notification/email/html/body-event.html | 4 +- .../email/html/event-finished.html | 2 +- .../api/notification/email/html/footer.html | 20 +++ .../email/html/notification-actions.html | 63 ++++++++ .../api/notification/email/html/styles.ejs | 45 +++++- .../notification/email/html/table-event.html | 13 +- .../email/html/table-trigger-row.html | 4 +- .../email/html/table-warning-row.html | 2 +- .../email/html/trigger-finished.html | 122 +-------------- .../email/html/trigger-notification.html | 111 +------------- .../api/notification/email/logos/logo-IBF.png | Bin 0 -> 2838 bytes 14 files changed, 212 insertions(+), 335 deletions(-) create mode 100644 services/API-service/src/api/notification/email/html/footer.html create mode 100644 services/API-service/src/api/notification/email/html/notification-actions.html create mode 100644 services/API-service/src/api/notification/email/logos/logo-IBF.png diff --git a/services/API-service/src/api/notification/email/email-template.service.ts b/services/API-service/src/api/notification/email/email-template.service.ts index 7c671ed6d..f373d1dfc 100644 --- a/services/API-service/src/api/notification/email/email-template.service.ts +++ b/services/API-service/src/api/notification/email/email-template.service.ts @@ -18,6 +18,7 @@ import * as juice from 'juice'; const emailFolder = './src/api/notification/email'; const emailTemplateFolder = `${emailFolder}/html`; const emailIconFolder = `${emailFolder}/icons`; +const emailLogoFolder = `${emailFolder}/logos`; class ReplaceKeyValue { replaceKey: string; @@ -70,8 +71,8 @@ export class EmailTemplateService { replaceValue: this.getHeaderEventStarted(emailContent), }, { - replaceKey: 'socialMediaPart', - replaceValue: this.getSocialMediaHtml(emailContent.country), + replaceKey: 'notificationActions', + replaceValue: this.getNotificationActionsHtml(country, disasterType), }, { replaceKey: 'tablesStacked', @@ -97,12 +98,6 @@ export class EmailTemplateService { replaceKey: 'linkDashboard', replaceValue: process.env.DASHBOARD_URL, }, - { - replaceKey: 'linkEapSop', - replaceValue: country.countryDisasterSettings.find( - (s) => s.disasterType === disasterType, - ).eapLink, - }, { replaceKey: 'socialMediaLink', replaceValue: country.notificationInfo.linkSocialMediaUrl, @@ -116,11 +111,8 @@ export class EmailTemplateService { replaceValue: emailContent.disasterTypeLabel, }, { - replaceKey: 'videoPdfLinks', - replaceValue: this.getVideoPdfLinks( - country.notificationInfo.linkVideo, - country.notificationInfo.linkPdf, - ), + replaceKey: 'footer', + replaceValue: this.getFooterHtml(), }, ]; return keyValueReplaceList; @@ -161,12 +153,6 @@ export class EmailTemplateService { replaceKey: 'socialMediaPart', replaceValue: this.getSocialMediaHtml(country), }, - { - replaceKey: 'linkEapSop', - replaceValue: country.countryDisasterSettings.find( - (s) => s.disasterType === disasterType, - ).eapLink, - }, { replaceKey: 'socialMediaLink', replaceValue: country.notificationInfo.linkSocialMediaUrl, @@ -175,17 +161,14 @@ export class EmailTemplateService { replaceKey: 'socialMediaType', replaceValue: country.notificationInfo.linkSocialMediaType, }, - { - replaceKey: 'videoPdfLinks', - replaceValue: this.getVideoPdfLinks( - country.notificationInfo.linkVideo, - country.notificationInfo.linkPdf, - ), - }, { replaceKey: 'disasterType', replaceValue: disasterTypeLabel, }, + { + replaceKey: 'footer', + replaceValue: this.getFooterHtml(), + }, ]; return keyValueReplaceList; } @@ -235,50 +218,22 @@ export class EmailTemplateService { return headerEventOverview; } - private getVideoPdfLinks(videoLink: string, pdfLink: string) { - // TODO: Use ejs template - const linkVideoHTML = ` -
video`; - - const linkPdfHTML = `PDF`; - let videoStr = ''; - if (videoLink) { - videoStr = ' ' + linkVideoHTML; - } - let pdfStr = ''; - if (pdfLink) { - pdfStr = ' ' + linkPdfHTML; - } - let orStr = ''; - if (videoStr && pdfStr) { - orStr = ' or'; - } - if (videoStr || pdfStr) { - return `See instructions for the IBF-portal in${videoStr}${orStr}${pdfStr}.`; - } + private getNotificationActionsHtml( + country: CountryEntity, + disasterType: DisasterType, + ): string { + const socialMediaLinkHtml = this.getSocialMediaHtml(country); + + let html = this.readHtmlFile('notification-actions.html'); + const data = { + linkDashboard: process.env.DASHBOARD_URL, + linkEapSop: country.countryDisasterSettings.find( + (s) => s.disasterType === disasterType, + ).eapLink, + socialMediaPart: socialMediaLinkHtml, + }; + html = ejs.render(html, data); + return html; // } private getSocialMediaHtml(country: CountryEntity): string { @@ -364,8 +319,12 @@ export class EmailTemplateService { // Inline the CSS const inlinedHtml = await new Promise((resolve, reject) => { juice.juiceResources(emailHtml, { webResources: {} }, (err, html) => { - if (err) reject(err); - else resolve(html); + if (err) { + console.error('Error inlining CSS: ', err); + reject(err); + } else { + resolve(html); + } }); }); @@ -404,7 +363,9 @@ export class EmailTemplateService { .join(''); } - private getEventSeverityLabel(eapAlertClassKey: EapAlertClassKeyEnum) { + private getEventSeverityLabel( + eapAlertClassKey: EapAlertClassKeyEnum, + ): string { if (eapAlertClassKey === EapAlertClassKeyEnum.med) { return 'Medium'; } else if (eapAlertClassKey === EapAlertClassKeyEnum.min) { @@ -458,10 +419,14 @@ export class EmailTemplateService { leadTime: event.firstLeadTime.replace('-', ' '), disasterIssuedLabel: event.eapAlertClass.label, color: this.ibfColorToHex(event.eapAlertClass?.color), - advisory: this.getAdvisoryHtml(event.triggerStatusLabel), + advisory: this.getAdvisoryHtml( + event.triggerStatusLabel, + emailContent.country, + emailContent.disasterType, + ), totalAffected: this.getTotalAffectedHtml( event, - emailContent.indicatorMetadata.unit, + emailContent.indicatorMetadata.label.toLowerCase(), ), }; @@ -472,10 +437,19 @@ export class EmailTemplateService { .join(''); } - private getAdvisoryHtml(triggerStatusLabel: TriggerStatusLabelEnum): string { - return triggerStatusLabel === TriggerStatusLabelEnum.Trigger - ? this.readHtmlFile('advisory-trigger.html') - : this.readHtmlFile('advisory-warning.html'); + private getAdvisoryHtml( + triggerStatusLabel: TriggerStatusLabelEnum, + country: CountryEntity, + disasterType: DisasterType, + ): string { + const advisoryHtml = + triggerStatusLabel === TriggerStatusLabelEnum.Trigger + ? this.readHtmlFile('advisory-trigger.html') + : this.readHtmlFile('advisory-warning.html'); + const eapLink = country.countryDisasterSettings.find( + (s) => s.disasterType === disasterType, + ).eapLink; + return ejs.render(advisoryHtml, { eapLink: eapLink }); } private getTotalAffectedHtml( @@ -493,6 +467,12 @@ export class EmailTemplateService { } } + private getFooterHtml(): string { + const footerHtml = this.readHtmlFile('footer.html'); + const ibfLogo = this.getLogoImageAsDataURL(); + return ejs.render(footerHtml, { ibfLogo: ibfLogo }); + } + private ibfColorToHex(color: string): string { // TODO: Define in a place where FrontEnd and Backend can share this switch (color) { @@ -542,6 +522,11 @@ export class EmailTemplateService { return imageDataURL; } + private getLogoImageAsDataURL() { + const filePath = `${emailLogoFolder}/logo-IBF.png`; + return this.getPngImageAsDataURL(filePath); + } + private getPngImageAsDataURL(relativePath: string) { const imageBuffer = fs.readFileSync(relativePath); const imageDataURL = `data:image/png;base64,${imageBuffer.toString( diff --git a/services/API-service/src/api/notification/email/html/advisory-trigger.html b/services/API-service/src/api/notification/email/html/advisory-trigger.html index 568dee3c8..d9e896dda 100644 --- a/services/API-service/src/api/notification/email/html/advisory-trigger.html +++ b/services/API-service/src/api/notification/email/html/advisory-trigger.html @@ -1,4 +1,7 @@ Activate - - Early Action Protocol + Early Action Protocol + diff --git a/services/API-service/src/api/notification/email/html/base.html b/services/API-service/src/api/notification/email/html/base.html index 4980390c6..2771d5b3e 100644 --- a/services/API-service/src/api/notification/email/html/base.html +++ b/services/API-service/src/api/notification/email/html/base.html @@ -62,17 +62,6 @@ width: 220px; } - .notification-action-button { - height: 40px; - width: 200px; - background-color: #6200ee; - color: #ffffff; - font-weight: bold; - border: 0px; - border-radius: 40px; - cursor: pointer; - } - p { margin: 10px 0; padding: 0; diff --git a/services/API-service/src/api/notification/email/html/body-event.html b/services/API-service/src/api/notification/email/html/body-event.html index c5a33daec..df371dfaa 100644 --- a/services/API-service/src/api/notification/email/html/body-event.html +++ b/services/API-service/src/api/notification/email/html/body-event.html @@ -4,7 +4,7 @@ <%= hazard %> <%= eventName %> - <%= disasterIssuedLabel %> + <%= disasterIssuedLabel %>: <%= startDateEventString %>, <%= leadTime %>s from now. @@ -24,7 +24,7 @@
- + This <%= triggerStatusLabel %> was issued by the IBF portal on <%= issuedDate %> (<%= timezone %>) diff --git a/services/API-service/src/api/notification/email/html/event-finished.html b/services/API-service/src/api/notification/email/html/event-finished.html index 12e17e678..188cb295a 100644 --- a/services/API-service/src/api/notification/email/html/event-finished.html +++ b/services/API-service/src/api/notification/email/html/event-finished.html @@ -8,7 +8,7 @@ managed. - + This warning was issued by the IBF portal on <%= issuedDate %> (<%= timezone %>) diff --git a/services/API-service/src/api/notification/email/html/footer.html b/services/API-service/src/api/notification/email/html/footer.html new file mode 100644 index 000000000..615c4ff56 --- /dev/null +++ b/services/API-service/src/api/notification/email/html/footer.html @@ -0,0 +1,20 @@ + + + + + +
+ + +
+ Impact-Based Forecasting Portal (IBF) was co-developed by Netherlands + Red Cross 510 the together with the Uganda Red Cross National Society. + For questions contact us at ibf-support@510.global +
+
diff --git a/services/API-service/src/api/notification/email/html/notification-actions.html b/services/API-service/src/api/notification/email/html/notification-actions.html new file mode 100644 index 000000000..b7a42d92e --- /dev/null +++ b/services/API-service/src/api/notification/email/html/notification-actions.html @@ -0,0 +1,63 @@ + + + + + + + <%- socialMediaPart %> + + + + + +
+ + + + +
+ Go to the IBF-portal +
+
+ + + + +
+ Find more information about the potentially exposed areas, view + the map and manage anticipatory actions. +
+
+ + + + +
+ About trigger +
+
+ + + + +
+ Read about the trigger methodology and the anticipatory actions. +
+
diff --git a/services/API-service/src/api/notification/email/html/styles.ejs b/services/API-service/src/api/notification/email/html/styles.ejs index 8dc314a29..8575726c6 100644 --- a/services/API-service/src/api/notification/email/html/styles.ejs +++ b/services/API-service/src/api/notification/email/html/styles.ejs @@ -13,7 +13,7 @@ .body-text-normal { font-weight: 400; } - .body-text-smaller { + .body-text-16px { font-size: 16px; line-height: 19.2px; } @@ -25,6 +25,13 @@ .body-text-white { color: white; } + .body-text-12px { + font-size: 12px; + line-height: 14.4px; + } + .important-black { + color: black !important; + } /* Icon styles */ .body-text-icon { @@ -32,6 +39,13 @@ height: 14px; } + .ibf-logo { + width: 45px; + height: 45px; + display: block; + padding-right: 10px; + } + /* Text formatting styles */ .body-text-break { display: block; @@ -58,7 +72,6 @@ .table-cell { height: 19px; padding: 2px 10px; - width: 211px; text-align: left; } @@ -80,4 +93,32 @@ .padding-top { padding-top: 10px; } + + /* Notification action button styles */ + .notification-action-button { + height: 40px; + width: 200px; + font-weight: bold; + border: 0px; + border-radius: 40px; + cursor: pointer; + } + .notification-action-button-primary { + background-color: #6200ee; + } + .notification-action-button.notification-action-button-primary a { + color: #ffffff !important; + text-decoration: none !important; + } + .notification-action-button-secondary { + background-color: #ffffff; + } + .notification-action-button.notification-action-button-secondary a { + color: #6200ee !important; + text-decoration: none !important; + } + .button-text-padding { + padding: 12px 18px 12px 18px; + border-radius: 3px; + } diff --git a/services/API-service/src/api/notification/email/html/table-event.html b/services/API-service/src/api/notification/email/html/table-event.html index cad642c59..6072f2057 100644 --- a/services/API-service/src/api/notification/email/html/table-event.html +++ b/services/API-service/src/api/notification/email/html/table-event.html @@ -18,7 +18,7 @@
Expected exposed <%= defaulAdminAreaLabelPlural %><% if (triggerStatusLabel === 'Trigger') { %> in order of <%= @@ -26,15 +26,16 @@
- +
<% if (triggerStatusLabel === 'Trigger') { %> - <% } %> - <%- tableRows %> @@ -42,7 +43,7 @@ <% if (triggerStatusLabel === 'Warning') { %>
Please note: Information regarding exposed population not available for medium warning level. diff --git a/services/API-service/src/api/notification/email/html/table-trigger-row.html b/services/API-service/src/api/notification/email/html/table-trigger-row.html index b2881de88..8f942e29e 100644 --- a/services/API-service/src/api/notification/email/html/table-trigger-row.html +++ b/services/API-service/src/api/notification/email/html/table-trigger-row.html @@ -1,8 +1,8 @@
- - diff --git a/services/API-service/src/api/notification/email/html/table-warning-row.html b/services/API-service/src/api/notification/email/html/table-warning-row.html index a26f8d8ea..b551f1d03 100644 --- a/services/API-service/src/api/notification/email/html/table-warning-row.html +++ b/services/API-service/src/api/notification/email/html/table-warning-row.html @@ -1,5 +1,5 @@ - diff --git a/services/API-service/src/api/notification/email/html/trigger-finished.html b/services/API-service/src/api/notification/email/html/trigger-finished.html index 0fd4f6d22..fbd5ff3ea 100644 --- a/services/API-service/src/api/notification/email/html/trigger-finished.html +++ b/services/API-service/src/api/notification/email/html/trigger-finished.html @@ -86,126 +86,8 @@
Dear Reader,


- <%- eventOverview %> -
+ <%= indicatorLabel %> - <%= defaulAdminAreaLabelSingular %> (<%= defaultAdminAreaLabelParent %>) + + <%= defaulAdminAreaLabelSingular %> (<%= + defaultAdminAreaLabelParent %>)
+ <%= affectectedOfIndicator %> + <%= adminBoundary %> (<%= higherAdminBoundary %>)
+ <%= adminBoundary %> (<%= higherAdminBoundary %>)
- - - - - - - - - - <%- socialMediaPart %> - -
- - - - -
- Go to IBF-portal -
-
- - - - -
- 1. Manage the anticipatory actions - in the IBF-portal for 1 more week. - <%- videoPdfLinks %> -
-
- - - - -
- About trigger -
-
- - - - -
- 2. Read about the trigger - methodology and the anticipatory - actions. -
-
+ <%- eventOverview %> <%- notificationActions %> + <%- footer %>
diff --git a/services/API-service/src/api/notification/email/html/trigger-notification.html b/services/API-service/src/api/notification/email/html/trigger-notification.html index f168c01cb..110ceb0d1 100644 --- a/services/API-service/src/api/notification/email/html/trigger-notification.html +++ b/services/API-service/src/api/notification/email/html/trigger-notification.html @@ -85,114 +85,7 @@
Dear Reader,


- <%- eventListBody %> - - - - - - - <%- socialMediaPart %> - - - - - -
- - - - -
- Go to the IBF-portal -
-
- - - - -
- Find more info on the potentially - exposed areas on a map and manage - anticipatory actions in the - IBF-portal. <%- videoPdfLinks %> -
-
- - - - -
- About trigger -
-
- - - - -
- Read about the trigger methodology - and the anticipatory actions. -
-
+ <%- eventListBody %> <%- notificationActions %>
Trigger Statement: <%- triggerStatement %> @@ -200,7 +93,7 @@ <%= mapImagePart %>

- <%- tablesStacked %> + <%- tablesStacked %> <%- footer %>
diff --git a/services/API-service/src/api/notification/email/logos/logo-IBF.png b/services/API-service/src/api/notification/email/logos/logo-IBF.png new file mode 100644 index 0000000000000000000000000000000000000000..b006cbaca414ca383765a59458ad3b4a401e3849 GIT binary patch literal 2838 zcmV+x3+eQUP)Gzu1K$eeGIpYg-{jY)FZ(7C|)CN)RfNKq8OEV#K5gH9}go(I6T$nl>0Q z*jNaXsx(#tsf~adUwu%iSxPtEF0HoP-Mx2r@7~wUoa28UGxyA$S$4Oa&DryqnKR${ z&iDW4KXVDZ%k<{bug|ZL%9j}Nufe>ElAwoEzmtKV;lwA51c3cu$^fX%01_@W5x9hO zXwkPcQ8uZ9zbtY^GV2`ZCl%sRPg63GnXj z{p%!Q2l3H`$Rj?=K!jO3b)9+CA4qD;+I>OnKJK)c)8@5%oM%IB37G-(6CE$z29oSU zz-YdU?$ZEhe3I@&qRNx>j;Upsh&V8=j6a4 z%RtRLFcPF*Pk5oQ9s+)_aj=gFx?jPI3`$zI!8J06eaT`P7t+ysv=a4wF7w>>JKiF? zPOawa>e69adVISFf>J`YU!<1Vh;guOVusLla8@I_C#(m0!*ScORfyQmz+C;m_I&rj z*`<{7WBAf0wUsI`ANxu(Tz_{fwDwRK8K}aaewc%|4i@xQ%b0-m-{^)9-r4~yTa#C~ z34~=i@4@h~3Hav^2B3JMWM6Io&tKaG*KJz?t@E3qw)08ti)%X`_Dc!F2M3`4JBLu3 zX4BEvzYG^2$D6lO{g8y^>of4s%UvCs&P(ZkoGpHh(?|1{cvH4AabQz3M~w4aB0a$AL-j2Y_4g+_3bck zO*aBIz~_=G`6^s^eG)1Gf)1L1m#Mg!NEH?mP=B~}Py4WP>{1AA!S!isRKT_>U2&=FpZ%gMO9^60Fn4Cn*@% zO4%Y6wAm5WP|1l+jR|=D1y8?}@Ct&CUoQ;%F!(P|OT+~J@iFpes?dL6-0}Y+^5be< zsRgY1c|iQD6wnYbL6;{<`q@A_!0S^>H-*J(n?QI1R&Gcd*q?We!}u`OE>0P9ead!Z zp?g(ljOpTV1xmxE$i*tafnwUHmBm!Ctam}8V<>?GrzO<>wCZWOjKbDeyHl?{w4fh?0j2$0NQ$tm+4x>;I5~-U}~Jei9eLBre~KXM*x9lo%jdkQDsGsNuD);G3--; zIBA&%bQRfFnoquTWH5kjOLL$MOv$XJi^3DboxrSl@CE{(+t~)k5BNCcIowfA z%AfP$?@v!e#;bq>2|VtxQXYM8ivrDTyy}xmDrhAi9W14|EZ;-^tyfTgg>ea$f`I1E znonPF4TrY(a2Wqz67=8p6ycfs#3AqHMQ%`1HB4JO#t z2CxCHj4~Z`*@8|QD$fx3(8d|IW?cQzp&-==wO^3t072|-Af$;lg$8WoL8!REtGsZW z2!1CM3Uo#T_ok6Yl_8BPpq$3L%#@BDs6esk>%AZ?nX{&}pbdDTx3Z9V+!cLsh*pTd zx6CG?%rUEy6(Bb?nHaEA5>O^ZsL~Qll`1gTG&1DFFCHAztE6(Z6z98IRR3%7Phd~x zfj-P)7AEfTMe~bnZ1Tm4=F8UJiR^U?iUVRkJCxUqtp4zokjXGOe|8GKzwI!b>L1fa zIC!!E^AnZ5jqP=KQ zPCUmr`x*jmtdT4BzQ7S4x3AAjyNRI+P z*P6O116!A234tl8u=fPoP0ExEI-XH3RA|}g5Fz|}8kIWk+55D-{7Hp33g^(wc7kB{ zVBn-;DezgvEU}e~k`1GVaqiUPvN7IAeg1V_D6_2BR)RLYd)l!wuMwq!x( zTOrmcn)k>4o#U6S!eeUi5j|`DXPH8led<}p1I?^i1z)d7*e6L4v7VNtW!%)$Rw3*+ ztb-~w7V+p_vG9cuYb}ntZ60iuM1Y0mmdnUFo>;5LLYQ-UFqzuhnBr{nRV+{R5=)eJAl0 z`x%6n0SJE!34hdabELaS|AE!N0qyGFnFw(`P@VSi%t9cz_m7jGd?pRwv>(-F{^SYL zx#R>I!;Q#lbHs9t^*H#juHpqjL$b*)(sqZO0wI38t;3JoS7Hd+i>I3o^yj#75f2&8V;)7o o$APf_;4ck(%BO#G2Hr*bKkY_1nNM3loB#j-07*qoM6N<$g0*UN{Qv*} literal 0 HcmV?d00001 From 9f1a7c64daf66424bf1c43cf5a535ffb736a3ab7 Mon Sep 17 00:00:00 2001 From: Ruben Date: Mon, 13 May 2024 15:43:18 +0200 Subject: [PATCH 12/21] Changed key value array to object --- .../email/email-template.service.ts | 155 +++++------------- 1 file changed, 41 insertions(+), 114 deletions(-) diff --git a/services/API-service/src/api/notification/email/email-template.service.ts b/services/API-service/src/api/notification/email/email-template.service.ts index f373d1dfc..f72a146c1 100644 --- a/services/API-service/src/api/notification/email/email-template.service.ts +++ b/services/API-service/src/api/notification/email/email-template.service.ts @@ -38,7 +38,6 @@ export class EmailTemplateService { return this.formatEmail(replaceKeyValues); } - // TODO REFACTOR this to use a DTO (ContentTriggerFinishedEmail) instead of multiple parameters public async createHtmlForTriggerFinishedEmail( country: CountryEntity, disasterType: DisasterType, @@ -58,64 +57,29 @@ export class EmailTemplateService { private createReplaceKeyValuesTrigger( emailContent: ContentEventEmail, _date: Date, - ): ReplaceKeyValue[] { + ): Record { const country = emailContent.country; const disasterType = emailContent.disasterType; - const keyValueReplaceList = [ - { - replaceKey: 'emailBody', - replaceValue: this.getEmailBody(false), - }, - { - replaceKey: 'headerEventOverview', - replaceValue: this.getHeaderEventStarted(emailContent), - }, - { - replaceKey: 'notificationActions', - replaceValue: this.getNotificationActionsHtml(country, disasterType), - }, - { - replaceKey: 'tablesStacked', - replaceValue: this.getTablesForEvents(emailContent), - }, - { - replaceKey: 'eventListBody', - replaceValue: this.getEventListBody(emailContent), - }, - { - replaceKey: 'imgLogo', - replaceValue: country.notificationInfo.logo[disasterType], - }, - { - replaceKey: 'triggerStatement', - replaceValue: country.notificationInfo.triggerStatement[disasterType], - }, - { - replaceKey: 'mapImagePart', - replaceValue: this.getMapImageHtml(emailContent), - }, - { - replaceKey: 'linkDashboard', - replaceValue: process.env.DASHBOARD_URL, - }, - { - replaceKey: 'socialMediaLink', - replaceValue: country.notificationInfo.linkSocialMediaUrl, - }, - { - replaceKey: 'socialMediaType', - replaceValue: country.notificationInfo.linkSocialMediaType, - }, - { - replaceKey: 'disasterType', - replaceValue: emailContent.disasterTypeLabel, - }, - { - replaceKey: 'footer', - replaceValue: this.getFooterHtml(), - }, - ]; - return keyValueReplaceList; + + const keyValueReplaceObject = { + emailBody: this.getEmailBody(false), + headerEventOverview: this.getHeaderEventStarted(emailContent), + notificationActions: this.getNotificationActionsHtml( + country, + disasterType, + ), + tablesStacked: this.getTablesForEvents(emailContent), + eventListBody: this.getEventListBody(emailContent), + imgLogo: country.notificationInfo.logo[disasterType], + triggerStatement: country.notificationInfo.triggerStatement[disasterType], + mapImagePart: this.getMapImageHtml(emailContent), + linkDashboard: process.env.DASHBOARD_URL, + socialMediaLink: country.notificationInfo.linkSocialMediaUrl, + socialMediaType: country.notificationInfo.linkSocialMediaType, + disasterType: emailContent.disasterTypeLabel, + footer: this.getFooterHtml(), + }; + return keyValueReplaceObject; } private createReplaceKeyValuesTriggerFinished( @@ -123,54 +87,24 @@ export class EmailTemplateService { disasterType: DisasterType, events: EventSummaryCountry[], disasterTypeLabel: string, - ): ReplaceKeyValue[] { - const keyValueReplaceList = [ - { - replaceKey: 'emailBody', - replaceValue: this.getEmailBody(true), - }, - { - replaceKey: 'headerEventOverview', - replaceValue: '', - }, - { - replaceKey: 'eventOverview', - replaceValue: this.geEventsFinishedOverview( - country, - events, - disasterTypeLabel, - ), - }, - { - replaceKey: 'imgLogo', - replaceValue: country.notificationInfo.logo[disasterType], - }, - { - replaceKey: 'linkDashboard', - replaceValue: process.env.DASHBOARD_URL, - }, - { - replaceKey: 'socialMediaPart', - replaceValue: this.getSocialMediaHtml(country), - }, - { - replaceKey: 'socialMediaLink', - replaceValue: country.notificationInfo.linkSocialMediaUrl, - }, - { - replaceKey: 'socialMediaType', - replaceValue: country.notificationInfo.linkSocialMediaType, - }, - { - replaceKey: 'disasterType', - replaceValue: disasterTypeLabel, - }, - { - replaceKey: 'footer', - replaceValue: this.getFooterHtml(), - }, - ]; - return keyValueReplaceList; + ): Record { + const keyValueReplaceObject = { + emailBody: this.getEmailBody(true), + headerEventOverview: '', + eventOverview: this.geEventsFinishedOverview( + country, + events, + disasterTypeLabel, + ), + imgLogo: country.notificationInfo.logo[disasterType], + linkDashboard: process.env.DASHBOARD_URL, + socialMediaPart: this.getSocialMediaHtml(country), + socialMediaLink: country.notificationInfo.linkSocialMediaUrl, + socialMediaType: country.notificationInfo.linkSocialMediaType, + disasterType: disasterTypeLabel, + footer: this.getFooterHtml(), + }; + return keyValueReplaceObject; } private getEmailBody(triggerFinished: boolean): string { @@ -292,19 +226,12 @@ export class EmailTemplateService { } private async formatEmail( - emailKeyValueReplaceList: ReplaceKeyValue[], + emailKeyValueReplaceObject: Record, ): Promise { // TODO REFACTOR: Apply styles in a septerate file also for the base.html const template = this.readHtmlFile('base.html'); const styles = this.readHtmlFile('styles.ejs'); const templateWithStyle = styles + template; - const replacements = emailKeyValueReplaceList.reduce( - (acc, { replaceKey, replaceValue }) => { - acc[replaceKey] = replaceValue; - return acc; - }, - {}, - ); let emailHtml = templateWithStyle; let previousHtml = null; @@ -314,7 +241,7 @@ export class EmailTemplateService { // doesn't render nested tags in one pass. while (emailHtml !== previousHtml) { previousHtml = emailHtml; - emailHtml = ejs.render(previousHtml, replacements); + emailHtml = ejs.render(previousHtml, emailKeyValueReplaceObject); } // Inline the CSS const inlinedHtml = await new Promise((resolve, reject) => { From 42a6d7e22f0289c013b68b005fae3d2d8501a88b Mon Sep 17 00:00:00 2001 From: Ruben Date: Mon, 13 May 2024 17:38:40 +0200 Subject: [PATCH 13/21] AB#27263 First setup api test: uga floods --- services/API-service/jest.api.config.js | 16 ++++ services/API-service/package.json | 2 + .../src/scripts/geoserver-sync.service.ts | 4 + .../src/scripts/mock.controller.ts | 5 ++ .../API-service/src/scripts/mock.service.ts | 2 +- .../src/scripts/scripts.service.ts | 1 + .../test/email/email-uga-floods.test.ts | 39 ++++++++++ .../test/helpers/utility.helper.ts | 73 +++++++++++++++++++ services/API-service/test/tsconfig.json | 8 ++ 9 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 services/API-service/jest.api.config.js create mode 100644 services/API-service/test/email/email-uga-floods.test.ts create mode 100644 services/API-service/test/helpers/utility.helper.ts create mode 100644 services/API-service/test/tsconfig.json diff --git a/services/API-service/jest.api.config.js b/services/API-service/jest.api.config.js new file mode 100644 index 000000000..bdb761c21 --- /dev/null +++ b/services/API-service/jest.api.config.js @@ -0,0 +1,16 @@ +/** + * @type {import('@jest/types').Config.InitialOptions} + */ +module.exports = { + moduleFileExtensions: ['js', 'ts'], + transform: { + '^.+\\.ts?$': ['ts-jest', { tsconfig: '/test/tsconfig.json' }], + }, + rootDir: '.', + testMatch: ['/test/**/*.test.ts'], + coverageReporters: ['json', 'lcov'], + modulePathIgnorePatterns: ['/dist/'], + testTimeout: 30_000, + verbose: true, + reporters: ['default'], +}; diff --git a/services/API-service/package.json b/services/API-service/package.json index 3984b8b7d..9d7baa334 100644 --- a/services/API-service/package.json +++ b/services/API-service/package.json @@ -16,6 +16,8 @@ "test": "jest --config=jest.json --detectOpenHandles --forceExit --passWithNoTests", "test:dev": "npm test -- --watchAll", "test:coverage": "npm test -- --coverage --coverageDirectory=coverage", + "test:api:all": "node --expose-gc node_modules/.bin/jest --config=jest.api.config.js --runInBand --detectOpenHandles --logHeapUsage", + "test:api:watch": "npm run test:api:all -- --watchAll", "typeorm": "ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js", "migration:generate": "npm run typeorm migration:generate -- -d ./appdatasource.ts", "migration:run": "npm run typeorm migration:run -- -d ./appdatasource.ts", diff --git a/services/API-service/src/scripts/geoserver-sync.service.ts b/services/API-service/src/scripts/geoserver-sync.service.ts index ae32d844e..156eaf4ae 100644 --- a/services/API-service/src/scripts/geoserver-sync.service.ts +++ b/services/API-service/src/scripts/geoserver-sync.service.ts @@ -51,6 +51,10 @@ export class GeoserverSyncService { const foundStoreNames = await this.getStoreNamesFromGeoserver( workspaceName, ); + console.log( + '🚀 ~ GeoserverSyncService ~ syncStores ~ foundStoreNames:', + foundStoreNames, + ); const missingStoreNames = expectedStoreNameObjects.filter( (o) => !foundStoreNames.includes(o.resourceName), ); diff --git a/services/API-service/src/scripts/mock.controller.ts b/services/API-service/src/scripts/mock.controller.ts index 090ec78ed..6664bf5f7 100644 --- a/services/API-service/src/scripts/mock.controller.ts +++ b/services/API-service/src/scripts/mock.controller.ts @@ -53,6 +53,11 @@ export class MockBaseScenario { @ApiProperty({ example: new Date() }) @IsOptional() public readonly date: Date; + + // TODO change this to query param + @ApiProperty({ example: false }) + @IsOptional() + public readonly isApiTest: boolean = false; } export class MockFloodsScenario extends MockBaseScenario { diff --git a/services/API-service/src/scripts/mock.service.ts b/services/API-service/src/scripts/mock.service.ts index 4626a6555..8bec4ff7f 100644 --- a/services/API-service/src/scripts/mock.service.ts +++ b/services/API-service/src/scripts/mock.service.ts @@ -234,7 +234,7 @@ export class MockService { // Add the needed stores and layers to geoserver, only do this in debug mode // The resulting XML files should be commited to git and will end up on the servers that way - if (DEBUG) { + if (DEBUG && !mockBody.isApiTest) { await this.geoServerSyncService.sync( selectedCountry.countryCodeISO3, disasterType, diff --git a/services/API-service/src/scripts/scripts.service.ts b/services/API-service/src/scripts/scripts.service.ts index 38c6547d8..ccbafae6a 100644 --- a/services/API-service/src/scripts/scripts.service.ts +++ b/services/API-service/src/scripts/scripts.service.ts @@ -80,6 +80,7 @@ export class ScriptsService { removeEvents: true, date: mockAllInput.date || new Date(), scenario: null, // This is overwritten by useDefaultScenario=true anyway + isApiTest: false, }, disasterType.disasterType, true, diff --git a/services/API-service/test/email/email-uga-floods.test.ts b/services/API-service/test/email/email-uga-floods.test.ts new file mode 100644 index 000000000..590766a70 --- /dev/null +++ b/services/API-service/test/email/email-uga-floods.test.ts @@ -0,0 +1,39 @@ +import { + getAccessToken, + mockFloods, + resetDB, + sendNotification, +} from '../helpers/utility.helper'; +import { FloodsScenario } from '../../src/scripts/enum/mock-scenario.enum'; +import { DisasterType } from '../../src/api/disaster/disaster-type.enum'; + +describe('Should send an email for uga floods', () => { + let accessToken: string; + const countryCodeISO3 = 'UGA'; + + beforeEach(async () => { + accessToken = await getAccessToken(); + await resetDB(accessToken); + }); + + it('send trigger email default floods', async () => { + // Arrange + const mockResult = await mockFloods( + FloodsScenario.Default, + countryCodeISO3, + accessToken, + ); + // Act + const response = await sendNotification( + countryCodeISO3, + DisasterType.Floods, + accessToken, + ); + + // Assert + // Also checking the status of the mockResult here as I think it also breaks often + expect(mockResult.status).toBe(202); + + expect(response.status).toBe(201); + }); +}); diff --git a/services/API-service/test/helpers/utility.helper.ts b/services/API-service/test/helpers/utility.helper.ts new file mode 100644 index 000000000..9e241f836 --- /dev/null +++ b/services/API-service/test/helpers/utility.helper.ts @@ -0,0 +1,73 @@ +import * as request from 'supertest'; +import TestAgent from 'supertest/lib/agent'; +import users from '../../src/scripts/json/users.json'; +import { FloodsScenario } from '../../src/scripts/enum/mock-scenario.enum'; +import { DisasterType } from '../../src/api/disaster/disaster-type.enum'; + +export async function getAccessToken(): Promise { + const admin = users.find((user) => user.userRole === 'admin'); + const login = await loginApi(admin.email, admin.password); + + const accessToken = login.body.user.token; + console.log('🚀 ~ getAccessToken ~ login.body:', login.body); + return accessToken; +} + +export function loginApi( + email: string, + password: string, +): Promise { + return getServer().post(`/user/login`).send({ + email, + password, + }); +} + +export function getHostname(): string { + return 'http://localhost:3000/api'; +} + +export function getServer(): TestAgent { + return request.agent(getHostname()); +} + +export function resetDB(accessToken: string): Promise { + console.log('🚀 ~ resetDB ~ accessToken:', accessToken); + return getServer() + .post('/scripts/reset') + .set('Authorization', `Bearer ${accessToken}`) + .send({ + secret: process.env.RESET_SECRET, + }); +} + +export function mockFloods( + scenario: FloodsScenario, + countryCodeISO3: string, + accessToken: string, +): Promise { + return getServer() + .post('/mock/floods') + .set('Authorization', `Bearer ${accessToken}`) + .send({ + scenario, + secret: process.env.RESET_SECRET, + removeEvents: true, + date: new Date(), + countryCodeISO3, + }); +} + +export function sendNotification( + countryCodeISO3: string, + disasterType: DisasterType, + accessToken: string, +): Promise { + return getServer() + .post('/notification/send') + .set('Authorization', `Bearer ${accessToken}`) + .send({ + countryCodeISO3, + disasterType, + }); +} diff --git a/services/API-service/test/tsconfig.json b/services/API-service/test/tsconfig.json new file mode 100644 index 000000000..c05167595 --- /dev/null +++ b/services/API-service/test/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "types": ["node", "jest", "supertest"] + }, + "exclude": ["../node_modules", "../dist", "../src/**/*"], + "include": ["**/*.ts"] +} From 74085422544bac620b86b2ca417abd49e4febd9b Mon Sep 17 00:00:00 2001 From: Ruben Date: Thu, 16 May 2024 14:01:54 +0200 Subject: [PATCH 14/21] AB#27263 Test all uganda flood scenarios --- services/API-service/README.md | 4 + .../1710512991479-rename-mock-rasters.ts | 2 - .../dto/notification-api-test-response.dto.ts | 9 ++ .../api/notification/email/email.service.ts | 13 ++- .../notification/email/html/body-event.html | 6 +- .../notification/notification.controller.ts | 23 ++++- .../api/notification/notification.service.ts | 51 ++++++++-- .../src/scripts/geoserver-sync.service.ts | 4 - .../src/scripts/mock.controller.ts | 37 ++++++-- .../API-service/src/scripts/mock.service.ts | 3 +- .../src/scripts/scripts.service.ts | 4 +- .../test/email/email-uga-floods.test.ts | 92 +++++++++++++++---- .../test/helpers/utility.helper.ts | 4 +- services/API-service/tsconfig.json | 4 +- 14 files changed, 209 insertions(+), 47 deletions(-) create mode 100644 services/API-service/src/api/notification/dto/notification-api-test-response.dto.ts diff --git a/services/API-service/README.md b/services/API-service/README.md index 2125ac05b..6a16d5f2d 100644 --- a/services/API-service/README.md +++ b/services/API-service/README.md @@ -177,3 +177,7 @@ For the rest, follow the same instructions as above to receive initial and follo - Endpoint URL: `https://ibf.510.global/api/point-data/community-notification/${countryCodeISO3}` - To test this locally you can replace `ibf.510.global` by a local ngrok address - To demo on other environments, replace by respective environment-url, e.g. `ibf-test.510.global` + +## API tests + +1. Run them by `docker exec ibf-api-service npm run test:api:all` diff --git a/services/API-service/migration/1710512991479-rename-mock-rasters.ts b/services/API-service/migration/1710512991479-rename-mock-rasters.ts index 4ce5f8a52..6237128d9 100644 --- a/services/API-service/migration/1710512991479-rename-mock-rasters.ts +++ b/services/API-service/migration/1710512991479-rename-mock-rasters.ts @@ -13,8 +13,6 @@ export class RenameMockRasters1710512991479 implements MigrationInterface { if (fs.existsSync(directoryPath)) { const files = fs.readdirSync(directoryPath); - console.log('🚀 ~ RenameMockRasters1710512991479 ~ up ~ files:', files); - files.forEach((file) => { if (!file.includes('hour_MWI')) { const newFilename = file.replace( diff --git a/services/API-service/src/api/notification/dto/notification-api-test-response.dto.ts b/services/API-service/src/api/notification/dto/notification-api-test-response.dto.ts new file mode 100644 index 000000000..773d8595f --- /dev/null +++ b/services/API-service/src/api/notification/dto/notification-api-test-response.dto.ts @@ -0,0 +1,9 @@ +export class NotificationApiTestResponseDto { + activeEvents: NotificationApiTestResponseChannelDto; + finishedEvents: NotificationApiTestResponseChannelDto; +} + +export class NotificationApiTestResponseChannelDto { + email: string; + whatsapp: string; +} diff --git a/services/API-service/src/api/notification/email/email.service.ts b/services/API-service/src/api/notification/email/email.service.ts index 2149f6773..3cad2dad4 100644 --- a/services/API-service/src/api/notification/email/email.service.ts +++ b/services/API-service/src/api/notification/email/email.service.ts @@ -42,8 +42,9 @@ export class EmailService { country: CountryEntity, disasterType: DisasterType, activeEvents: EventSummaryCountry[], + isApiTest: boolean, date?: Date, - ): Promise { + ): Promise { date = date ? new Date(date) : new Date(); const emailContent = @@ -56,6 +57,9 @@ export class EmailService { emailContent, date, ); + if (isApiTest) { + return emailHtml; + } const emailSubject = `IBF ${( await this.notificationContentService.getDisasterTypeLabel(disasterType) ).toLowerCase()} notification`; @@ -71,8 +75,9 @@ export class EmailService { country: CountryEntity, disasterType: DisasterType, finishedEvents: EventSummaryCountry[], + isApiTest: boolean, date?: Date, - ): Promise { + ): Promise { const disasterTypeLabel = await this.notificationContentService.getDisasterTypeLabel(disasterType); const emailHtml = @@ -83,6 +88,10 @@ export class EmailService { disasterTypeLabel, date ? new Date(date) : new Date(), ); + + if (isApiTest) { + return emailHtml; + } const emailSubject = `IBF ${disasterTypeLabel.toLowerCase()} trigger is now below threshold`; this.sendEmail( emailSubject, diff --git a/services/API-service/src/api/notification/email/html/body-event.html b/services/API-service/src/api/notification/email/html/body-event.html index df371dfaa..76f203826 100644 --- a/services/API-service/src/api/notification/email/html/body-event.html +++ b/services/API-service/src/api/notification/email/html/body-event.html @@ -1,5 +1,9 @@
- + triangleIcon <%= hazard %> <%= eventName %> diff --git a/services/API-service/src/api/notification/notification.controller.ts b/services/API-service/src/api/notification/notification.controller.ts index f894c9b45..e122e5b94 100644 --- a/services/API-service/src/api/notification/notification.controller.ts +++ b/services/API-service/src/api/notification/notification.controller.ts @@ -2,7 +2,9 @@ import { NotificationService } from './notification.service'; import { Body, Controller, + ParseBoolPipe, Post, + Query, UseGuards, UseInterceptors, } from '@nestjs/common'; @@ -10,6 +12,7 @@ import { ApiBearerAuth, ApiConsumes, ApiOperation, + ApiQuery, ApiResponse, ApiTags, } from '@nestjs/swagger'; @@ -17,6 +20,7 @@ import { RolesGuard } from '../../roles.guard'; import { SendNotificationDto } from './dto/send-notification.dto'; import { Roles } from '../../roles.decorator'; import { UserRole } from '../user/user-role.enum'; +import { NotificationApiTestResponseDto } from './dto/notification-api-test-response.dto'; @ApiBearerAuth() @UseGuards(RolesGuard) @@ -38,15 +42,30 @@ export class NotificationController { description: 'Notification request sent (actual e-mails/whatsapps sent only if there is an active event)', }) + @ApiQuery({ + name: 'isApiTest', + required: false, + type: 'boolean', + description: + 'If true, only returns the notification content without sending it', + }) @Post('send') @ApiConsumes() @UseInterceptors() public async send( @Body() sendNotification: SendNotificationDto, - ): Promise { - await this.notificationService.send( + @Query( + 'isApiTest', + new ParseBoolPipe({ + optional: true, + }), + ) + isApiTest: boolean, + ): Promise { + return await this.notificationService.send( sendNotification.countryCodeISO3, sendNotification.disasterType, + isApiTest, sendNotification.date, ); } diff --git a/services/API-service/src/api/notification/notification.service.ts b/services/API-service/src/api/notification/notification.service.ts index 5cdf65f6c..744d58610 100644 --- a/services/API-service/src/api/notification/notification.service.ts +++ b/services/API-service/src/api/notification/notification.service.ts @@ -7,6 +7,10 @@ import { EmailService } from './email/email.service'; import { TyphoonTrackService } from '../typhoon-track/typhoon-track.service'; import { EventSummaryCountry } from '../../shared/data.model'; import { LeadTime } from '../admin-area-dynamic-data/enum/lead-time.enum'; +import { + NotificationApiTestResponseChannelDto, + NotificationApiTestResponseDto, +} from './dto/notification-api-test-response.dto'; @Injectable() export class NotificationService { @@ -21,33 +25,50 @@ export class NotificationService { public async send( countryCodeISO3: string, disasterType: DisasterType, + isApiTest: boolean, date?: Date, - ): Promise { - await this.sendNotiFicationsActiveEvents( + ): Promise { + const apiTestResponse = new NotificationApiTestResponseDto(); + const apiTestReponseActive = await this.sendNotiFicationsActiveEvents( disasterType, countryCodeISO3, + isApiTest, date, ); + if (isApiTest && apiTestReponseActive) { + apiTestResponse.activeEvents = apiTestReponseActive; + } if (disasterType === DisasterType.Floods) { // Sending finished events is now for floods only - await this.sendNotificationsFinishedEvents( + const apiTestReponseFinished = await this.sendNotificationsFinishedEvents( countryCodeISO3, disasterType, + isApiTest, date, ); + if (isApiTest && apiTestReponseFinished) { + apiTestResponse.finishedEvents = apiTestReponseFinished; + } } // REFACTOR: First close finished events. This is ideally done through separate endpoint called at end of pipeline, but that would require all pipelines to be updated. // Instead, making use of this endpoint which is already called at the end of every pipeline await this.eventService.closeEventsAutomatic(countryCodeISO3, disasterType); + + if (isApiTest) { + return apiTestResponse; + } } private async sendNotiFicationsActiveEvents( disasterType: DisasterType, countryCodeISO3: string, + isApiTest: boolean, date?: Date, - ): Promise { + ): Promise { + const apiTestReponseActive = new NotificationApiTestResponseChannelDto(); + const events = await this.eventService.getEventSummary( countryCodeISO3, disasterType, @@ -66,12 +87,16 @@ export class NotificationService { await this.notificationContentService.getCountryNotificationInfo( countryCodeISO3, ); - await this.emailService.sendTriggerEmail( + const messageForApiTest = await this.emailService.sendTriggerEmail( country, disasterType, activeNotifiableEvents, + isApiTest, date, ); + if (isApiTest && messageForApiTest) { + apiTestReponseActive.email = messageForApiTest; + } if (country.notificationInfo.useWhatsapp[disasterType]) { this.whatsappService.sendTriggerWhatsapp( country, @@ -80,13 +105,18 @@ export class NotificationService { ); } } + if (isApiTest) { + return apiTestReponseActive; + } } private async sendNotificationsFinishedEvents( countryCodeISO3: string, disasterType: DisasterType, + isApiTest: boolean, date?: Date, - ): Promise { + ): Promise { + const apiTestReponseFinished = new NotificationApiTestResponseChannelDto(); const finishedNotifiableEvents = await this.eventService.getEventsSummaryTriggerFinishedMail( countryCodeISO3, @@ -99,12 +129,16 @@ export class NotificationService { countryCodeISO3, ); - await this.emailService.sendTriggerFinishedEmail( + const emailFinished = await this.emailService.sendTriggerFinishedEmail( country, disasterType, finishedNotifiableEvents, + isApiTest, date, ); + if (isApiTest && emailFinished) { + apiTestReponseFinished.email = emailFinished; + } if (country.notificationInfo.useWhatsapp[disasterType]) { // TODO: Send one whatsapp message for all closing events @@ -116,6 +150,9 @@ export class NotificationService { ); } } + if (isApiTest) { + return apiTestReponseFinished; + } } } diff --git a/services/API-service/src/scripts/geoserver-sync.service.ts b/services/API-service/src/scripts/geoserver-sync.service.ts index 156eaf4ae..ae32d844e 100644 --- a/services/API-service/src/scripts/geoserver-sync.service.ts +++ b/services/API-service/src/scripts/geoserver-sync.service.ts @@ -51,10 +51,6 @@ export class GeoserverSyncService { const foundStoreNames = await this.getStoreNamesFromGeoserver( workspaceName, ); - console.log( - '🚀 ~ GeoserverSyncService ~ syncStores ~ foundStoreNames:', - foundStoreNames, - ); const missingStoreNames = expectedStoreNameObjects.filter( (o) => !foundStoreNames.includes(o.resourceName), ); diff --git a/services/API-service/src/scripts/mock.controller.ts b/services/API-service/src/scripts/mock.controller.ts index 6664bf5f7..ae4699c61 100644 --- a/services/API-service/src/scripts/mock.controller.ts +++ b/services/API-service/src/scripts/mock.controller.ts @@ -5,6 +5,8 @@ import { Res, HttpStatus, UseGuards, + Query, + ParseBoolPipe, } from '@nestjs/common'; import { ApiBearerAuth, @@ -53,11 +55,6 @@ export class MockBaseScenario { @ApiProperty({ example: new Date() }) @IsOptional() public readonly date: Date; - - // TODO change this to query param - @ApiProperty({ example: false }) - @IsOptional() - public readonly isApiTest: boolean = false; } export class MockFloodsScenario extends MockBaseScenario { @@ -107,6 +104,13 @@ export class MockController { public async mockFloodsScenario( @Body() body: MockFloodsScenario, @Res() res, + @Query( + 'isApiTest', + new ParseBoolPipe({ + optional: true, + }), + ) + isApiTest: boolean, ): Promise { if (body.secret !== process.env.RESET_SECRET) { return res.status(HttpStatus.FORBIDDEN).send('Not allowed'); @@ -115,6 +119,7 @@ export class MockController { body, DisasterType.Floods, false, + isApiTest, ); return res.status(HttpStatus.ACCEPTED).send(result); @@ -132,6 +137,13 @@ export class MockController { public async mockFlashFloodsScenario( @Body() body: MockFlashFloodsScenario, @Res() res, + @Query( + 'isApiTest', + new ParseBoolPipe({ + optional: true, + }), + ) + isApiTest: boolean, ): Promise { if (body.secret !== process.env.RESET_SECRET) { return res.status(HttpStatus.FORBIDDEN).send('Not allowed'); @@ -140,6 +152,7 @@ export class MockController { body, DisasterType.FlashFloods, false, + isApiTest, ); return res.status(HttpStatus.ACCEPTED).send(result); @@ -157,6 +170,13 @@ export class MockController { public async mockEpidemicsScenario( @Body() body: MockEpidemicsScenario, @Res() res, + @Query( + 'isApiTest', + new ParseBoolPipe({ + optional: true, + }), + ) + isApiTest: boolean, ): Promise { if (body.secret !== process.env.RESET_SECRET) { return res.status(HttpStatus.FORBIDDEN).send('Not allowed'); @@ -166,7 +186,12 @@ export class MockController { body.countryCodeISO3 === 'PHL' ? DisasterType.Dengue : DisasterType.Malaria; - const result = await this.mockService.mock(body, disasterType, false); + const result = await this.mockService.mock( + body, + disasterType, + false, + isApiTest, + ); return res.status(HttpStatus.ACCEPTED).send(result); } diff --git a/services/API-service/src/scripts/mock.service.ts b/services/API-service/src/scripts/mock.service.ts index 8bec4ff7f..46f4ffaad 100644 --- a/services/API-service/src/scripts/mock.service.ts +++ b/services/API-service/src/scripts/mock.service.ts @@ -63,6 +63,7 @@ export class MockService { | MockFlashFloodsScenario, disasterType: DisasterType, useDefaultScenario: boolean, + isApiTest: boolean, ) { if (mockBody.removeEvents) { await this.removeEvents(mockBody.countryCodeISO3, disasterType); @@ -234,7 +235,7 @@ export class MockService { // Add the needed stores and layers to geoserver, only do this in debug mode // The resulting XML files should be commited to git and will end up on the servers that way - if (DEBUG && !mockBody.isApiTest) { + if (DEBUG && !isApiTest) { await this.geoServerSyncService.sync( selectedCountry.countryCodeISO3, disasterType, diff --git a/services/API-service/src/scripts/scripts.service.ts b/services/API-service/src/scripts/scripts.service.ts index ccbafae6a..e5a266d6d 100644 --- a/services/API-service/src/scripts/scripts.service.ts +++ b/services/API-service/src/scripts/scripts.service.ts @@ -56,6 +56,8 @@ export class ScriptsService { ) {} public async mockAll(mockAllInput: MockAll) { + const isApiTest = false + const envCountries = process.env.COUNTRIES.split(','); const newMockServiceDisasterTypes = [ @@ -80,10 +82,10 @@ export class ScriptsService { removeEvents: true, date: mockAllInput.date || new Date(), scenario: null, // This is overwritten by useDefaultScenario=true anyway - isApiTest: false, }, disasterType.disasterType, true, + isApiTest, ); } else { await this.mockCountry({ diff --git a/services/API-service/test/email/email-uga-floods.test.ts b/services/API-service/test/email/email-uga-floods.test.ts index 590766a70..f7bd6732e 100644 --- a/services/API-service/test/email/email-uga-floods.test.ts +++ b/services/API-service/test/email/email-uga-floods.test.ts @@ -6,34 +6,92 @@ import { } from '../helpers/utility.helper'; import { FloodsScenario } from '../../src/scripts/enum/mock-scenario.enum'; import { DisasterType } from '../../src/api/disaster/disaster-type.enum'; +import { JSDOM } from 'jsdom'; +import scenarios from '../../src/scripts/mock-data/floods/uga/scenarios.json'; +import disaters from '../../src/scripts/json/disasters.json'; +const disasterType = DisasterType.Floods; +const countryCodeISO3 = 'UGA'; describe('Should send an email for uga floods', () => { let accessToken: string; - const countryCodeISO3 = 'UGA'; beforeEach(async () => { accessToken = await getAccessToken(); await resetDB(accessToken); }); - it('send trigger email default floods', async () => { + it('default', async () => { // Arrange - const mockResult = await mockFloods( - FloodsScenario.Default, - countryCodeISO3, - accessToken, - ); - // Act - const response = await sendNotification( - countryCodeISO3, - DisasterType.Floods, - accessToken, - ); + const scenario = FloodsScenario.Default; + await testFloodScenario(scenario, accessToken); + }); + + it('warning', async () => { + // Arrange + const scenario = FloodsScenario.Warning; + await testFloodScenario(scenario, accessToken); + }); - // Assert - // Also checking the status of the mockResult here as I think it also breaks often - expect(mockResult.status).toBe(202); + it('warning-to-trigger', async () => { + // Arrange + const scenario = FloodsScenario.WarningToTrigger; + await testFloodScenario(scenario, accessToken); + }); - expect(response.status).toBe(201); + it('no-trigger', async () => { + // Arrange + const scenario = FloodsScenario.NoTrigger; + await testFloodScenario(scenario, accessToken); }); }); + +async function testFloodScenario( + scenario: FloodsScenario, + accessToken: string, +): Promise { + const disasterTypeLabel = disaters.find( + (d) => d.disasterType === disasterType, + ).label; + const scenarioSeed = scenarios.find((s) => s.scenarioName === scenario); + const mockResult = await mockFloods(scenario, countryCodeISO3, accessToken); + const eventsSeed = scenarioSeed.events ? scenarioSeed.events : []; + // Act + const response = await sendNotification( + countryCodeISO3, + DisasterType.Floods, + accessToken, + ); + + // Assert + // Also checking the status of the mockResult here as I think it also breaks often + expect(mockResult.status).toBe(202); + expect(response.status).toBe(201); + if (eventsSeed.length > 0) { + expect(response.body.activeEvents.email).toBeDefined(); + } else { + expect(response.body.activeEvents.email).toBeFalsy(); + } + expect(response.body.activeEvents.whatsapp).toBeFalsy(); + expect(response.body.finishedEvents).toBeFalsy(); + + // Parse the HTML content + const dom = new JSDOM(response.body.activeEvents.email); + const document = dom.window.document; + + // Get all span elements with apiTest="eventName" and their lower case text content + const eventNamesInEmail = Array.from( + document.querySelectorAll('span[apiTest="eventName"]'), + (el) => (el as Element).textContent.toLowerCase(), + ); + + expect(eventNamesInEmail.length).toBe(eventsSeed.length); + + // Check if there are elements with the desired text content + for (const event of eventsSeed) { + const eventTitle = `${disasterTypeLabel} ${event.eventName}`.toLowerCase(); + const hasEvent = eventNamesInEmail.some((eventName) => + eventName.includes(eventTitle), + ); + expect(hasEvent).toBe(true); + } +} diff --git a/services/API-service/test/helpers/utility.helper.ts b/services/API-service/test/helpers/utility.helper.ts index 9e241f836..a8e02db84 100644 --- a/services/API-service/test/helpers/utility.helper.ts +++ b/services/API-service/test/helpers/utility.helper.ts @@ -9,7 +9,6 @@ export async function getAccessToken(): Promise { const login = await loginApi(admin.email, admin.password); const accessToken = login.body.user.token; - console.log('🚀 ~ getAccessToken ~ login.body:', login.body); return accessToken; } @@ -32,7 +31,6 @@ export function getServer(): TestAgent { } export function resetDB(accessToken: string): Promise { - console.log('🚀 ~ resetDB ~ accessToken:', accessToken); return getServer() .post('/scripts/reset') .set('Authorization', `Bearer ${accessToken}`) @@ -49,6 +47,7 @@ export function mockFloods( return getServer() .post('/mock/floods') .set('Authorization', `Bearer ${accessToken}`) + .query({ isApiTest: true }) .send({ scenario, secret: process.env.RESET_SECRET, @@ -66,6 +65,7 @@ export function sendNotification( return getServer() .post('/notification/send') .set('Authorization', `Bearer ${accessToken}`) + .query({ isApiTest: true }) .send({ countryCodeISO3, disasterType, diff --git a/services/API-service/tsconfig.json b/services/API-service/tsconfig.json index 91fd4879a..d8166b82c 100644 --- a/services/API-service/tsconfig.json +++ b/services/API-service/tsconfig.json @@ -5,7 +5,7 @@ "noImplicitAny": false, "removeComments": true, "noLib": false, - "lib": ["es2017"], + "lib": ["es2017", "dom"], "emitDecoratorMetadata": true, "experimentalDecorators": true, "target": "es6", @@ -18,7 +18,7 @@ "baseUrl": "./", "skipLibCheck": true }, - "watchOptions": { + "watchOptions": { "watchFile": "fixedPollingInterval", "excludeDirectories": ["node_modules", "dist"], "excludeFiles": ["**/*.json"] From 8b1e0a60c1093e3e917c4c681232d79cfadce247 Mon Sep 17 00:00:00 2001 From: Ruben Date: Thu, 23 May 2024 09:43:08 +0200 Subject: [PATCH 15/21] Api test SSD Floods and PHL typhoon AB#27263 --- .../email/email-template.service.ts | 112 +++++++++++++----- .../html/body-total-affected-warning.html | 2 +- .../notification/email/html/table-event.html | 2 +- .../notification-content.service.ts | 9 +- .../src/scripts/enum/mock-scenario.enum.ts | 9 ++ .../src/scripts/scripts.controller.ts | 10 +- .../src/scripts/scripts.service.ts | 4 +- .../email/floods/email-ssd-floods.test.ts | 36 ++++++ .../email/floods/email-uga-floods.test.ts | 56 +++++++++ .../test-flood-scenario.helper.ts} | 61 +++------- .../email/typhoon/email-phl-typhoon.test.ts | 21 ++++ .../typhoon/test-typhoon-scenario.helper.ts | 53 +++++++++ .../test/helpers/utility.helper.ts | 24 +++- 13 files changed, 304 insertions(+), 95 deletions(-) create mode 100644 services/API-service/test/email/floods/email-ssd-floods.test.ts create mode 100644 services/API-service/test/email/floods/email-uga-floods.test.ts rename services/API-service/test/email/{email-uga-floods.test.ts => floods/test-flood-scenario.helper.ts} (54%) create mode 100644 services/API-service/test/email/typhoon/email-phl-typhoon.test.ts create mode 100644 services/API-service/test/email/typhoon/test-typhoon-scenario.helper.ts diff --git a/services/API-service/src/api/notification/email/email-template.service.ts b/services/API-service/src/api/notification/email/email-template.service.ts index f72a146c1..a0f151f44 100644 --- a/services/API-service/src/api/notification/email/email-template.service.ts +++ b/services/API-service/src/api/notification/email/email-template.service.ts @@ -20,11 +20,6 @@ const emailTemplateFolder = `${emailFolder}/html`; const emailIconFolder = `${emailFolder}/icons`; const emailLogoFolder = `${emailFolder}/logos`; -class ReplaceKeyValue { - replaceKey: string; - replaceValue: string; -} - @Injectable() export class EmailTemplateService { public async createHtmlForTriggerEmail( @@ -275,9 +270,15 @@ export class EmailTemplateService { emailContent.defaultAdminAreaLabel.plural.toLocaleLowerCase(), defaultAdminAreaLabelParent: adminAreaLabelsParent.singular, indicatorLabel: emailContent.indicatorMetadata.label, - triangleIcon: this.getTriangleIcon(event.eapAlertClass?.key), + triangleIcon: this.getTriangleIcon( + event.eapAlertClass?.key, + event.triggerStatusLabel, + ), tableRows: this.getTablesRows(event), - color: this.ibfColorToHex(event.eapAlertClass?.color), + color: this.getIbfHexColor( + event.eapAlertClass?.color, + event.triggerStatusLabel, + ), severityLabel: this.getEventSeverityLabel(event.eapAlertClass?.key), }; @@ -342,10 +343,19 @@ export class EmailTemplateService { indicatorUnit: emailContent.indicatorMetadata.unit, timezone: CountryTimeZoneMapping[emailContent.country.countryCodeISO3], - triangleIcon: this.getTriangleIcon(event.eapAlertClass?.key), + triangleIcon: this.getTriangleIcon( + event.eapAlertClass?.key, + event.triggerStatusLabel, + ), leadTime: event.firstLeadTime.replace('-', ' '), - disasterIssuedLabel: event.eapAlertClass.label, - color: this.ibfColorToHex(event.eapAlertClass?.color), + disasterIssuedLabel: this.getDisasterIssuedLabel( + event.eapAlertClass?.label, + event.triggerStatusLabel, + ), + color: this.getIbfHexColor( + event.eapAlertClass?.color, + event.triggerStatusLabel, + ), advisory: this.getAdvisoryHtml( event.triggerStatusLabel, emailContent.country, @@ -364,6 +374,17 @@ export class EmailTemplateService { .join(''); } + private getDisasterIssuedLabel( + eapLabel: string, + triggerStatusLabel: TriggerStatusLabelEnum, + ) { + if (eapLabel) { + return eapLabel; + } else { + return triggerStatusLabel; + } + } + private getAdvisoryHtml( triggerStatusLabel: TriggerStatusLabelEnum, country: CountryEntity, @@ -383,15 +404,18 @@ export class EmailTemplateService { event: NotificationDataPerEventDto, indicatorUnit: string, ): string { + let html = ''; if (event.triggerStatusLabel === TriggerStatusLabelEnum.Warning) { - return this.readHtmlFile('body-total-affected-warning.html'); + html = this.readHtmlFile('body-total-affected-warning.html'); } else { - let html = this.readHtmlFile('body-total-affected-trigger.html'); - return ejs.render(html, { - totalAffectectedOfIndicator: event.totalAffectectedOfIndicator, - indicatorUnit: indicatorUnit, - }); + + html = this.readHtmlFile('body-total-affected-trigger.html'); } + return ejs.render(html, { + totalAffectectedOfIndicator: event.totalAffectectedOfIndicator, + indicatorUnit: indicatorUnit, + }); + } private getFooterHtml(): string { @@ -400,16 +424,32 @@ export class EmailTemplateService { return ejs.render(footerHtml, { ibfLogo: ibfLogo }); } - private ibfColorToHex(color: string): string { - // TODO: Define in a place where FrontEnd and Backend can share this - switch (color) { - case 'ibf-orange': - return '#aa6009'; - case 'ibf-yellow': - return '#7d6906'; - default: - return '#8a0f32'; + private getIbfHexColor( + color: string, + triggerStatusLabel: TriggerStatusLabelEnum, + ): string { + const ibfOrange = '#aa6009'; + const ibfYellow = '#7d6906'; + const ibfRed = '#8a0f32'; + + // Color defined in the EAP Alert Class. This is only used for flood events + // For other events, the color is defined in the disaster settings + // So we decide it based on the trigger status label + + if (color) { + // TODO: Define in a place where FrontEnd and Backend can share this + switch (color) { + case 'ibf-orange': + return ibfOrange; + case 'ibf-yellow': + return ibfYellow; + default: + return ibfRed; + } } + return triggerStatusLabel === TriggerStatusLabelEnum.Trigger + ? ibfRed + : ibfOrange; } private getCurrentDateTimeString(countryCodeISO3: string): string { @@ -434,15 +474,25 @@ export class EmailTemplateService { return date.toLocaleString('default', options); } - private getTriangleIcon(eapAlertClassKey: EapAlertClassKeyEnum) { + private getTriangleIcon( + eapAlertClassKey: EapAlertClassKeyEnum, + triggerStatusLabel: TriggerStatusLabelEnum, + ) { let fileName = ''; // Still need implement the difference between medium and low warning - if (eapAlertClassKey === EapAlertClassKeyEnum.med) { - fileName = 'warning-medium.png'; - } else if (eapAlertClassKey === EapAlertClassKeyEnum.min) { - fileName = 'warning-low.png'; + if (eapAlertClassKey) { + if (eapAlertClassKey === EapAlertClassKeyEnum.med) { + fileName = 'warning-medium.png'; + } else if (eapAlertClassKey === EapAlertClassKeyEnum.min) { + fileName = 'warning-low.png'; + } else { + fileName = 'trigger.png'; + } } else { - fileName = 'trigger.png'; + fileName = + triggerStatusLabel === TriggerStatusLabelEnum.Trigger + ? 'trigger.png' + : 'warning-medium.png'; } const filePath = `${emailIconFolder}/${fileName}`; const imageDataURL = this.getPngImageAsDataURL(filePath); diff --git a/services/API-service/src/api/notification/email/html/body-total-affected-warning.html b/services/API-service/src/api/notification/email/html/body-total-affected-warning.html index 7ab140eb2..6572e5a8e 100644 --- a/services/API-service/src/api/notification/email/html/body-total-affected-warning.html +++ b/services/API-service/src/api/notification/email/html/body-total-affected-warning.html @@ -1 +1 @@ -Information regarding exposed population not available for warnings
+Information regarding <%= indicatorUnit %> not available for warnings
diff --git a/services/API-service/src/api/notification/email/html/table-event.html b/services/API-service/src/api/notification/email/html/table-event.html index 6072f2057..083b23c3a 100644 --- a/services/API-service/src/api/notification/email/html/table-event.html +++ b/services/API-service/src/api/notification/email/html/table-event.html @@ -45,7 +45,7 @@
- Please note: Information regarding exposed population not available + Please note: Information regarding <%= indicatorLabel %> not available for medium warning level.
<% } %> diff --git a/services/API-service/src/api/notification/notification-content/notification-content.service.ts b/services/API-service/src/api/notification/notification-content/notification-content.service.ts index 59c2d335f..8d3fdd45f 100644 --- a/services/API-service/src/api/notification/notification-content/notification-content.service.ts +++ b/services/API-service/src/api/notification/notification-content/notification-content.service.ts @@ -1,4 +1,3 @@ -import { AdminAreaDynamicDataService } from '../../admin-area-dynamic-data/admin-area-dynamic-data.service'; import { CountryEntity } from '../../country/country.entity'; import { Injectable } from '@nestjs/common'; import { EventService } from '../../event/event.service'; @@ -246,8 +245,12 @@ export class NotificationContentService { }); } - private getTotalAffectedPerEvent(adminAreas: TriggeredArea[]) { - return adminAreas.reduce((acc, cur) => acc + cur.actionsValue, 0); + private getTotalAffectedPerEvent(adminAreas: TriggeredArea[]): number { + const total = adminAreas.reduce((acc, cur) => acc + cur.actionsValue, 0); + // Round to 2 decimals + if (total) { + return Math.floor(total * 100) / 100; + } } private async getFirstLeadTimeDate( diff --git a/services/API-service/src/scripts/enum/mock-scenario.enum.ts b/services/API-service/src/scripts/enum/mock-scenario.enum.ts index f849dec98..db494e37e 100644 --- a/services/API-service/src/scripts/enum/mock-scenario.enum.ts +++ b/services/API-service/src/scripts/enum/mock-scenario.enum.ts @@ -14,3 +14,12 @@ export enum EpidemicsScenario { Default = 'default', NoTrigger = 'no-trigger', } + +export enum TyphoonScenario { + NoEvent = 'noEvent', + EventNoLandfall = 'eventNoLandfall', + EventNoLandfallYet = 'eventNoLandfallYet', + EventNoTrigger = 'eventNoTrigger', + EventTrigger = 'eventTrigger', + EventAfterLandfall = 'eventAfterLandfall', +} diff --git a/services/API-service/src/scripts/scripts.controller.ts b/services/API-service/src/scripts/scripts.controller.ts index d5b6f694c..b8a6dda0f 100644 --- a/services/API-service/src/scripts/scripts.controller.ts +++ b/services/API-service/src/scripts/scripts.controller.ts @@ -26,6 +26,7 @@ import { RolesGuard } from '../roles.guard'; import { DisasterType } from '../api/disaster/disaster-type.enum'; import { Roles } from '../roles.decorator'; import { UserRole } from '../api/user/user-role.enum'; +import { TyphoonScenario } from './enum/mock-scenario.enum'; class ResetDto { @ApiProperty({ example: 'fill_in_secret' }) @@ -82,15 +83,6 @@ export class MockAll { public readonly date: Date; } -export enum TyphoonScenario { - NoEvent = 'noEvent', - EventNoLandfall = 'eventNoLandfall', - EventNoLandfallYet = 'eventNoLandfallYet', - EventNoTrigger = 'eventNoTrigger', - EventTrigger = 'eventTrigger', - EventAfterLandfall = 'eventAfterLandfall', -} - export class MockTyphoonScenario { @ApiProperty({ example: 'fill_in_secret' }) @IsNotEmpty() diff --git a/services/API-service/src/scripts/scripts.service.ts b/services/API-service/src/scripts/scripts.service.ts index e5a266d6d..c76e9893d 100644 --- a/services/API-service/src/scripts/scripts.service.ts +++ b/services/API-service/src/scripts/scripts.service.ts @@ -6,7 +6,6 @@ import { MockAll, MockDynamic, MockTyphoonScenario, - TyphoonScenario, } from './scripts.controller'; import countries from './json/countries.json'; import fs from 'fs'; @@ -26,6 +25,7 @@ import { AdminAreaDynamicDataEntity } from '../api/admin-area-dynamic-data/admin import { AdminAreaEntity } from '../api/admin-area/admin-area.entity'; import { MockHelperService } from './mock-helper.service'; import { MockService } from './mock.service'; +import { TyphoonScenario } from './enum/mock-scenario.enum'; @Injectable() export class ScriptsService { @@ -56,7 +56,7 @@ export class ScriptsService { ) {} public async mockAll(mockAllInput: MockAll) { - const isApiTest = false + const isApiTest = false; const envCountries = process.env.COUNTRIES.split(','); diff --git a/services/API-service/test/email/floods/email-ssd-floods.test.ts b/services/API-service/test/email/floods/email-ssd-floods.test.ts new file mode 100644 index 000000000..531d61f9a --- /dev/null +++ b/services/API-service/test/email/floods/email-ssd-floods.test.ts @@ -0,0 +1,36 @@ +import { getAccessToken, resetDB } from '../../helpers/utility.helper'; +import { FloodsScenario } from '../../../src/scripts/enum/mock-scenario.enum'; + +import scenarios from '../../../src/scripts/mock-data/floods/ssd/scenarios.json'; + +import { testFloodScenario } from './test-flood-scenario.helper'; + +const countryCodeISO3 = 'SSD'; +describe('Should send an email for ssd floods', () => { + let accessToken: string; + + beforeEach(async () => { + accessToken = await getAccessToken(); + await resetDB(accessToken); + }); + + it('default', async () => { + // Arrange + const scenario = FloodsScenario.Default; + await testFloodScenario(scenario, { + scenarios, + countryCodeISO3, + accessToken, + }); + }); + + it('no-trigger', async () => { + // Arrange + const scenario = FloodsScenario.NoTrigger; + await testFloodScenario(scenario, { + scenarios, + countryCodeISO3, + accessToken, + }); + }); +}); diff --git a/services/API-service/test/email/floods/email-uga-floods.test.ts b/services/API-service/test/email/floods/email-uga-floods.test.ts new file mode 100644 index 000000000..2b1788a8a --- /dev/null +++ b/services/API-service/test/email/floods/email-uga-floods.test.ts @@ -0,0 +1,56 @@ +import { getAccessToken, resetDB } from '../../helpers/utility.helper'; +import { FloodsScenario } from '../../../src/scripts/enum/mock-scenario.enum'; + +import scenarios from '../../../src/scripts/mock-data/floods/uga/scenarios.json'; + +import { testFloodScenario } from './test-flood-scenario.helper'; + +const countryCodeISO3 = 'UGA'; +describe('Should send an email for uga floods', () => { + let accessToken: string; + + beforeEach(async () => { + accessToken = await getAccessToken(); + await resetDB(accessToken); + }); + + it('default', async () => { + // Arrange + const scenario = FloodsScenario.Default; + await testFloodScenario(scenario, { + scenarios, + countryCodeISO3, + accessToken, + }); + }); + + it('warning', async () => { + // Arrange + const scenario = FloodsScenario.Warning; + await testFloodScenario(scenario, { + scenarios, + countryCodeISO3, + accessToken, + }); + }); + + it('warning-to-trigger', async () => { + // Arrange + const scenario = FloodsScenario.WarningToTrigger; + await testFloodScenario(scenario, { + scenarios, + countryCodeISO3, + accessToken, + }); + }); + + it('no-trigger', async () => { + // Arrange + const scenario = FloodsScenario.NoTrigger; + await testFloodScenario(scenario, { + scenarios, + countryCodeISO3, + accessToken, + }); + }); +}); diff --git a/services/API-service/test/email/email-uga-floods.test.ts b/services/API-service/test/email/floods/test-flood-scenario.helper.ts similarity index 54% rename from services/API-service/test/email/email-uga-floods.test.ts rename to services/API-service/test/email/floods/test-flood-scenario.helper.ts index f7bd6732e..91d7746d7 100644 --- a/services/API-service/test/email/email-uga-floods.test.ts +++ b/services/API-service/test/email/floods/test-flood-scenario.helper.ts @@ -1,55 +1,22 @@ -import { - getAccessToken, - mockFloods, - resetDB, - sendNotification, -} from '../helpers/utility.helper'; -import { FloodsScenario } from '../../src/scripts/enum/mock-scenario.enum'; -import { DisasterType } from '../../src/api/disaster/disaster-type.enum'; +import { DisasterType } from '../../../src/api/disaster/disaster-type.enum'; +import { FloodsScenario } from '../../../src/scripts/enum/mock-scenario.enum'; +import { mockFloods, sendNotification } from '../../helpers/utility.helper'; +import disasters from '../../../src/scripts/json/disasters.json'; import { JSDOM } from 'jsdom'; -import scenarios from '../../src/scripts/mock-data/floods/uga/scenarios.json'; -import disaters from '../../src/scripts/json/disasters.json'; -const disasterType = DisasterType.Floods; -const countryCodeISO3 = 'UGA'; -describe('Should send an email for uga floods', () => { - let accessToken: string; - - beforeEach(async () => { - accessToken = await getAccessToken(); - await resetDB(accessToken); - }); - - it('default', async () => { - // Arrange - const scenario = FloodsScenario.Default; - await testFloodScenario(scenario, accessToken); - }); - - it('warning', async () => { - // Arrange - const scenario = FloodsScenario.Warning; - await testFloodScenario(scenario, accessToken); - }); - - it('warning-to-trigger', async () => { - // Arrange - const scenario = FloodsScenario.WarningToTrigger; - await testFloodScenario(scenario, accessToken); - }); - - it('no-trigger', async () => { - // Arrange - const scenario = FloodsScenario.NoTrigger; - await testFloodScenario(scenario, accessToken); - }); -}); +export interface TestFloodScenarioDto { + scenarios: any[]; + countryCodeISO3: string; + accessToken: string; +} -async function testFloodScenario( +export async function testFloodScenario( scenario: FloodsScenario, - accessToken: string, + params: TestFloodScenarioDto, ): Promise { - const disasterTypeLabel = disaters.find( + const { scenarios, countryCodeISO3, accessToken } = params; + const disasterType = DisasterType.Floods; + const disasterTypeLabel = disasters.find( (d) => d.disasterType === disasterType, ).label; const scenarioSeed = scenarios.find((s) => s.scenarioName === scenario); diff --git a/services/API-service/test/email/typhoon/email-phl-typhoon.test.ts b/services/API-service/test/email/typhoon/email-phl-typhoon.test.ts new file mode 100644 index 000000000..b43595c5e --- /dev/null +++ b/services/API-service/test/email/typhoon/email-phl-typhoon.test.ts @@ -0,0 +1,21 @@ +import { TyphoonScenario } from '../../../src/scripts/enum/mock-scenario.enum'; +import { getAccessToken, resetDB } from '../../helpers/utility.helper'; +import { testTyphoonScenario } from './test-typhoon-scenario.helper'; + +const countryCodeISO3 = 'PHL'; +describe('Should send an email for phl typhoon', () => { + let accessToken: string; + + beforeEach(async () => { + accessToken = await getAccessToken(); + await resetDB(accessToken); + }); + + it('default', async () => { + await testTyphoonScenario( + TyphoonScenario.EventTrigger, + countryCodeISO3, + accessToken, + ); + }); +}); diff --git a/services/API-service/test/email/typhoon/test-typhoon-scenario.helper.ts b/services/API-service/test/email/typhoon/test-typhoon-scenario.helper.ts new file mode 100644 index 000000000..38fbb23d4 --- /dev/null +++ b/services/API-service/test/email/typhoon/test-typhoon-scenario.helper.ts @@ -0,0 +1,53 @@ +import { DisasterType } from '../../../src/api/disaster/disaster-type.enum'; +import { TyphoonScenario } from '../../../src/scripts/enum/mock-scenario.enum'; +import { mockTyphoon, sendNotification } from '../../helpers/utility.helper'; +import { JSDOM } from 'jsdom'; + +export async function testTyphoonScenario( + scenario: TyphoonScenario, + countryCodeISO3: string, + accessToken: string, +): Promise { + const nrOfEvents = 2; + const eventName = 'Mock typhoon' + const disasterTypeLabel = DisasterType.Typhoon + + // const disasterType = DisasterType.Typhoon; + // const disasterTypeLabel = disasters.find( + // (d) => d.disasterType === disasterType, + // ).label; + const mockResult = await mockTyphoon(scenario, countryCodeISO3, accessToken); + // Act + const response = await sendNotification( + countryCodeISO3, + DisasterType.Typhoon, + accessToken, + ); + // Assert + // Also checking the status of the mockResult here as I think it also breaks often + expect(mockResult.status).toBe(202); + expect(response.status).toBe(201); + expect(response.body.activeEvents.email).toBeDefined(); + + expect(response.body.activeEvents.whatsapp).toBeFalsy(); + expect(response.body.finishedEvents).toBeFalsy(); + + // Parse the HTML content + const dom = new JSDOM(response.body.activeEvents.email); + const document = dom.window.document; + + // Get all span elements with apiTest="eventName" and their lower case text content + const eventNamesInEmail = Array.from( + document.querySelectorAll('span[apiTest="eventName"]'), + (el) => (el as Element).textContent.toLowerCase(), + ); + + expect(eventNamesInEmail.length).toBe(nrOfEvents); + + // Check if there are elements with the desired text content + for (const eventNameInEmail of eventNamesInEmail) { + const eventTitle = `${disasterTypeLabel} ${eventName}`.toLowerCase(); + const hasEvent = eventNameInEmail.includes(eventTitle); + expect(hasEvent).toBe(true); + } +} diff --git a/services/API-service/test/helpers/utility.helper.ts b/services/API-service/test/helpers/utility.helper.ts index a8e02db84..7632558e4 100644 --- a/services/API-service/test/helpers/utility.helper.ts +++ b/services/API-service/test/helpers/utility.helper.ts @@ -1,7 +1,10 @@ import * as request from 'supertest'; import TestAgent from 'supertest/lib/agent'; import users from '../../src/scripts/json/users.json'; -import { FloodsScenario } from '../../src/scripts/enum/mock-scenario.enum'; +import { + FloodsScenario, + TyphoonScenario, +} from '../../src/scripts/enum/mock-scenario.enum'; import { DisasterType } from '../../src/api/disaster/disaster-type.enum'; export async function getAccessToken(): Promise { @@ -57,6 +60,25 @@ export function mockFloods( }); } +export function mockTyphoon( + scenario: TyphoonScenario, + countryCodeISO3: string, + accessToken: string, +): Promise { + return getServer() + .post('/scripts/mock-typhoon-scenario') + .set('Authorization', `Bearer ${accessToken}`) + .query({ isApiTest: true }) + .send({ + scenario, + eventNr: 1, + secret: process.env.RESET_SECRET, + removeEvents: true, + date: new Date(), + countryCodeISO3, + }); +} + export function sendNotification( countryCodeISO3: string, disasterType: DisasterType, From a2eb16b450154f8af8feb1aca8791b873e63f72e Mon Sep 17 00:00:00 2001 From: Ruben Date: Fri, 24 May 2024 10:51:58 +0200 Subject: [PATCH 16/21] AB#27286 warning date trigger event --- .../dto/notification-date-per-event.dto.ts | 21 +++++++- .../email/email-template.service.ts | 34 ++++++++----- .../notification/email/html/body-event.html | 17 ++++++- .../notification-content.service.ts | 49 +++++++++++++++++-- 4 files changed, 103 insertions(+), 18 deletions(-) diff --git a/services/API-service/src/api/notification/dto/notification-date-per-event.dto.ts b/services/API-service/src/api/notification/dto/notification-date-per-event.dto.ts index 8389691b0..373beeabc 100644 --- a/services/API-service/src/api/notification/dto/notification-date-per-event.dto.ts +++ b/services/API-service/src/api/notification/dto/notification-date-per-event.dto.ts @@ -5,10 +5,29 @@ export class NotificationDataPerEventDto { triggerStatusLabel: TriggerStatusLabelEnum; eventName: string; disasterSpecificCopy: DisasterSpecificCopy; + + /** + * The day that the event starts. + */ firstLeadTime: LeadTime; + + /** + * The day that the event triggers. This could be different from firstLeadTimeString. + * For example, a flood could transition from a warning (a chance of a small flood) + * to an EAP trigger (a larger chance of a bigger flood). + */ + firstTriggerLeadTime: LeadTime; + + firstLeadTimeString: string; + firstTriggerLeadTimeString: string; + triggeredAreas: TriggeredArea[]; + + /** + * The number of areas where the event triggers. + */ nrOfTriggeredAreas: number; - startDateDisasterString: string; + totalAffectedOfIndicator: number; mapImage?: Buffer; issuedDate: Date; diff --git a/services/API-service/src/api/notification/email/email-template.service.ts b/services/API-service/src/api/notification/email/email-template.service.ts index 5b676aaec..c58a3a66e 100644 --- a/services/API-service/src/api/notification/email/email-template.service.ts +++ b/services/API-service/src/api/notification/email/email-template.service.ts @@ -323,38 +323,50 @@ export class EmailTemplateService { return emailContent.dataPerEvent .map((event) => { const data = { + // Event details + eventName: event.eventName, hazard: emailContent.disasterTypeLabel, triggerStatusLabel: event.triggerStatusLabel, - eventName: event.eventName, - nrOfTriggeredAreas: event.nrOfTriggeredAreas, - expectedTriggerDate: event.firstLeadTime, issuedDate: this.dateObjectToDateTimeString( event.issuedDate, emailContent.country.countryCodeISO3, ), - startDateEventString: event.startDateDisasterString, + timezone: + CountryTimeZoneMapping[emailContent.country.countryCodeISO3], + + // Lead time details + firstLeadTimeString: event.firstLeadTimeString, + firstTriggerLeadTimeString: event.firstTriggerLeadTimeString, + firstLeadTimeQuantity: event.firstLeadTime.replace('-', ' '), + firstTriggerLeadTimeQuantity: event.firstTriggerLeadTime + ? event.firstTriggerLeadTime.replace('-', ' ') + : '', + + // Area details + nrOfTriggeredAreas: event.nrOfTriggeredAreas, defaultAdminAreaLabel: emailContent.defaultAdminAreaLabel.plural.toLocaleLowerCase(), + + // Indicator details indicatorLabel: emailContent.indicatorMetadata.label, totalAffectedOfIndicator: formatActionUnitValue( event.totalAffectedOfIndicator, emailContent.indicatorMetadata.numberFormatMap, ), indicatorUnit: emailContent.indicatorMetadata.unit, - timezone: - CountryTimeZoneMapping[emailContent.country.countryCodeISO3], + totalAffected: this.getTotalAffectedHtml( + event, + emailContent.indicatorMetadata.label.toLowerCase(), + ), + + // EAP details triangleIcon: this.getTriangleIcon(event.eapAlertClass?.key), - leadTime: event.firstLeadTime.replace('-', ' '), disasterIssuedLabel: event.eapAlertClass.label, color: this.ibfColorToHex(event.eapAlertClass?.color), advisory: this.getAdvisoryHtml( event.triggerStatusLabel, emailContent.linkEapSop, ), - totalAffected: this.getTotalAffectedHtml( - event, - emailContent.indicatorMetadata.label.toLowerCase(), - ), }; const templateFileName = 'body-event.html'; diff --git a/services/API-service/src/api/notification/email/html/body-event.html b/services/API-service/src/api/notification/email/html/body-event.html index fc7439767..8df8f290c 100644 --- a/services/API-service/src/api/notification/email/html/body-event.html +++ b/services/API-service/src/api/notification/email/html/body-event.html @@ -4,11 +4,26 @@ <%= hazard %> <%= eventName %>
+ <% if (firstTriggerLeadTimeString) { %> + + <%= hazard %> expected to start on <%= firstLeadTimeString %>, <%= + firstLeadTimeQuantity %>s from now. + + + <%= disasterIssuedLabel %>: + + expected to reach threshold on <%= firstTriggerLeadTimeString %>, <%= + firstTriggerLeadTimeQuantity %>s from now. + + + <% } else { %> <%= disasterIssuedLabel %>: - <%= startDateEventString %>, <%= leadTime %>s from now. + expected on <%= firstLeadTimeString %>, <%= firstLeadTimeQuantity %>s from + now. + <% } %> Expected exposed <%= defaultAdminAreaLabel %>: diff --git a/services/API-service/src/api/notification/notification-content/notification-content.service.ts b/services/API-service/src/api/notification/notification-content/notification-content.service.ts index ec5eaffdf..e221a63e9 100644 --- a/services/API-service/src/api/notification/notification-content/notification-content.service.ts +++ b/services/API-service/src/api/notification/notification-content/notification-content.service.ts @@ -167,6 +167,7 @@ export class NotificationContentService { event, ); data.firstLeadTime = event.firstLeadTime; + data.firstTriggerLeadTime = event.firstTriggerLeadTime; data.triggeredAreas = await this.getSortedTriggeredAreas( country, disasterType, @@ -179,11 +180,17 @@ export class NotificationContentService { ); // This looks weird, but as far as I understand the startDate of the event is the day it was first issued data.issuedDate = new Date(event.startDate); - data.startDateDisasterString = await this.getFirstLeadTimeString( + data.firstLeadTimeString = await this.getFirstLeadTimeString( event, event.countryCodeISO3, disasterType, ); + data.firstTriggerLeadTimeString = await this.getFirstTriggerLeadTimeString( + event, + event.countryCodeISO3, + disasterType, + ); + data.totalAffectedOfIndicator = this.getTotalAffectedPerEvent( data.triggeredAreas, ); @@ -290,16 +297,48 @@ export class NotificationContentService { countryCodeISO3: string, disasterType: DisasterType, date?: Date, - ) { + ): Promise { + return this.getEventTimeString( + event.firstLeadTime, + countryCodeISO3, + disasterType, + date, + ); + } + + public async getFirstTriggerLeadTimeString( + event: EventSummaryCountry, + countryCodeISO3: string, + disasterType: DisasterType, + date?: Date, + ): Promise { + if (event.firstTriggerLeadTime) { + return this.getEventTimeString( + event.firstTriggerLeadTime, + countryCodeISO3, + disasterType, + date, + ); + } else { + return null; + } + } + + private async getEventTimeString( + leadTime: LeadTime, + countryCodeISO3: string, + disasterType: DisasterType, + date?: Date, + ): Promise { const startDateFirstEvent = await this.getFirstLeadTimeDate( - Number(event.firstLeadTime.split('-')[0]), - event.firstLeadTime.split('-')[1], + Number(leadTime.split('-')[0]), + leadTime.split('-')[1], countryCodeISO3, disasterType, date, ); const startTimeFirstEvent = await this.getLeadTimeTimestamp( - event.firstLeadTime, + leadTime, countryCodeISO3, disasterType, ); From f08df08772dfd2126a868d7b8a49dbdde700ca25 Mon Sep 17 00:00:00 2001 From: Ruben Date: Fri, 24 May 2024 13:46:29 +0200 Subject: [PATCH 17/21] typo --- .../src/api/notification/email/email-template.service.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/services/API-service/src/api/notification/email/email-template.service.ts b/services/API-service/src/api/notification/email/email-template.service.ts index 0f28dc726..1db22f41b 100644 --- a/services/API-service/src/api/notification/email/email-template.service.ts +++ b/services/API-service/src/api/notification/email/email-template.service.ts @@ -262,9 +262,9 @@ export class EmailTemplateService { hazard: emailContent.disasterTypeLabel, triggerStatusLabel: event.triggerStatusLabel, eventName: event.eventName, - defaulAdminAreaLabelSingular: + defaultAdminAreaLabelSingular: emailContent.defaultAdminAreaLabel.singular, - defaulAdminAreaLabelPlural: + defaultAdminAreaLabelPlural: emailContent.defaultAdminAreaLabel.plural.toLocaleLowerCase(), defaultAdminAreaLabelParent: adminAreaLabelsParent.singular, indicatorLabel: emailContent.indicatorMetadata.label, @@ -334,7 +334,7 @@ export class EmailTemplateService { emailContent.country.countryCodeISO3, ), startDateEventString: event.startDateDisasterString, - defaulAdminAreaLabel: + defaultAdminAreaLabel: emailContent.defaultAdminAreaLabel.plural.toLocaleLowerCase(), indicatorLabel: emailContent.indicatorMetadata.label, totalAffectedOfIndicator: event.totalAffectedOfIndicator, @@ -404,7 +404,7 @@ export class EmailTemplateService { html = this.readHtmlFile('body-total-affected-trigger.html'); } return ejs.render(html, { - totalAffectectedOfIndicator: event.totalAffectedOfIndicator, + totalAffectedOfIndicator: event.totalAffectedOfIndicator, indicatorUnit: indicatorUnit, }); } From f42558d6075c1aebc2dcd977dcae7cfe074d3539 Mon Sep 17 00:00:00 2001 From: Gulfaraz Rahman Date: Thu, 4 Jul 2024 18:15:08 +0200 Subject: [PATCH 18/21] style: typo --- .../notification/email/email-template.service.ts | 13 ++++++------- .../notification/email/html/table-trigger-row.html | 2 +- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/services/API-service/src/api/notification/email/email-template.service.ts b/services/API-service/src/api/notification/email/email-template.service.ts index a3a7f1925..0b5b110f0 100644 --- a/services/API-service/src/api/notification/email/email-template.service.ts +++ b/services/API-service/src/api/notification/email/email-template.service.ts @@ -14,7 +14,6 @@ import { } from '../../../shared/data.model'; import { CountryEntity } from '../../country/country.entity'; import * as juice from 'juice'; -import { formatActionUnitValue } from '../helpers/format-action-unit-value.helper'; const emailFolder = './src/api/notification/email'; const emailTemplateFolder = `${emailFolder}/html`; @@ -120,7 +119,7 @@ export class EmailTemplateService { const template = this.readHtmlFile('event-finished.html'); for (const event of events) { - const eventFinshedHtml = ejs.render(template, { + const eventFinishedHtml = ejs.render(template, { disasterTypeLabel: disasterTypeLabel, eventName: event.eventName, issuedDate: this.dateObjectToDateTimeString( @@ -129,7 +128,7 @@ export class EmailTemplateService { ), timezone: CountryTimeZoneMapping[country.countryCodeISO3], }); - html += eventFinshedHtml; + html += eventFinishedHtml; } return html; } @@ -221,7 +220,7 @@ export class EmailTemplateService { private async formatEmail( emailKeyValueReplaceObject: Record, ): Promise { - // TODO REFACTOR: Apply styles in a septerate file also for the base.html + // TODO REFACTOR: Apply styles in a separate file also for the base.html const template = this.readHtmlFile('base.html'); const styles = this.readHtmlFile('styles.ejs'); const templateWithStyle = styles + template; @@ -304,13 +303,13 @@ export class EmailTemplateService { private getTablesRows(event: NotificationDataPerEventDto) { return event.triggeredAreas .map((area) => { - const tableRowHmltFileName = + const tableRowHtmlFileName = TriggerStatusLabelEnum.Trigger === event.triggerStatusLabel ? 'table-trigger-row.html' : 'table-warning-row.html'; - const areaTemplate = this.readHtmlFile(tableRowHmltFileName); + const areaTemplate = this.readHtmlFile(tableRowHtmlFileName); const areaData = { - affectectedOfIndicator: area.actionsValue, + affectedOfIndicator: area.actionsValue, adminBoundary: area.displayName ? area.displayName : area.name, higherAdminBoundary: area.nameParent, }; diff --git a/services/API-service/src/api/notification/email/html/table-trigger-row.html b/services/API-service/src/api/notification/email/html/table-trigger-row.html index 8f942e29e..5dfe41cd0 100644 --- a/services/API-service/src/api/notification/email/html/table-trigger-row.html +++ b/services/API-service/src/api/notification/email/html/table-trigger-row.html @@ -1,6 +1,6 @@ - <%= affectectedOfIndicator %> + <%= affectedOfIndicator %> <%= adminBoundary %> (<%= higherAdminBoundary %>) From c841b6efdbdfbc20da173c99fa320e3c641a3b32 Mon Sep 17 00:00:00 2001 From: Gulfaraz Rahman Date: Thu, 4 Jul 2024 18:57:39 +0200 Subject: [PATCH 19/21] refactor: readability issues and minor refactors --- .../email/email-template.service.ts | 169 ++++++------------ 1 file changed, 56 insertions(+), 113 deletions(-) diff --git a/services/API-service/src/api/notification/email/email-template.service.ts b/services/API-service/src/api/notification/email/email-template.service.ts index 0b5b110f0..01aad41f5 100644 --- a/services/API-service/src/api/notification/email/email-template.service.ts +++ b/services/API-service/src/api/notification/email/email-template.service.ts @@ -57,7 +57,7 @@ export class EmailTemplateService { const disasterType = emailContent.disasterType; const keyValueReplaceObject = { - emailBody: this.getEmailBody(false), + emailBody: this.readHtmlFile('trigger-notification.html'), headerEventOverview: this.getHeaderEventStarted(emailContent), notificationActions: this.getNotificationActionsHtml( country, @@ -84,9 +84,9 @@ export class EmailTemplateService { disasterTypeLabel: string, ): Record { const keyValueReplaceObject = { - emailBody: this.getEmailBody(true), + emailBody: this.readHtmlFile('trigger-finished.html'), headerEventOverview: '', - eventOverview: this.geEventsFinishedOverview( + eventOverview: this.getEventsFinishedOverview( country, events, disasterTypeLabel, @@ -102,35 +102,18 @@ export class EmailTemplateService { return keyValueReplaceObject; } - private getEmailBody(triggerFinished: boolean): string { - if (triggerFinished) { - return this.readHtmlFile('trigger-finished.html'); - } else { - return this.readHtmlFile('trigger-notification.html'); - } - } - - private geEventsFinishedOverview( + private getEventsFinishedOverview( country: CountryEntity, events: EventSummaryCountry[], disasterTypeLabel: string, ): string { - let html = ''; const template = this.readHtmlFile('event-finished.html'); - - for (const event of events) { - const eventFinishedHtml = ejs.render(template, { - disasterTypeLabel: disasterTypeLabel, - eventName: event.eventName, - issuedDate: this.dateObjectToDateTimeString( - new Date(event.startDate), - country.countryCodeISO3, - ), - timezone: CountryTimeZoneMapping[country.countryCodeISO3], - }); - html += eventFinishedHtml; - } - return html; + return events.map(event => ejs.render(template, { + disasterTypeLabel, + eventName: event.eventName, + issuedDate: this.dateObjectToDateTimeString(new Date(event.startDate), country.countryCodeISO3), + timezone: CountryTimeZoneMapping[country.countryCodeISO3], + })).join(''); } private getHeaderEventStarted(emailContent: ContentEventEmail): string { @@ -162,59 +145,47 @@ export class EmailTemplateService { return html; } - private getSocialMediaHtml(country: CountryEntity): string { - if (country.notificationInfo.linkSocialMediaType) { - this.readHtmlFile('social-media-link.html'); - } else { - return ''; - } + private getSocialMediaHtml(country: CountryEntity) { + return country.notificationInfo.linkSocialMediaType ? this.readHtmlFile('social-media-link.html') : ''; } - private getMapImageHtml(emailContent: ContentEventEmail): string { - let html = ''; - for (const event of emailContent.dataPerEvent) { - const mapImage = event.mapImage; - if (mapImage) { - let eventHtml = this.readHtmlFile('map-image.html'); + private getMapImageHtml(emailContent: ContentEventEmail) { + return emailContent.dataPerEvent + .filter(event => event.mapImage) + .map(event => { + const eventHtmlTemplate = this.readHtmlFile('map-image.html'); const replacements = { mapImgSrc: this.getMapImgSrc( emailContent.country.countryCodeISO3, emailContent.disasterType, event.eventName, ), - mapImgDescription: this.getMapImageDescription( - emailContent.disasterType, - ), + mapImgDescription: this.getMapImageDescription(emailContent.disasterType), eventName: event.eventName ? `(for ${event.eventName})` : '', }; - eventHtml = ejs.render(eventHtml, replacements); - html += eventHtml; - } - } - return html; + return ejs.render(eventHtmlTemplate, replacements); + }) + .join(''); } private getMapImgSrc( countryCodeISO3: string, disasterType: DisasterType, eventName: string, - ): string { - const src = `${ + ) { + return `${ process.env.NG_API_URL }/event/event-map-image/${countryCodeISO3}/${disasterType}/${ eventName || 'no-name' }`; - - return src; } private getMapImageDescription(disasterType: DisasterType): string { - switch (disasterType) { - case DisasterType.Floods: - return 'The triggered areas are outlined in purple. The potential flood extent is shown in red.
'; - default: - return ''; - } + const descriptions = { + [DisasterType.Floods]: 'The triggered areas are outlined in purple. The potential flood extent is shown in red.
', + }; + + return descriptions[disasterType] || ''; } private async formatEmail( @@ -288,16 +259,13 @@ export class EmailTemplateService { .join(''); } - private getEventSeverityLabel( - eapAlertClassKey: EapAlertClassKeyEnum, - ): string { - if (eapAlertClassKey === EapAlertClassKeyEnum.med) { - return 'Medium'; - } else if (eapAlertClassKey === EapAlertClassKeyEnum.min) { - return 'Low'; - } else { - return ''; - } + private getEventSeverityLabel(eapAlertClassKey: EapAlertClassKeyEnum): string { + const severityLabels = { + [EapAlertClassKeyEnum.med]: 'Medium', + [EapAlertClassKeyEnum.min]: 'Low', + }; + + return severityLabels[eapAlertClassKey] || ''; } private getTablesRows(event: NotificationDataPerEventDto) { @@ -370,39 +338,20 @@ export class EmailTemplateService { .join(''); } - private getDisasterIssuedLabel( - eapLabel: string, - triggerStatusLabel: TriggerStatusLabelEnum, - ) { - if (eapLabel) { - return eapLabel; - } else { - return triggerStatusLabel; - } + private getDisasterIssuedLabel(eapLabel: string, triggerStatusLabel: TriggerStatusLabelEnum) { + return eapLabel || triggerStatusLabel; } - private getAdvisoryHtml( - triggerStatusLabel: TriggerStatusLabelEnum, - eapLink: string, - ): string { - const advisoryHtml = - triggerStatusLabel === TriggerStatusLabelEnum.Trigger - ? this.readHtmlFile('advisory-trigger.html') - : this.readHtmlFile('advisory-warning.html'); - return ejs.render(advisoryHtml, { eapLink: eapLink }); + private getAdvisoryHtml(triggerStatusLabel: TriggerStatusLabelEnum, eapLink: string) { + const fileName = triggerStatusLabel === TriggerStatusLabelEnum.Trigger ? 'advisory-trigger.html' : 'advisory-warning.html'; + const advisoryHtml = this.readHtmlFile(fileName); + return ejs.render(advisoryHtml, { eapLink }); } - private getTotalAffectedHtml( - event: NotificationDataPerEventDto, - indicatorUnit: string, - ): string { - let html = ''; - if (event.triggerStatusLabel === TriggerStatusLabelEnum.Warning) { - html = this.readHtmlFile('body-total-affected-warning.html'); - } else { - html = this.readHtmlFile('body-total-affected-trigger.html'); - } - return ejs.render(html, { + private getTotalAffectedHtml(event: NotificationDataPerEventDto, indicatorUnit: string): string { + const fileName = event.triggerStatusLabel === TriggerStatusLabelEnum.Warning ? 'body-total-affected-warning.html' : 'body-total-affected-trigger.html'; + const htmlTemplate = this.readHtmlFile(fileName); + return ejs.render(htmlTemplate, { totalAffectedOfIndicator: event.totalAffectedOfIndicator, indicatorUnit: indicatorUnit, }); @@ -471,25 +420,19 @@ export class EmailTemplateService { eapAlertClassKey: EapAlertClassKeyEnum, triggerStatusLabel: TriggerStatusLabelEnum, ) { - let fileName = ''; - // Still need implement the difference between medium and low warning - if (eapAlertClassKey) { - if (eapAlertClassKey === EapAlertClassKeyEnum.med) { - fileName = 'warning-medium.png'; - } else if (eapAlertClassKey === EapAlertClassKeyEnum.min) { - fileName = 'warning-low.png'; - } else { - fileName = 'trigger.png'; - } - } else { - fileName = - triggerStatusLabel === TriggerStatusLabelEnum.Trigger - ? 'trigger.png' - : 'warning-medium.png'; + const fileNameMap = { + [EapAlertClassKeyEnum.med]: 'warning-medium.png', + [EapAlertClassKeyEnum.min]: 'warning-low.png', + default: 'trigger.png', + }; + + let fileName = eapAlertClassKey ? fileNameMap[eapAlertClassKey] : fileNameMap.default; + if (!eapAlertClassKey && triggerStatusLabel !== TriggerStatusLabelEnum.Trigger) { + fileName = 'warning-medium.png'; } + const filePath = `${emailIconFolder}/${fileName}`; - const imageDataURL = this.getPngImageAsDataURL(filePath); - return imageDataURL; + return this.getPngImageAsDataURL(filePath); } private getLogoImageAsDataURL() { From 435a2f51aac9d0bf6a079975dbcc687dde3ffa63 Mon Sep 17 00:00:00 2001 From: Gulfaraz Rahman Date: Thu, 4 Jul 2024 19:10:01 +0200 Subject: [PATCH 20/21] style: format using prettier --- .../email/email-template.service.ts | 92 +++++++++++++------ 1 file changed, 63 insertions(+), 29 deletions(-) diff --git a/services/API-service/src/api/notification/email/email-template.service.ts b/services/API-service/src/api/notification/email/email-template.service.ts index 01aad41f5..d01d5a1eb 100644 --- a/services/API-service/src/api/notification/email/email-template.service.ts +++ b/services/API-service/src/api/notification/email/email-template.service.ts @@ -1,19 +1,19 @@ -import { Injectable } from '@nestjs/common'; -import { ContentEventEmail } from '../dto/content-trigger-email.dto'; -import { - NotificationDataPerEventDto, - TriggerStatusLabelEnum, -} from '../dto/notification-date-per-event.dto'; -import * as ejs from 'ejs'; import * as fs from 'fs'; -import { CountryTimeZoneMapping } from '../../country/country-time-zone-mapping'; -import { DisasterType } from '../../disaster/disaster-type.enum'; +import * as ejs from 'ejs'; +import * as juice from 'juice'; import { EapAlertClassKeyEnum, EventSummaryCountry, } from '../../../shared/data.model'; +import { CountryTimeZoneMapping } from '../../country/country-time-zone-mapping'; import { CountryEntity } from '../../country/country.entity'; -import * as juice from 'juice'; +import { DisasterType } from '../../disaster/disaster-type.enum'; +import { ContentEventEmail } from '../dto/content-trigger-email.dto'; +import { + NotificationDataPerEventDto, + TriggerStatusLabelEnum, +} from '../dto/notification-date-per-event.dto'; +import { Injectable } from '@nestjs/common'; const emailFolder = './src/api/notification/email'; const emailTemplateFolder = `${emailFolder}/html`; @@ -108,12 +108,19 @@ export class EmailTemplateService { disasterTypeLabel: string, ): string { const template = this.readHtmlFile('event-finished.html'); - return events.map(event => ejs.render(template, { - disasterTypeLabel, - eventName: event.eventName, - issuedDate: this.dateObjectToDateTimeString(new Date(event.startDate), country.countryCodeISO3), - timezone: CountryTimeZoneMapping[country.countryCodeISO3], - })).join(''); + return events + .map((event) => + ejs.render(template, { + disasterTypeLabel, + eventName: event.eventName, + issuedDate: this.dateObjectToDateTimeString( + new Date(event.startDate), + country.countryCodeISO3, + ), + timezone: CountryTimeZoneMapping[country.countryCodeISO3], + }), + ) + .join(''); } private getHeaderEventStarted(emailContent: ContentEventEmail): string { @@ -146,13 +153,15 @@ export class EmailTemplateService { } private getSocialMediaHtml(country: CountryEntity) { - return country.notificationInfo.linkSocialMediaType ? this.readHtmlFile('social-media-link.html') : ''; + return country.notificationInfo.linkSocialMediaType + ? this.readHtmlFile('social-media-link.html') + : ''; } private getMapImageHtml(emailContent: ContentEventEmail) { return emailContent.dataPerEvent - .filter(event => event.mapImage) - .map(event => { + .filter((event) => event.mapImage) + .map((event) => { const eventHtmlTemplate = this.readHtmlFile('map-image.html'); const replacements = { mapImgSrc: this.getMapImgSrc( @@ -160,7 +169,9 @@ export class EmailTemplateService { emailContent.disasterType, event.eventName, ), - mapImgDescription: this.getMapImageDescription(emailContent.disasterType), + mapImgDescription: this.getMapImageDescription( + emailContent.disasterType, + ), eventName: event.eventName ? `(for ${event.eventName})` : '', }; return ejs.render(eventHtmlTemplate, replacements); @@ -182,7 +193,8 @@ export class EmailTemplateService { private getMapImageDescription(disasterType: DisasterType): string { const descriptions = { - [DisasterType.Floods]: 'The triggered areas are outlined in purple. The potential flood extent is shown in red.
', + [DisasterType.Floods]: + 'The triggered areas are outlined in purple. The potential flood extent is shown in red.
', }; return descriptions[disasterType] || ''; @@ -259,7 +271,9 @@ export class EmailTemplateService { .join(''); } - private getEventSeverityLabel(eapAlertClassKey: EapAlertClassKeyEnum): string { + private getEventSeverityLabel( + eapAlertClassKey: EapAlertClassKeyEnum, + ): string { const severityLabels = { [EapAlertClassKeyEnum.med]: 'Medium', [EapAlertClassKeyEnum.min]: 'Low', @@ -338,18 +352,33 @@ export class EmailTemplateService { .join(''); } - private getDisasterIssuedLabel(eapLabel: string, triggerStatusLabel: TriggerStatusLabelEnum) { + private getDisasterIssuedLabel( + eapLabel: string, + triggerStatusLabel: TriggerStatusLabelEnum, + ) { return eapLabel || triggerStatusLabel; } - private getAdvisoryHtml(triggerStatusLabel: TriggerStatusLabelEnum, eapLink: string) { - const fileName = triggerStatusLabel === TriggerStatusLabelEnum.Trigger ? 'advisory-trigger.html' : 'advisory-warning.html'; + private getAdvisoryHtml( + triggerStatusLabel: TriggerStatusLabelEnum, + eapLink: string, + ) { + const fileName = + triggerStatusLabel === TriggerStatusLabelEnum.Trigger + ? 'advisory-trigger.html' + : 'advisory-warning.html'; const advisoryHtml = this.readHtmlFile(fileName); return ejs.render(advisoryHtml, { eapLink }); } - private getTotalAffectedHtml(event: NotificationDataPerEventDto, indicatorUnit: string): string { - const fileName = event.triggerStatusLabel === TriggerStatusLabelEnum.Warning ? 'body-total-affected-warning.html' : 'body-total-affected-trigger.html'; + private getTotalAffectedHtml( + event: NotificationDataPerEventDto, + indicatorUnit: string, + ): string { + const fileName = + event.triggerStatusLabel === TriggerStatusLabelEnum.Warning + ? 'body-total-affected-warning.html' + : 'body-total-affected-trigger.html'; const htmlTemplate = this.readHtmlFile(fileName); return ejs.render(htmlTemplate, { totalAffectedOfIndicator: event.totalAffectedOfIndicator, @@ -426,8 +455,13 @@ export class EmailTemplateService { default: 'trigger.png', }; - let fileName = eapAlertClassKey ? fileNameMap[eapAlertClassKey] : fileNameMap.default; - if (!eapAlertClassKey && triggerStatusLabel !== TriggerStatusLabelEnum.Trigger) { + let fileName = eapAlertClassKey + ? fileNameMap[eapAlertClassKey] + : fileNameMap.default; + if ( + !eapAlertClassKey && + triggerStatusLabel !== TriggerStatusLabelEnum.Trigger + ) { fileName = 'warning-medium.png'; } From 29ef92c61e1113071e21cd1e41b051f49b601326 Mon Sep 17 00:00:00 2001 From: Gulfaraz Rahman Date: Thu, 4 Jul 2024 19:27:06 +0200 Subject: [PATCH 21/21] style: format fix --- .../test/email/typhoon/test-typhoon-scenario.helper.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/services/API-service/test/email/typhoon/test-typhoon-scenario.helper.ts b/services/API-service/test/email/typhoon/test-typhoon-scenario.helper.ts index 38fbb23d4..e6289e900 100644 --- a/services/API-service/test/email/typhoon/test-typhoon-scenario.helper.ts +++ b/services/API-service/test/email/typhoon/test-typhoon-scenario.helper.ts @@ -9,8 +9,8 @@ export async function testTyphoonScenario( accessToken: string, ): Promise { const nrOfEvents = 2; - const eventName = 'Mock typhoon' - const disasterTypeLabel = DisasterType.Typhoon + const eventName = 'Mock typhoon'; + const disasterTypeLabel = DisasterType.Typhoon; // const disasterType = DisasterType.Typhoon; // const disasterTypeLabel = disasters.find(