feat(notification): Spring Boot Teams notification service (#19)
- 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
This commit is contained in:
6
notification_service/.gitignore
vendored
Normal file
6
notification_service/.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
target/
|
||||
.mvn/wrapper/maven-wrapper.jar
|
||||
!.mvn/wrapper/maven-wrapper.properties
|
||||
.idea/
|
||||
*.iml
|
||||
.DS_Store
|
||||
64
notification_service/pom.xml
Normal file
64
notification_service/pom.xml
Normal file
@@ -0,0 +1,64 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-parent</artifactId>
|
||||
<version>3.3.5</version>
|
||||
<relativePath/>
|
||||
</parent>
|
||||
|
||||
<groupId>ch.hesso.pi</groupId>
|
||||
<artifactId>notification-service</artifactId>
|
||||
<version>0.0.1-SNAPSHOT</version>
|
||||
<name>notification-service</name>
|
||||
<description>Air quality alert notifications via Microsoft Teams</description>
|
||||
|
||||
<properties>
|
||||
<java.version>21</java.version>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-scheduling</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
<configuration>
|
||||
<excludes>
|
||||
<exclude>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
</exclude>
|
||||
</excludes>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
</project>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -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
|
||||
) {}
|
||||
@@ -0,0 +1,3 @@
|
||||
package ch.hesso.pi.notification.model.teams;
|
||||
|
||||
public record Fact(String name, String value) {}
|
||||
@@ -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<Section> sections
|
||||
) {
|
||||
public static final String TYPE = "MessageCard";
|
||||
public static final String CONTEXT = "https://schema.org/extensions";
|
||||
}
|
||||
@@ -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<Fact> facts,
|
||||
boolean markdown
|
||||
) {}
|
||||
@@ -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<SensorReading> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<SensorReading> fetchLatestReadings() {
|
||||
String url = props.getAirQuality().getApiUrl() + "/sensors/latest";
|
||||
try {
|
||||
List<SensorReading> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
17
notification_service/src/main/resources/application.yml
Normal file
17
notification_service/src/main/resources/application.yml
Normal file
@@ -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
|
||||
@@ -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() {
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user