diff --git a/interfaces/IBF-dashboard/src/assets/i18n/en.json b/interfaces/IBF-dashboard/src/assets/i18n/en.json index b833618f1..f31f21a6e 100644 --- a/interfaces/IBF-dashboard/src/assets/i18n/en.json +++ b/interfaces/IBF-dashboard/src/assets/i18n/en.json @@ -265,10 +265,10 @@ }, "active-event-active-trigger": { "header": "Trigger: {{ eventName }} at {{firstLeadTimeDate}}", - "header-ongoing": "Ongoing trigger: {{ eventName }} at {{firstLeadTimeDate}}", + "header-ongoing": "Ongoing trigger: {{ eventName }}", "header-below-trigger": "Warning: {{ eventName }} at {{firstLeadTimeDate}}", - "header-ongoing-below-trigger": "Ongoing warning: {{ eventName }} at {{firstLeadTimeDate}}", - "welcome": "A trigger warning for {{ eventName }} was issued on {{ startDate }}.", + "header-ongoing-below-trigger": "Ongoing warning: {{ eventName }}", + "welcome": "A trigger for {{ eventName }} was issued on {{ startDate }}.", "welcome-below-trigger": "A warning for {{ eventName }} was issued on {{ startDate }}, but it is currently not predicted to reach trigger thresholds.", "upcoming-event": { "landfall": "It is estimated to make landfall on {{ firstLeadTimeDate }}.

", @@ -277,7 +277,7 @@ }, "ongoing-event": { "landfall": "It has already made landfall.

", - "no-landfall": "It has already reached the point closest to land.

" + "no-landfall": "It has already reached the point closest to land. It is not predicted to make landfall.

" } }, "active-event": { 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 b25fe9278..159bd68c7 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,10 +1,14 @@ -import { EapAlertClass, TriggeredArea } from '../../../shared/data.model'; +import { + DisasterSpecificProperties, + EapAlertClass, + 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; + disasterSpecificProperties: DisasterSpecificProperties; /** * The day that the event starts. 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 594ee44a6..32fbd22e4 100644 --- a/services/API-service/src/api/notification/email/email.service.ts +++ b/services/API-service/src/api/notification/email/email.service.ts @@ -64,7 +64,7 @@ export class EmailService { if (isApiTest) { // NOTE: use this to test the email output instead of using Mailchimp // fs.writeFileSync( - // `email-${country.countryCodeISO3}-${disasterType}.html`, + // `email-${country.countryCodeISO3}-${disasterType}-${new Date()}.html`, // emailHtml, // ); return emailHtml; diff --git a/services/API-service/src/api/notification/email/mjml/body-event.ts b/services/API-service/src/api/notification/email/mjml/body-event.ts index 2a6f968ac..b49ae46fc 100644 --- a/services/API-service/src/api/notification/email/mjml/body-event.ts +++ b/services/API-service/src/api/notification/email/mjml/body-event.ts @@ -1,5 +1,10 @@ +import { LeadTime } from '../../../admin-area-dynamic-data/enum/lead-time.enum'; +import { DisasterType } from '../../../disaster/disaster-type.enum'; import { ContentEventEmail } from '../../dto/content-trigger-email.dto'; -import { TriggerStatusLabelEnum } from '../../dto/notification-date-per-event.dto'; +import { + NotificationDataPerEventDto, + TriggerStatusLabelEnum, +} from '../../dto/notification-date-per-event.dto'; import { dateObjectToDateTimeString, getDisasterIssuedLabel, @@ -28,6 +33,7 @@ const getMjmlBodyEvent = ({ triangleIcon, eapLink, triggerStatusLabel, + disasterSpecificCopy, }: { color: string; defaultAdminAreaLabel: string; @@ -44,6 +50,7 @@ const getMjmlBodyEvent = ({ triangleIcon: string; eapLink: string; triggerStatusLabel: string; + disasterSpecificCopy: string; }): object => { const icon = getInlineImage({ src: triangleIcon, size: 16 }); @@ -54,23 +61,29 @@ const getMjmlBodyEvent = ({ const contentContent = []; - if (firstTriggerLeadTimeFromNow) { - // Trigger event - if (firstLeadTimeString !== firstTriggerLeadTimeString) { - // Warning-to-trigger event: show start of warning first - contentContent.push( - `${disasterTypeLabel}: Expected to start on ${firstLeadTimeString}, ${firstLeadTimeFromNow}.`, - ); - } - // Either way, show start of trigger next + if (disasterSpecificCopy) { contentContent.push( - `${disasterIssuedLabel}: Expected to trigger on ${firstTriggerLeadTimeString}, ${firstTriggerLeadTimeFromNow}.`, + `${disasterIssuedLabel}: ${disasterSpecificCopy}`, ); } else { - // Warning event - contentContent.push( - `${disasterIssuedLabel}: Expected to start on ${firstLeadTimeString}, ${firstLeadTimeFromNow}.`, - ); + if (triggerStatusLabel === TriggerStatusLabelEnum.Trigger) { + // Trigger event + if (firstLeadTimeString !== firstTriggerLeadTimeString) { + // Warning-to-trigger event: show start of warning first + contentContent.push( + `${disasterTypeLabel}: Expected to start on ${firstLeadTimeString}, ${firstLeadTimeFromNow}.`, + ); + } + // Either way, show start of trigger next, the above line is optionally extra + contentContent.push( + `${disasterIssuedLabel}: Expected to start on ${firstTriggerLeadTimeString}, ${firstTriggerLeadTimeFromNow}.`, + ); + } else { + // Warning event + contentContent.push( + `${disasterIssuedLabel}: Expected to start on ${firstLeadTimeString}, ${firstLeadTimeFromNow}.`, + ); + } } contentContent.push( @@ -88,7 +101,7 @@ const getMjmlBodyEvent = ({ }); const closingElement = getTextElement({ - content: `This ${triggerStatusLabel} was first issued by IBF on ${issuedDate} (${timeZone})`, + content: `This ${triggerStatusLabel.toLowerCase()} was first issued by IBF on ${issuedDate} (${timeZone})`, attributes: { 'padding-top': '8px', 'font-size': '14px', @@ -101,10 +114,48 @@ const getMjmlBodyEvent = ({ }); }; +const getTyphoonSpecificCopy = (event: NotificationDataPerEventDto): string => { + let disasterSpecificCopy: string; + if (event.firstLeadTime === LeadTime.hour0) { + if (event.disasterSpecificProperties.typhoonLandfall) { + disasterSpecificCopy = 'Has already made landfall.'; + } else { + disasterSpecificCopy = + 'Has already reached the point closest to land. Not predicted to make landfall.'; + } + } else { + if (event.disasterSpecificProperties.typhoonLandfall) { + disasterSpecificCopy = `Expected to make landfall on ${ + event.triggerStatusLabel === TriggerStatusLabelEnum.Trigger + ? event.firstTriggerLeadTimeString + : event.firstLeadTimeString + }.`; + } else if (event.disasterSpecificProperties.typhoonNoLandfallYet) { + disasterSpecificCopy = + 'The landfall time prediction cannot be determined yet. Keep monitoring the event.'; + } else { + disasterSpecificCopy = `Expected to reach the point closest to land on ${ + event.triggerStatusLabel === TriggerStatusLabelEnum.Trigger + ? event.firstTriggerLeadTimeString + : event.firstLeadTimeString + }. Not predicted to make landfall.`; + } + } + if (event.triggerStatusLabel === TriggerStatusLabelEnum.Warning) { + disasterSpecificCopy += ' Not predicted to reach trigger thresholds.'; + } + return disasterSpecificCopy; +}; + export const getMjmlEventListBody = (emailContent: ContentEventEmail) => { const eventList = []; for (const event of emailContent.dataPerEvent) { + let disasterSpecificCopy: string; + if (emailContent.disasterType === DisasterType.Typhoon) { + disasterSpecificCopy = getTyphoonSpecificCopy(event); + } + eventList.push( getMjmlBodyEvent({ eventName: event.eventName, @@ -137,12 +188,15 @@ export const getMjmlEventListBody = (emailContent: ContentEventEmail) => { disasterIssuedLabel: getDisasterIssuedLabel( event.eapAlertClass?.label, - emailContent.disasterTypeLabel, + event.triggerStatusLabel, ), color: getIbfHexColor( event.eapAlertClass?.color, event.triggerStatusLabel, ), + + // Disaster-specific copy + disasterSpecificCopy, }), ); } diff --git a/services/API-service/src/api/notification/helpers/mjml.helper.ts b/services/API-service/src/api/notification/helpers/mjml.helper.ts index cc2e3d7b2..44a7a1298 100644 --- a/services/API-service/src/api/notification/helpers/mjml.helper.ts +++ b/services/API-service/src/api/notification/helpers/mjml.helper.ts @@ -268,11 +268,13 @@ export const getTimeFromNow = (leadTime: LeadTime) => { const leadTimeQuantity = parseInt(leadTime.split('-')[0]); - return [LeadTime.day0, LeadTime.month0, LeadTime.hour0].includes(leadTime) - ? 'ongoing' - : `${leadTime.replace('-', ' ')}${ - leadTimeQuantity === 1 ? '' : 's' - } from now`; + if (leadTimeQuantity === 0) { + return 'ongoing'; + } + + return `${leadTime.replace('-', ' ')}${ + leadTimeQuantity === 1 ? '' : 's' + } from now`; }; export const getTriangleIcon = ( @@ -321,9 +323,9 @@ export const getPngImageAsDataURL = (relativePath: string) => { export const getDisasterIssuedLabel = ( eapLabel: string, - disasterTypeLabel: string, + triggerStatusLabel: TriggerStatusLabelEnum, ) => { - return eapLabel || disasterTypeLabel; + return eapLabel || triggerStatusLabel; }; export const getIbfHexColor = ( 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 41f33385a..a53b4f5aa 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 @@ -80,7 +80,7 @@ export class NotificationContentService { }); } - public async getDisaster( + private async getDisaster( disasterType: DisasterType, ): Promise { return await this.disasterRepository.findOne({ @@ -165,11 +165,7 @@ export class NotificationContentService { : TriggerStatusLabelEnum.Warning; data.eventName = await this.getFormattedEventName(event, disasterType); - data.disasterSpecificCopy = await this.getDisasterSpecificCopy( - disasterType, - event.firstLeadTime, - event, - ); + data.disasterSpecificProperties = event.disasterSpecificProperties; data.firstLeadTime = event.firstLeadTime; data.firstTriggerLeadTime = event.firstTriggerLeadTime; data.triggeredAreas = await this.getSortedTriggeredAreas( @@ -178,7 +174,7 @@ export class NotificationContentService { event, ); data.nrOfTriggeredAreas = data.triggeredAreas.length; - // 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.firstLeadTimeString = await this.getFirstLeadTimeString( event, @@ -348,109 +344,6 @@ export class NotificationContentService { ); } - private async getDisasterSpecificCopy( - disasterType: DisasterType, - leadTime: LeadTime, - event: EventSummaryCountry, - ): Promise<{ - eventStatus: string; - extraInfo: string; - leadTimeString?: string; - timestamp?: string; - }> { - switch (disasterType) { - case DisasterType.HeavyRain: - return this.getHeavyRainCopy(); - case DisasterType.Typhoon: - return await this.getTyphoonCopy(leadTime, event); - case DisasterType.FlashFloods: - return await this.getFlashFloodsCopy(leadTime, event); - default: - return { eventStatus: '', extraInfo: '' }; - } - } - - private getHeavyRainCopy(): { - eventStatus: string; - extraInfo: string; - } { - return { - eventStatus: 'Estimated', - extraInfo: '', - }; - } - - private async getTyphoonCopy( - leadTime: LeadTime, - event: EventSummaryCountry, - ): Promise<{ - eventStatus: string; - extraInfo: string; - leadTimeString: string; - timestamp: string; - }> { - const { typhoonLandfall, typhoonNoLandfallYet } = - event.disasterSpecificProperties; - let eventStatus = ''; - let extraInfo = ''; - let leadTimeString = null; - - if (leadTime === LeadTime.hour0) { - if (typhoonLandfall) { - eventStatus = 'Has already made landfall'; - leadTimeString = 'Already made landfall'; - } else { - eventStatus = 'Has already reached the point closest to land'; - leadTimeString = 'reached the point closest to land'; - } - } else { - if (typhoonNoLandfallYet) { - eventStatus = - 'Landfall time prediction cannot be determined yet'; - extraInfo = 'Keep monitoring the event.'; - leadTimeString = 'Undetermined landfall'; - } else if (typhoonLandfall) { - eventStatus = 'Estimated to make landfall'; - } else { - eventStatus = - 'Not predicted to make landfall. It is estimated to reach the point closest to land'; - } - } - - const timestampString = await this.getLeadTimeTimestamp( - leadTime, - event.countryCodeISO3, - DisasterType.Typhoon, - ); - - return { - eventStatus: eventStatus, - extraInfo: extraInfo, - leadTimeString, - timestamp: timestampString, - }; - } - - private async getFlashFloodsCopy( - leadTime: LeadTime, - event: EventSummaryCountry, - ): Promise<{ - eventStatus: string; - extraInfo: string; - timestamp: string; - }> { - const timestampString = await this.getLeadTimeTimestamp( - leadTime, - event.countryCodeISO3, - DisasterType.FlashFloods, - ); - return { - eventStatus: 'The flash flood is forecasted: ', - extraInfo: '', - timestamp: timestampString, - }; - } - private async getLeadTimeTimestamp( leadTime: LeadTime, countryCodeISO3: string, diff --git a/tests/integration/email/typhoon/email-phl-typhoon.test.ts b/tests/integration/email/typhoon/email-phl-typhoon.test.ts index 937888b75..dbba8f332 100644 --- a/tests/integration/email/typhoon/email-phl-typhoon.test.ts +++ b/tests/integration/email/typhoon/email-phl-typhoon.test.ts @@ -19,4 +19,40 @@ describe('Should send an email for phl typhoon', () => { ); expect(result).toBeTruthy(); }); + + it('warning', async () => { + const result = await testTyphoonScenario( + TyphoonScenario.EventNoTrigger, + countryCodeISO3, + accessToken, + ); + expect(result).toBeTruthy(); + }); + + it('no landfall (trigger)', async () => { + const result = await testTyphoonScenario( + TyphoonScenario.EventNoLandfall, + countryCodeISO3, + accessToken, + ); + expect(result).toBeTruthy(); + }); + + it('no landfall yet (trigger)', async () => { + const result = await testTyphoonScenario( + TyphoonScenario.EventNoLandfallYet, + countryCodeISO3, + accessToken, + ); + expect(result).toBeTruthy(); + }); + + it('after landfall (trigger)', async () => { + const result = await testTyphoonScenario( + TyphoonScenario.EventAfterLandfall, + countryCodeISO3, + accessToken, + ); + expect(result).toBeTruthy(); + }); }); diff --git a/tests/integration/email/typhoon/test-typhoon-scenario.helper.ts b/tests/integration/email/typhoon/test-typhoon-scenario.helper.ts index 6e5a39b82..0b6d7e19b 100644 --- a/tests/integration/email/typhoon/test-typhoon-scenario.helper.ts +++ b/tests/integration/email/typhoon/test-typhoon-scenario.helper.ts @@ -38,7 +38,8 @@ export async function testTyphoonScenario( expect(response.body.finishedEvents).toBeFalsy(); // Parse the HTML content - const dom = new JSDOM(response.body.activeEvents.email); + const emailContent = response.body.activeEvents.email; + const dom = new JSDOM(emailContent); const document = dom.window.document; // Get all span elements with data-testid="event-name" and their lower case text content @@ -56,5 +57,23 @@ export async function testTyphoonScenario( expect(hasEvent).toBe(true); } + if (scenario === TyphoonScenario.EventTrigger) { + expect(emailContent).toContain('Expected to make landfall on'); + } else if (scenario === TyphoonScenario.EventNoTrigger) { + expect(emailContent).toContain( + 'Not predicted to reach trigger thresholds.', + ); + } else if (scenario === TyphoonScenario.EventNoLandfall) { + expect(emailContent).toContain( + 'Expected to reach the point closest to land on', + ); + } else if (scenario === TyphoonScenario.EventNoLandfallYet) { + expect(emailContent).toContain( + 'The landfall time prediction cannot be determined yet', + ); + } else if (scenario === TyphoonScenario.EventAfterLandfall) { + expect(emailContent).toContain('Has already made landfall'); + } + return true; }