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:
khalil-bot
2026-05-11 15:58:06 +02:00
parent 0795ade6fb
commit 408826e421
15 changed files with 496 additions and 0 deletions

6
notification_service/.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
target/
.mvn/wrapper/maven-wrapper.jar
!.mvn/wrapper/maven-wrapper.properties
.idea/
*.iml
.DS_Store

View 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>

View File

@@ -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);
}
}

View File

@@ -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; }
}
}

View File

@@ -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();
}
}

View File

@@ -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); }
}

View File

@@ -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
) {}

View File

@@ -0,0 +1,3 @@
package ch.hesso.pi.notification.model.teams;
public record Fact(String name, String value) {}

View File

@@ -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";
}

View File

@@ -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
) {}

View File

@@ -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);
}
}
}
}

View File

@@ -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;
}
}
}

View File

@@ -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)
);
}
}

View 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

View File

@@ -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() {
}
}