diff --git a/build.gradle b/build.gradle index 6b516665..4a266645 100644 --- a/build.gradle +++ b/build.gradle @@ -59,6 +59,7 @@ dependencies { //email implementation 'org.springframework.boot:spring-boot-starter-mail' + implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' implementation 'junit:junit:4.13.2' implementation group: 'org.mapstruct', name: 'mapstruct', version: '1.5.2.Final' diff --git a/src/main/java/io/kontur/disasterninja/dto/Partner.java b/src/main/java/io/kontur/disasterninja/dto/Partner.java new file mode 100644 index 00000000..5b45b032 --- /dev/null +++ b/src/main/java/io/kontur/disasterninja/dto/Partner.java @@ -0,0 +1,14 @@ +package io.kontur.disasterninja.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; + +import java.util.Set; + +@Data +@AllArgsConstructor +public class Partner { + private String name; + private int totalLocations; + private Set locations; +} diff --git a/src/main/java/io/kontur/disasterninja/notifications/email/EmailMessageFormatter.java b/src/main/java/io/kontur/disasterninja/notifications/email/EmailMessageFormatter.java index 1955b603..ce794fc7 100644 --- a/src/main/java/io/kontur/disasterninja/notifications/email/EmailMessageFormatter.java +++ b/src/main/java/io/kontur/disasterninja/notifications/email/EmailMessageFormatter.java @@ -1,23 +1,25 @@ package io.kontur.disasterninja.notifications.email; import io.kontur.disasterninja.dto.EmailDto; +import io.kontur.disasterninja.dto.Partner; import io.kontur.disasterninja.dto.eventapi.EventApiEventDto; import io.kontur.disasterninja.dto.eventapi.FeedEpisode; import io.kontur.disasterninja.notifications.MessageFormatter; -import io.micrometer.core.instrument.util.IOUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Component; +import org.thymeleaf.TemplateEngine; +import org.thymeleaf.context.Context; import java.io.IOException; -import java.io.InputStream; -import java.nio.charset.StandardCharsets; import java.time.format.DateTimeFormatter; +import java.util.List; import java.util.Map; import static io.kontur.disasterninja.util.FormatUtil.formatNumber; +import static java.time.ZoneOffset.UTC; import static org.apache.commons.lang3.text.WordUtils.capitalizeFully; @Component @@ -26,7 +28,9 @@ public class EmailMessageFormatter extends MessageFormatter { private final static Logger LOG = LoggerFactory.getLogger(EmailMessageFormatter.class); - private final static DateTimeFormatter dateFormatter = DateTimeFormatter.ofPattern("MMMM d, yyyy"); + private final static DateTimeFormatter dateFormatter = DateTimeFormatter.ofPattern("MMMM d, yyyy HH:mm 'UTC'"); + + private final TemplateEngine templateEngine; @Value("${notifications.konturUrlPattern}") private String konturUrlPattern; @@ -34,53 +38,43 @@ public class EmailMessageFormatter extends MessageFormatter { @Value("${notifications.feed}") private String feed; - public EmailDto format(EventApiEventDto event, Map urbanPopulationProperties, - Map analytics) throws IOException { - String textTemplate = loadTemplate("notification/gg-email-template.txt"); - String htmlTemplate = loadTemplate("notification/gg-email-template.html"); + public EmailMessageFormatter(TemplateEngine templateEngine) { + this.templateEngine = templateEngine; + } + public EmailDto format(EventApiEventDto event, Map urbanPopulationProperties, + Map analytics, List partners) throws IOException { FeedEpisode lastEpisode = getLatestEpisode(event); - return new EmailDto( createSubject(event, lastEpisode), - replacePlaceholders(textTemplate, event, lastEpisode, urbanPopulationProperties), - replacePlaceholders(htmlTemplate, event, lastEpisode, urbanPopulationProperties)); - } - - private String loadTemplate(String templateName) throws IOException { - try (InputStream inputStream = getClass().getClassLoader().getResourceAsStream(templateName)) { - if (inputStream == null) { - throw new IllegalArgumentException("Failed to find notification template: " + templateName); - } - return IOUtils.toString(inputStream, StandardCharsets.UTF_8); - } + generateTemplate("gg-email-template.txt", event, lastEpisode, urbanPopulationProperties, partners), + generateTemplate("gg-email-template.html", event, lastEpisode, urbanPopulationProperties, partners)); } private String createSubject(EventApiEventDto event, FeedEpisode lastEpisode) { StringBuilder sb = new StringBuilder(); sb.append(getMessageColorCode(event, lastEpisode, true)); - sb.append(getEventStatus(event)); sb.append(event.getName()); return sb.toString(); } - private String replacePlaceholders(String template, EventApiEventDto event, FeedEpisode lastEpisode, Map urbanPopulationProperties) { - - return template - .replace("${name}", event.getName()) - .replace("${link}", String.format(konturUrlPattern, event.getEventId(), feed)) - .replace("${description}", lastEpisode.getDescription()) - .replace("${urbanPopulation}", formatNumber(urbanPopulationProperties.get("population"))) - .replace("${urbanArea}", formatNumber(urbanPopulationProperties.get("areaKm2"))) - .replace("${population}", formatNumber(lastEpisode.getEpisodeDetails().get("population"))) - .replace("${populatedArea}", formatNumber(lastEpisode.getEpisodeDetails().get("populatedAreaKm2"))) - .replace("${industrialArea}", formatNumber(lastEpisode.getEpisodeDetails().get("industrialAreaKm2"))) - .replace("${forestArea}", formatNumber(lastEpisode.getEpisodeDetails().get("forestAreaKm2"))) - .replace("${location}", event.getLocation()) - .replace("${type}", capitalizeFully(event.getType())) - .replace("${severity}", capitalizeFully(event.getSeverity().name())) - .replace("${startedAt}", event.getStartedAt().format(dateFormatter)) - .replace("${updatedAt}", event.getUpdatedAt().format(dateFormatter)); + public String generateTemplate(String template, EventApiEventDto event, FeedEpisode lastEpisode, Map urbanPopulationProperties, List partners) { + Context context = new Context(); + context.setVariable("link", String.format(konturUrlPattern, event.getEventId(), feed)); + context.setVariable("description", lastEpisode.getDescription()); + context.setVariable("urbanPopulation", formatNumber(urbanPopulationProperties.get("population"))); + context.setVariable("urbanArea", formatNumber(urbanPopulationProperties.get("areaKm2"))); + context.setVariable("population", formatNumber(lastEpisode.getEpisodeDetails().get("population"))); + context.setVariable("populatedArea", formatNumber(lastEpisode.getEpisodeDetails().get("populatedAreaKm2"))); + context.setVariable("industrialArea", formatNumber(lastEpisode.getEpisodeDetails().get("industrialAreaKm2"))); + context.setVariable("forestArea", formatNumber(lastEpisode.getEpisodeDetails().get("forestAreaKm2"))); + context.setVariable("type", capitalizeFully(event.getType())); + context.setVariable("severity", capitalizeFully(event.getSeverity().name())); + context.setVariable("location", event.getLocation()); + context.setVariable("startedAt", event.getStartedAt().withOffsetSameInstant(UTC).format(dateFormatter)); + context.setVariable("updatedAt", event.getUpdatedAt().withOffsetSameInstant(UTC).format(dateFormatter)); + context.setVariable("partners", partners); + + return templateEngine.process(template, context); } - } diff --git a/src/main/java/io/kontur/disasterninja/notifications/email/EmailNotificationService.java b/src/main/java/io/kontur/disasterninja/notifications/email/EmailNotificationService.java index 0c5e0fec..59b0d0f4 100644 --- a/src/main/java/io/kontur/disasterninja/notifications/email/EmailNotificationService.java +++ b/src/main/java/io/kontur/disasterninja/notifications/email/EmailNotificationService.java @@ -1,14 +1,26 @@ package io.kontur.disasterninja.notifications.email; import io.kontur.disasterninja.dto.EmailDto; +import io.kontur.disasterninja.dto.Partner; import io.kontur.disasterninja.dto.eventapi.EventApiEventDto; import io.kontur.disasterninja.notifications.NotificationService; +import io.kontur.disasterninja.service.GeometryTransformer; +import io.kontur.disasterninja.service.layers.LayersApiService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Component; +import org.springframework.util.CollectionUtils; +import org.wololo.geojson.Feature; +import org.wololo.geojson.FeatureCollection; +import org.wololo.geojson.Geometry; +import java.util.HashSet; +import java.util.List; import java.util.Map; +import java.util.UUID; +import java.util.stream.Collectors; @Component @ConditionalOnProperty(value = "notifications.enabled") @@ -18,10 +30,23 @@ public class EmailNotificationService extends NotificationService { private final EmailMessageFormatter emailMessageFormatter; private final EmailSender emailSender; + private final LayersApiService layersApiService; + private final GeometryTransformer geometryTransformer; - public EmailNotificationService(EmailMessageFormatter emailMessageFormatter, EmailSender emailSender) { + @Value("${notifications.relevantLocationsLayer}") + private String relevantLocationsLayer; + + @Value("${notifications.relevantLocationsLayerAppId}") + private String relevantLocationsLayerAppId; + + public EmailNotificationService(EmailMessageFormatter emailMessageFormatter, + EmailSender emailSender, + LayersApiService layersApiService, + GeometryTransformer geometryTransformer) { this.emailMessageFormatter = emailMessageFormatter; this.emailSender = emailSender; + this.layersApiService = layersApiService; + this.geometryTransformer = geometryTransformer; } @Override @@ -29,11 +54,38 @@ public void process(EventApiEventDto event, Map urbanPopulationP LOG.info("Found new event, sending email notification. Event ID = '{}', name = '{}'", event.getEventId(), event.getName()); try { - EmailDto emailDto = emailMessageFormatter.format(event, urbanPopulationProperties, analytics); + List partners = getPartners(getRelevantLocations(event.getGeometries())); + EmailDto emailDto = emailMessageFormatter.format(event, urbanPopulationProperties, analytics, partners); emailSender.send(emailDto); LOG.info("Successfully sent email notification. Event ID = '{}', name = '{}'", event.getEventId(), event.getName()); } catch (Exception e) { LOG.error("Failed to process email notification. Event ID = '{}', name = '{}'. {}", event.getEventId(), event.getName(), e.getMessage(), e); } } + + @Override + public boolean isApplicable(EventApiEventDto event) { + return event.getEventDetails() != null + && event.getEpisodes() != null + && event.getEpisodes().stream().noneMatch(episode -> episode.getEpisodeDetails() == null) + && !CollectionUtils.isEmpty(getRelevantLocations(event.getGeometries())); + } + + private List getPartners(List partnerLocations) { + Map> partnerLocationMap = partnerLocations.stream() + .collect(Collectors.groupingBy( + feature -> (String) feature.getProperties().get("partner"), + Collectors.mapping( + feature -> (String) feature.getProperties().get("location"), + Collectors.toList()))); + + return partnerLocationMap.entrySet().stream() + .map(entry -> new Partner(entry.getKey(), entry.getValue().size(), new HashSet<>(entry.getValue()))) + .collect(Collectors.toList()); + } + + private List getRelevantLocations(FeatureCollection fc) { + Geometry geometry = geometryTransformer.makeValid(geometryTransformer.getGeometryFromGeoJson(fc)); + return layersApiService.getFeatures(geometry, relevantLocationsLayer, UUID.fromString(relevantLocationsLayerAppId)); + } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 2494ce17..33a2a48d 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -106,6 +106,10 @@ spring: serialization: WRITE_DATES_AS_TIMESTAMPS: false default-property-inclusion: non_null + thymeleaf: + prefix: classpath:/notification/ + cache: false + encoding: UTF-8 graphql: apollo: diff --git a/src/main/resources/notification/gg-email-template.html b/src/main/resources/notification/gg-email-template.html index 0e5f1f2d..4b012a91 100644 --- a/src/main/resources/notification/gg-email-template.html +++ b/src/main/resources/notification/gg-email-template.html @@ -1,44 +1,113 @@ - + - ${name} + Event Notification + -
-

🌍 Event Overview:

-

View the event on globalgiving.kontur.io.

-

${description}

-
+
+
+

DISASTER ALERT

+

+ View Full Event Details +
-
-

👥 Impact Summary:

-
    -
  • Urban core: ${urbanPopulation} people on ${urbanArea} km².
  • -
  • Total population: ${population} people on ${populatedArea} km².
  • -
  • Industrial area: ${industrialArea} km².
  • -
  • Forest area: ${forestArea} km².
  • -
  • Location: ${location}
  • -
-
+
+

👥 Impact Summary

+
    +
  • + Urban core: people on km² +
  • +
  • + Total population: people on km² +
  • +
  • + Industrial area: km² +
  • +
  • + Forest area: km² +
  • +
+
-
-

🗓️ Event Status:

-
    -
  • Type: ${type}
  • -
  • Severity: ${severity}
  • -
  • Start Date: ${startedAt}
  • -
  • Latest Update: ${updatedAt}
  • -
-
+
+

🗓️ Event Status

+
    +
  • + Type: +
  • +
  • + Severity: +
  • +
  • + Location: +
  • +
  • + Start Date: +
  • +
  • + Latest Update: +
  • +
+
-
-

✉️ Need Help?

-

For any questions or issues, feel free to contact us at - hello@kontur.io. -

-
+
+

🏢 Affected Partner Locations

+
    +
  • + Partner has affected locations: +
      +
    • + +
    • +
    +
  • +
+
+ +
+
+

For any questions or issues, feel free to contact us at + hello@kontur.io. +

+
+
diff --git a/src/main/resources/notification/gg-email-template.txt b/src/main/resources/notification/gg-email-template.txt index 39a10cf7..def6b1df 100644 --- a/src/main/resources/notification/gg-email-template.txt +++ b/src/main/resources/notification/gg-email-template.txt @@ -1,19 +1,29 @@ Event Overview: -View the event on globalgiving.kontur.io: ${link} -${description} - +[(${description})] +View Full Event Details: [(${link})] +[# th:if="${urbanPopulation != null && urbanArea != null || population != null && populatedArea != null || industrialArea != null || forestArea != null}"] Impact Summary: -- Urban core: ${urbanPopulation} people on ${urbanArea} km². -- Total population: ${population} people on ${populatedArea} km². -- Industrial area: ${industrialArea} km². -- Forest area: ${forestArea} km². -- Location: ${location} - + [# th:if="${urbanPopulation != null && urbanArea != null}"]- Urban core: [(${urbanPopulation})] people on [(${urbanArea})] km².[/] + [# th:if="${population != null && populatedArea != null}"]- Total population: [(${population})] people on [(${populatedArea})] km².[/] + [# th:if="${industrialArea != null}"]- Industrial area: [(${industrialArea})] km².[/] + [# th:if="${forestArea != null}"]- Forest area: [(${forestArea})] km².[/] +[/] +[# th:if="${type != null || severity != null || startedAt != null || updatedAt != null || location != null}"] Event Status: -- Type: ${type} -- Severity: ${severity} -- Start Date: ${startedAt} -- Latest Update: ${updatedAt} - + [# th:if="${type != null}"]- Type: [(${type})].[/] + [# th:if="${severity != null}"]- Severity: [(${severity})].[/] + [# th:if="${location != null}"]- Location: [(${location})].[/] + [# th:if="${startedAt != null}"]- Start Date: [(${startedAt})].[/] + [# th:if="${updatedAt != null}"]- Latest Update: [(${updatedAt})].[/] +[/] +[# th:if="${partners != null && #lists.size(partners) > 0}"] +Affected Partner Locations: + [# th:each="partner : ${partners}"] + - Partner [(${partner.name})] has [(${partner.totalLocations})] affected location[# th:if="${partner.totalLocations > 1}"]s[/]: + [# th:each="partnerLocation, iterStat : ${partner.locations}"] + [# th:if="${iterStat.index < 10}"]- [(${location})][/] + [/] + [/] +[/] Need Help? For any questions or issues, feel free to contact us at hello@kontur.io. \ No newline at end of file