From 408826e4214796e1be611c676a38fa8571c9f6cd Mon Sep 17 00:00:00 2001 From: khalil-bot Date: Mon, 11 May 2026 15:58:06 +0200 Subject: [PATCH] feat(notification): Spring Boot Teams notification service (#19) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MessageCard webhook payload (Office 365 Connector format) - Periodic polling of /sensors/latest with configurable interval - CO₂ level thresholds aligned with UI config - Alert threshold configurable via alert-from-level property - TEAMS_WEBHOOK_URL and AIR_QUALITY_API_URL via env vars Closes #19 --- notification_service/.gitignore | 6 ++ notification_service/pom.xml | 64 +++++++++++++ .../NotificationServiceApplication.java | 14 +++ .../config/NotificationProperties.java | 59 ++++++++++++ .../notification/config/RestClientConfig.java | 14 +++ .../hesso/pi/notification/model/Co2Level.java | 24 +++++ .../pi/notification/model/SensorReading.java | 13 +++ .../pi/notification/model/teams/Fact.java | 3 + .../notification/model/teams/MessageCard.java | 30 ++++++ .../pi/notification/model/teams/Section.java | 14 +++ .../scheduler/AirQualityScheduler.java | 59 ++++++++++++ .../service/AirQualityService.java | 70 ++++++++++++++ .../service/TeamsNotificationService.java | 92 +++++++++++++++++++ .../src/main/resources/application.yml | 17 ++++ .../NotificationServiceApplicationTests.java | 17 ++++ 15 files changed, 496 insertions(+) create mode 100644 notification_service/.gitignore create mode 100644 notification_service/pom.xml create mode 100644 notification_service/src/main/java/ch/hesso/pi/notification/NotificationServiceApplication.java create mode 100644 notification_service/src/main/java/ch/hesso/pi/notification/config/NotificationProperties.java create mode 100644 notification_service/src/main/java/ch/hesso/pi/notification/config/RestClientConfig.java create mode 100644 notification_service/src/main/java/ch/hesso/pi/notification/model/Co2Level.java create mode 100644 notification_service/src/main/java/ch/hesso/pi/notification/model/SensorReading.java create mode 100644 notification_service/src/main/java/ch/hesso/pi/notification/model/teams/Fact.java create mode 100644 notification_service/src/main/java/ch/hesso/pi/notification/model/teams/MessageCard.java create mode 100644 notification_service/src/main/java/ch/hesso/pi/notification/model/teams/Section.java create mode 100644 notification_service/src/main/java/ch/hesso/pi/notification/scheduler/AirQualityScheduler.java create mode 100644 notification_service/src/main/java/ch/hesso/pi/notification/service/AirQualityService.java create mode 100644 notification_service/src/main/java/ch/hesso/pi/notification/service/TeamsNotificationService.java create mode 100644 notification_service/src/main/resources/application.yml create mode 100644 notification_service/src/test/java/ch/hesso/pi/notification/NotificationServiceApplicationTests.java diff --git a/notification_service/.gitignore b/notification_service/.gitignore new file mode 100644 index 0000000..838f4a5 --- /dev/null +++ b/notification_service/.gitignore @@ -0,0 +1,6 @@ +target/ +.mvn/wrapper/maven-wrapper.jar +!.mvn/wrapper/maven-wrapper.properties +.idea/ +*.iml +.DS_Store diff --git a/notification_service/pom.xml b/notification_service/pom.xml new file mode 100644 index 0000000..566fbdd --- /dev/null +++ b/notification_service/pom.xml @@ -0,0 +1,64 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.3.5 + + + + ch.hesso.pi + notification-service + 0.0.1-SNAPSHOT + notification-service + Air quality alert notifications via Microsoft Teams + + + 21 + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-scheduling + + + + org.projectlombok + lombok + true + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + + + diff --git a/notification_service/src/main/java/ch/hesso/pi/notification/NotificationServiceApplication.java b/notification_service/src/main/java/ch/hesso/pi/notification/NotificationServiceApplication.java new file mode 100644 index 0000000..2409ccc --- /dev/null +++ b/notification_service/src/main/java/ch/hesso/pi/notification/NotificationServiceApplication.java @@ -0,0 +1,14 @@ +package ch.hesso.pi.notification; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableScheduling; + +@SpringBootApplication +@EnableScheduling +public class NotificationServiceApplication { + + public static void main(String[] args) { + SpringApplication.run(NotificationServiceApplication.class, args); + } +} diff --git a/notification_service/src/main/java/ch/hesso/pi/notification/config/NotificationProperties.java b/notification_service/src/main/java/ch/hesso/pi/notification/config/NotificationProperties.java new file mode 100644 index 0000000..ca17635 --- /dev/null +++ b/notification_service/src/main/java/ch/hesso/pi/notification/config/NotificationProperties.java @@ -0,0 +1,59 @@ +package ch.hesso.pi.notification.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@Configuration +@ConfigurationProperties(prefix = "notification") +public class NotificationProperties { + + private Teams teams = new Teams(); + private AirQuality airQuality = new AirQuality(); + + public Teams getTeams() { return teams; } + public void setTeams(Teams teams) { this.teams = teams; } + + public AirQuality getAirQuality() { return airQuality; } + public void setAirQuality(AirQuality airQuality) { this.airQuality = airQuality; } + + public static class Teams { + private String webhookUrl; + + public String getWebhookUrl() { return webhookUrl; } + public void setWebhookUrl(String webhookUrl) { this.webhookUrl = webhookUrl; } + } + + public static class AirQuality { + private String apiUrl = "http://localhost:8080"; + private long pollIntervalMs = 60_000; + private Thresholds thresholds = new Thresholds(); + private String alertFromLevel = "poor"; + + public String getApiUrl() { return apiUrl; } + public void setApiUrl(String apiUrl) { this.apiUrl = apiUrl; } + + public long getPollIntervalMs() { return pollIntervalMs; } + public void setPollIntervalMs(long pollIntervalMs) { this.pollIntervalMs = pollIntervalMs; } + + public Thresholds getThresholds() { return thresholds; } + public void setThresholds(Thresholds thresholds) { this.thresholds = thresholds; } + + public String getAlertFromLevel() { return alertFromLevel; } + public void setAlertFromLevel(String alertFromLevel) { this.alertFromLevel = alertFromLevel; } + } + + public static class Thresholds { + private int moderate = 1000; + private int poor = 1400; + private int critical = 2000; + + public int getModerate() { return moderate; } + public void setModerate(int moderate) { this.moderate = moderate; } + + public int getPoor() { return poor; } + public void setPoor(int poor) { this.poor = poor; } + + public int getCritical() { return critical; } + public void setCritical(int critical) { this.critical = critical; } + } +} diff --git a/notification_service/src/main/java/ch/hesso/pi/notification/config/RestClientConfig.java b/notification_service/src/main/java/ch/hesso/pi/notification/config/RestClientConfig.java new file mode 100644 index 0000000..1e18e68 --- /dev/null +++ b/notification_service/src/main/java/ch/hesso/pi/notification/config/RestClientConfig.java @@ -0,0 +1,14 @@ +package ch.hesso.pi.notification.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestClient; + +@Configuration +public class RestClientConfig { + + @Bean + public RestClient restClient() { + return RestClient.builder().build(); + } +} diff --git a/notification_service/src/main/java/ch/hesso/pi/notification/model/Co2Level.java b/notification_service/src/main/java/ch/hesso/pi/notification/model/Co2Level.java new file mode 100644 index 0000000..9946859 --- /dev/null +++ b/notification_service/src/main/java/ch/hesso/pi/notification/model/Co2Level.java @@ -0,0 +1,24 @@ +package ch.hesso.pi.notification.model; + +public enum Co2Level { + + EXCELLENT("✅ Excellent", "#4caf50"), + GOOD ("🟢 Good", "#8bc34a"), + MODERATE ("🟡 Moderate", "#ffc107"), + POOR ("🟠 Poor", "#ff9800"), + CRITICAL ("🔴 Critical", "#f44336"); + + private final String label; + private final String themeColor; + + Co2Level(String label, String themeColor) { + this.label = label; + this.themeColor = themeColor; + } + + public String getLabel() { return label; } + public String getThemeColor() { return themeColor; } + + /** Returns the hex color without the leading '#' (Teams themeColor format). */ + public String getThemeColorHex() { return themeColor.substring(1); } +} diff --git a/notification_service/src/main/java/ch/hesso/pi/notification/model/SensorReading.java b/notification_service/src/main/java/ch/hesso/pi/notification/model/SensorReading.java new file mode 100644 index 0000000..b035de3 --- /dev/null +++ b/notification_service/src/main/java/ch/hesso/pi/notification/model/SensorReading.java @@ -0,0 +1,13 @@ +package ch.hesso.pi.notification.model; + +import java.time.Instant; + +public record SensorReading( + String roomId, + String roomName, + int co2, + double temperature, + int humidity, + String windowState, + Instant timestamp +) {} diff --git a/notification_service/src/main/java/ch/hesso/pi/notification/model/teams/Fact.java b/notification_service/src/main/java/ch/hesso/pi/notification/model/teams/Fact.java new file mode 100644 index 0000000..b59c3eb --- /dev/null +++ b/notification_service/src/main/java/ch/hesso/pi/notification/model/teams/Fact.java @@ -0,0 +1,3 @@ +package ch.hesso.pi.notification.model.teams; + +public record Fact(String name, String value) {} diff --git a/notification_service/src/main/java/ch/hesso/pi/notification/model/teams/MessageCard.java b/notification_service/src/main/java/ch/hesso/pi/notification/model/teams/MessageCard.java new file mode 100644 index 0000000..9fe8ec9 --- /dev/null +++ b/notification_service/src/main/java/ch/hesso/pi/notification/model/teams/MessageCard.java @@ -0,0 +1,30 @@ +package ch.hesso.pi.notification.model.teams; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; + +/** + * Office 365 Connector Card (MessageCard) payload for Teams incoming webhooks. + * Spec: https://learn.microsoft.com/en-us/outlook/actionable-messages/message-card-reference + * + * Adaptive Cards require a Bot Framework registration; this format works with + * any incoming webhook URL without extra infrastructure. + */ +public record MessageCard( + + @JsonProperty("@type") + String type, + + @JsonProperty("@context") + String context, + + String themeColor, + String summary, + String title, + String text, + List
sections +) { + public static final String TYPE = "MessageCard"; + public static final String CONTEXT = "https://schema.org/extensions"; +} diff --git a/notification_service/src/main/java/ch/hesso/pi/notification/model/teams/Section.java b/notification_service/src/main/java/ch/hesso/pi/notification/model/teams/Section.java new file mode 100644 index 0000000..f0d52a0 --- /dev/null +++ b/notification_service/src/main/java/ch/hesso/pi/notification/model/teams/Section.java @@ -0,0 +1,14 @@ +package ch.hesso.pi.notification.model.teams; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import java.util.List; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public record Section( + String activityTitle, + String activitySubtitle, + String activityImage, + List facts, + boolean markdown +) {} diff --git a/notification_service/src/main/java/ch/hesso/pi/notification/scheduler/AirQualityScheduler.java b/notification_service/src/main/java/ch/hesso/pi/notification/scheduler/AirQualityScheduler.java new file mode 100644 index 0000000..8ba9b42 --- /dev/null +++ b/notification_service/src/main/java/ch/hesso/pi/notification/scheduler/AirQualityScheduler.java @@ -0,0 +1,59 @@ +package ch.hesso.pi.notification.scheduler; + +import ch.hesso.pi.notification.config.NotificationProperties; +import ch.hesso.pi.notification.model.Co2Level; +import ch.hesso.pi.notification.model.SensorReading; +import ch.hesso.pi.notification.service.AirQualityService; +import ch.hesso.pi.notification.service.TeamsNotificationService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +public class AirQualityScheduler { + + private static final Logger log = LoggerFactory.getLogger(AirQualityScheduler.class); + + private final AirQualityService airQualityService; + private final TeamsNotificationService teamsService; + private final NotificationProperties props; + + public AirQualityScheduler(AirQualityService airQualityService, + TeamsNotificationService teamsService, + NotificationProperties props) { + this.airQualityService = airQualityService; + this.teamsService = teamsService; + this.props = props; + } + + /** + * Polls air quality data at the configured interval and sends a Teams alert + * for each room whose CO₂ level reaches or exceeds the configured threshold. + * + * The interval is read from the environment variable POLL_INTERVAL_MS + * (default: 60 000 ms). Spring's @Scheduled only supports fixed values at + * compile-time, so we use fixedDelayString with a SpEL expression instead. + */ + @Scheduled(fixedDelayString = "${notification.air-quality.poll-interval-ms:60000}", + initialDelayString = "5000") + public void checkAirQuality() { + log.debug("Polling air quality data…"); + + List readings = airQualityService.fetchLatestReadings(); + if (readings.isEmpty()) { + log.warn("No readings returned — skipping this cycle"); + return; + } + + for (SensorReading reading : readings) { + Co2Level level = airQualityService.resolveLevel(reading.co2()); + if (airQualityService.isAlertable(level)) { + log.info("Alertable reading: room={} co2={} level={}", reading.roomId(), reading.co2(), level); + teamsService.sendAlert(reading, level); + } + } + } +} diff --git a/notification_service/src/main/java/ch/hesso/pi/notification/service/AirQualityService.java b/notification_service/src/main/java/ch/hesso/pi/notification/service/AirQualityService.java new file mode 100644 index 0000000..8b56100 --- /dev/null +++ b/notification_service/src/main/java/ch/hesso/pi/notification/service/AirQualityService.java @@ -0,0 +1,70 @@ +package ch.hesso.pi.notification.service; + +import ch.hesso.pi.notification.config.NotificationProperties; +import ch.hesso.pi.notification.model.Co2Level; +import ch.hesso.pi.notification.model.SensorReading; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestClientException; + +import java.util.List; + +@Service +public class AirQualityService { + + private static final Logger log = LoggerFactory.getLogger(AirQualityService.class); + + private final RestClient restClient; + private final NotificationProperties props; + + public AirQualityService(RestClient restClient, NotificationProperties props) { + this.restClient = restClient; + this.props = props; + } + + /** + * Fetches the latest sensor reading for every room from the backend API. + * Returns an empty list on error so the scheduler can continue safely. + */ + public List fetchLatestReadings() { + String url = props.getAirQuality().getApiUrl() + "/sensors/latest"; + try { + List readings = restClient.get() + .uri(url) + .retrieve() + .body(new ParameterizedTypeReference<>() {}); + return readings != null ? readings : List.of(); + } catch (RestClientException e) { + log.error("Failed to fetch latest readings from {}: {}", url, e.getMessage()); + return List.of(); + } + } + + /** Resolves the CO₂ level enum from a raw ppm value using configured thresholds. */ + public Co2Level resolveLevel(int ppm) { + NotificationProperties.Thresholds t = props.getAirQuality().getThresholds(); + if (ppm < 800) return Co2Level.EXCELLENT; + if (ppm < t.getModerate()) return Co2Level.GOOD; + if (ppm < t.getPoor()) return Co2Level.MODERATE; + if (ppm < t.getCritical()) return Co2Level.POOR; + return Co2Level.CRITICAL; + } + + /** Returns true if the given level meets or exceeds the configured alert threshold. */ + public boolean isAlertable(Co2Level level) { + Co2Level floor = parseAlertFromLevel(props.getAirQuality().getAlertFromLevel()); + return level.ordinal() >= floor.ordinal(); + } + + private Co2Level parseAlertFromLevel(String value) { + try { + return Co2Level.valueOf(value.toUpperCase()); + } catch (IllegalArgumentException e) { + log.warn("Unknown alert-from-level '{}', defaulting to POOR", value); + return Co2Level.POOR; + } + } +} diff --git a/notification_service/src/main/java/ch/hesso/pi/notification/service/TeamsNotificationService.java b/notification_service/src/main/java/ch/hesso/pi/notification/service/TeamsNotificationService.java new file mode 100644 index 0000000..af0ebec --- /dev/null +++ b/notification_service/src/main/java/ch/hesso/pi/notification/service/TeamsNotificationService.java @@ -0,0 +1,92 @@ +package ch.hesso.pi.notification.service; + +import ch.hesso.pi.notification.config.NotificationProperties; +import ch.hesso.pi.notification.model.Co2Level; +import ch.hesso.pi.notification.model.SensorReading; +import ch.hesso.pi.notification.model.teams.Fact; +import ch.hesso.pi.notification.model.teams.MessageCard; +import ch.hesso.pi.notification.model.teams.Section; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestClientException; + +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.List; + +@Service +public class TeamsNotificationService { + + private static final Logger log = LoggerFactory.getLogger(TeamsNotificationService.class); + private static final DateTimeFormatter TIME_FMT = + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss").withZone(ZoneId.systemDefault()); + + private final RestClient restClient; + private final NotificationProperties props; + + public TeamsNotificationService(RestClient restClient, NotificationProperties props) { + this.restClient = restClient; + this.props = props; + } + + /** + * Builds and sends a MessageCard to the configured Teams webhook. + * Does nothing if the webhook URL is not configured. + */ + public void sendAlert(SensorReading reading, Co2Level level) { + String webhookUrl = props.getTeams().getWebhookUrl(); + if (!StringUtils.hasText(webhookUrl)) { + log.warn("Teams webhook URL not configured — skipping alert for room {}", reading.roomId()); + return; + } + + MessageCard card = buildCard(reading, level); + + try { + restClient.post() + .uri(webhookUrl) + .contentType(MediaType.APPLICATION_JSON) + .body(card) + .retrieve() + .toBodilessEntity(); + + log.info("Alert sent for room {} — {} ({} ppm)", reading.roomId(), level.getLabel(), reading.co2()); + } catch (RestClientException e) { + log.error("Failed to send Teams alert for room {}: {}", reading.roomId(), e.getMessage()); + } + } + + private MessageCard buildCard(SensorReading reading, Co2Level level) { + String timestamp = reading.timestamp() != null + ? TIME_FMT.format(reading.timestamp()) + : "—"; + + Section section = new Section( + level.getLabel() + " — " + reading.roomName(), + "Detected at " + timestamp, + null, + List.of( + new Fact("Room", reading.roomName()), + new Fact("CO₂", reading.co2() + " ppm"), + new Fact("Temperature", reading.temperature() + " °C"), + new Fact("Humidity", reading.humidity() + " %"), + new Fact("Windows", reading.windowState()) + ), + true + ); + + return new MessageCard( + MessageCard.TYPE, + MessageCard.CONTEXT, + level.getThemeColorHex(), + "Air quality alert: " + reading.roomName(), + "🌬️ Air Quality Alert", + null, + List.of(section) + ); + } +} diff --git a/notification_service/src/main/resources/application.yml b/notification_service/src/main/resources/application.yml new file mode 100644 index 0000000..ce26747 --- /dev/null +++ b/notification_service/src/main/resources/application.yml @@ -0,0 +1,17 @@ +spring: + application: + name: notification-service + +notification: + teams: + webhook-url: ${TEAMS_WEBHOOK_URL:} + air-quality: + api-url: ${AIR_QUALITY_API_URL:http://localhost:8080} + poll-interval-ms: ${POLL_INTERVAL_MS:60000} + # Levels matching the UI co2-levels.config.ts thresholds + thresholds: + moderate: 1000 + poor: 1400 + critical: 2000 + # Minimum level that triggers a notification (excellent / good / moderate / poor / critical) + alert-from-level: poor diff --git a/notification_service/src/test/java/ch/hesso/pi/notification/NotificationServiceApplicationTests.java b/notification_service/src/test/java/ch/hesso/pi/notification/NotificationServiceApplicationTests.java new file mode 100644 index 0000000..468ccb6 --- /dev/null +++ b/notification_service/src/test/java/ch/hesso/pi/notification/NotificationServiceApplicationTests.java @@ -0,0 +1,17 @@ +package ch.hesso.pi.notification; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.TestPropertySource; + +@SpringBootTest +@TestPropertySource(properties = { + "notification.teams.webhook-url=http://localhost:9999/test", + "notification.air-quality.api-url=http://localhost:9998" +}) +class NotificationServiceApplicationTests { + + @Test + void contextLoads() { + } +}