From 841793d7b956e29597ca896747837a2f85402def Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sun, 19 Apr 2026 21:28:55 +0200 Subject: [PATCH] feat(alerting): AlertController in-app inbox with ack/read/bulk-read (Task 33) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /environments/{envSlug}/alerts — inbox filtered by userId/groupIds/roleNames via InAppInboxQuery - GET /unread-count — memoized unread count (5s TTL) - GET /{id}, POST /{id}/ack, POST /{id}/read, POST /bulk-read - bulkRead filters instanceIds to env before delegating to AlertReadRepository - VIEWER+ for all endpoints; env isolation enforced by requireInstance - 7 IT tests: list, env isolation, unread-count, ack flow, read, bulk-read, viewer access Co-Authored-By: Claude Opus 4.7 (1M context) --- .../alerting/controller/AlertController.java | 132 +++++++++++ .../server/app/alerting/dto/AlertDto.java | 34 +++ .../app/alerting/dto/BulkReadRequest.java | 12 + .../app/alerting/dto/UnreadCountResponse.java | 3 + .../controller/AlertControllerIT.java | 208 ++++++++++++++++++ 5 files changed, 389 insertions(+) create mode 100644 cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/controller/AlertController.java create mode 100644 cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/dto/AlertDto.java create mode 100644 cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/dto/BulkReadRequest.java create mode 100644 cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/dto/UnreadCountResponse.java create mode 100644 cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/controller/AlertControllerIT.java diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/controller/AlertController.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/controller/AlertController.java new file mode 100644 index 00000000..5ce9ea48 --- /dev/null +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/controller/AlertController.java @@ -0,0 +1,132 @@ +package com.cameleer.server.app.alerting.controller; + +import com.cameleer.server.app.alerting.dto.AlertDto; +import com.cameleer.server.app.alerting.dto.BulkReadRequest; +import com.cameleer.server.app.alerting.dto.UnreadCountResponse; +import com.cameleer.server.app.alerting.notify.InAppInboxQuery; +import com.cameleer.server.app.web.EnvPath; +import com.cameleer.server.core.alerting.AlertInstance; +import com.cameleer.server.core.alerting.AlertInstanceRepository; +import com.cameleer.server.core.alerting.AlertReadRepository; +import com.cameleer.server.core.runtime.Environment; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +/** + * REST controller for the in-app alert inbox (env-scoped). + * VIEWER+ can read their own inbox; OPERATOR+ can ack any alert. + */ +@RestController +@RequestMapping("/api/v1/environments/{envSlug}/alerts") +@Tag(name = "Alerts Inbox", description = "In-app alert inbox, ack and read tracking (env-scoped)") +@PreAuthorize("hasAnyRole('VIEWER','OPERATOR','ADMIN')") +public class AlertController { + + private static final int DEFAULT_LIMIT = 50; + + private final InAppInboxQuery inboxQuery; + private final AlertInstanceRepository instanceRepo; + private final AlertReadRepository readRepo; + + public AlertController(InAppInboxQuery inboxQuery, + AlertInstanceRepository instanceRepo, + AlertReadRepository readRepo) { + this.inboxQuery = inboxQuery; + this.instanceRepo = instanceRepo; + this.readRepo = readRepo; + } + + @GetMapping + public List list( + @EnvPath Environment env, + @RequestParam(defaultValue = "50") int limit) { + String userId = currentUserId(); + int effectiveLimit = Math.min(limit, 200); + return inboxQuery.listInbox(env.id(), userId, effectiveLimit) + .stream().map(AlertDto::from).toList(); + } + + @GetMapping("/unread-count") + public UnreadCountResponse unreadCount(@EnvPath Environment env) { + String userId = currentUserId(); + long count = inboxQuery.countUnread(env.id(), userId); + return new UnreadCountResponse(count); + } + + @GetMapping("/{id}") + public AlertDto get(@EnvPath Environment env, @PathVariable UUID id) { + AlertInstance instance = requireInstance(id, env.id()); + return AlertDto.from(instance); + } + + @PostMapping("/{id}/ack") + public AlertDto ack(@EnvPath Environment env, @PathVariable UUID id) { + AlertInstance instance = requireInstance(id, env.id()); + String userId = currentUserId(); + instanceRepo.ack(id, userId, Instant.now()); + // Re-fetch to return fresh state + return AlertDto.from(instanceRepo.findById(id) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND))); + } + + @PostMapping("/{id}/read") + public void read(@EnvPath Environment env, @PathVariable UUID id) { + requireInstance(id, env.id()); + String userId = currentUserId(); + readRepo.markRead(userId, id); + } + + @PostMapping("/bulk-read") + public void bulkRead(@EnvPath Environment env, + @Valid @RequestBody BulkReadRequest req) { + String userId = currentUserId(); + // filter to only instances in this env + List filtered = req.instanceIds().stream() + .filter(instanceId -> instanceRepo.findById(instanceId) + .map(i -> i.environmentId().equals(env.id())) + .orElse(false)) + .toList(); + if (!filtered.isEmpty()) { + readRepo.bulkMarkRead(userId, filtered); + } + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private AlertInstance requireInstance(UUID id, UUID envId) { + AlertInstance instance = instanceRepo.findById(id) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, + "Alert not found: " + id)); + if (!instance.environmentId().equals(envId)) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, + "Alert not found in this environment: " + id); + } + return instance; + } + + private String currentUserId() { + var auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth == null || auth.getName() == null) { + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "No authentication"); + } + String name = auth.getName(); + return name.startsWith("user:") ? name.substring(5) : name; + } +} diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/dto/AlertDto.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/dto/AlertDto.java new file mode 100644 index 00000000..1ddfb514 --- /dev/null +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/dto/AlertDto.java @@ -0,0 +1,34 @@ +package com.cameleer.server.app.alerting.dto; + +import com.cameleer.server.core.alerting.AlertInstance; +import com.cameleer.server.core.alerting.AlertSeverity; +import com.cameleer.server.core.alerting.AlertState; + +import java.time.Instant; +import java.util.Map; +import java.util.UUID; + +public record AlertDto( + UUID id, + UUID ruleId, + UUID environmentId, + AlertState state, + AlertSeverity severity, + String title, + String message, + Instant firedAt, + Instant ackedAt, + String ackedBy, + Instant resolvedAt, + boolean silenced, + Double currentValue, + Double threshold, + Map context +) { + public static AlertDto from(AlertInstance i) { + return new AlertDto( + i.id(), i.ruleId(), i.environmentId(), i.state(), i.severity(), + i.title(), i.message(), i.firedAt(), i.ackedAt(), i.ackedBy(), + i.resolvedAt(), i.silenced(), i.currentValue(), i.threshold(), i.context()); + } +} diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/dto/BulkReadRequest.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/dto/BulkReadRequest.java new file mode 100644 index 00000000..fa2dca1e --- /dev/null +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/dto/BulkReadRequest.java @@ -0,0 +1,12 @@ +package com.cameleer.server.app.alerting.dto; + +import jakarta.validation.constraints.NotNull; + +import java.util.List; +import java.util.UUID; + +public record BulkReadRequest(@NotNull List instanceIds) { + public BulkReadRequest { + instanceIds = instanceIds == null ? List.of() : List.copyOf(instanceIds); + } +} diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/dto/UnreadCountResponse.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/dto/UnreadCountResponse.java new file mode 100644 index 00000000..0efaf0c3 --- /dev/null +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/dto/UnreadCountResponse.java @@ -0,0 +1,3 @@ +package com.cameleer.server.app.alerting.dto; + +public record UnreadCountResponse(long count) {} diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/controller/AlertControllerIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/controller/AlertControllerIT.java new file mode 100644 index 00000000..72648e09 --- /dev/null +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/controller/AlertControllerIT.java @@ -0,0 +1,208 @@ +package com.cameleer.server.app.alerting.controller; + +import com.cameleer.server.app.AbstractPostgresIT; +import com.cameleer.server.app.TestSecurityHelper; +import com.cameleer.server.app.search.ClickHouseLogStore; +import com.cameleer.server.app.search.ClickHouseSearchIndex; +import com.cameleer.server.core.alerting.AlertInstance; +import com.cameleer.server.core.alerting.AlertInstanceRepository; +import com.cameleer.server.core.alerting.AlertReadRepository; +import com.cameleer.server.core.alerting.AlertSeverity; +import com.cameleer.server.core.alerting.AlertState; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +class AlertControllerIT extends AbstractPostgresIT { + + @MockBean(name = "clickHouseSearchIndex") ClickHouseSearchIndex clickHouseSearchIndex; + @MockBean(name = "clickHouseLogStore") ClickHouseLogStore clickHouseLogStore; + + @Autowired private TestRestTemplate restTemplate; + @Autowired private ObjectMapper objectMapper; + @Autowired private TestSecurityHelper securityHelper; + @Autowired private AlertInstanceRepository instanceRepo; + @Autowired private AlertReadRepository readRepo; + + private String operatorJwt; + private String viewerJwt; + private String envSlugA; + private String envSlugB; + private UUID envIdA; + private UUID envIdB; + + @BeforeEach + void setUp() { + operatorJwt = securityHelper.operatorToken(); + viewerJwt = securityHelper.viewerToken(); + seedUser("test-operator"); + seedUser("test-viewer"); + + envSlugA = "alert-env-a-" + UUID.randomUUID().toString().substring(0, 6); + envSlugB = "alert-env-b-" + UUID.randomUUID().toString().substring(0, 6); + envIdA = UUID.randomUUID(); + envIdB = UUID.randomUUID(); + jdbcTemplate.update( + "INSERT INTO environments (id, slug, display_name) VALUES (?, ?, ?) ON CONFLICT (id) DO NOTHING", + envIdA, envSlugA, envSlugA); + jdbcTemplate.update( + "INSERT INTO environments (id, slug, display_name) VALUES (?, ?, ?) ON CONFLICT (id) DO NOTHING", + envIdB, envSlugB, envSlugB); + } + + @AfterEach + void cleanUp() { + jdbcTemplate.update("DELETE FROM alert_notifications WHERE alert_instance_id IN (SELECT id FROM alert_instances WHERE environment_id IN (?, ?))", envIdA, envIdB); + jdbcTemplate.update("DELETE FROM alert_instances WHERE environment_id IN (?, ?)", envIdA, envIdB); + jdbcTemplate.update("DELETE FROM environments WHERE id IN (?, ?)", envIdA, envIdB); + jdbcTemplate.update("DELETE FROM users WHERE user_id IN ('test-operator','test-viewer')"); + } + + @Test + void listReturnsAlertsForEnv() throws Exception { + AlertInstance instance = seedInstance(envIdA); + + ResponseEntity resp = restTemplate.exchange( + "/api/v1/environments/" + envSlugA + "/alerts", + HttpMethod.GET, + new HttpEntity<>(securityHelper.authHeadersNoBody(operatorJwt)), + String.class); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK); + JsonNode body = objectMapper.readTree(resp.getBody()); + assertThat(body.isArray()).isTrue(); + // The alert we seeded should be present + boolean found = false; + for (JsonNode node : body) { + if (node.path("id").asText().equals(instance.id().toString())) { + found = true; + break; + } + } + assertThat(found).as("seeded alert must appear in env-A inbox").isTrue(); + } + + @Test + void envIsolation() throws Exception { + // Seed an alert in env-A + AlertInstance instanceA = seedInstance(envIdA); + + // env-B inbox should NOT see env-A's alert + ResponseEntity resp = restTemplate.exchange( + "/api/v1/environments/" + envSlugB + "/alerts", + HttpMethod.GET, + new HttpEntity<>(securityHelper.authHeadersNoBody(operatorJwt)), + String.class); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK); + JsonNode body = objectMapper.readTree(resp.getBody()); + for (JsonNode node : body) { + assertThat(node.path("id").asText()) + .as("env-A alert must not appear in env-B inbox") + .isNotEqualTo(instanceA.id().toString()); + } + } + + @Test + void unreadCountReturnsNumber() { + ResponseEntity resp = restTemplate.exchange( + "/api/v1/environments/" + envSlugA + "/alerts/unread-count", + HttpMethod.GET, + new HttpEntity<>(securityHelper.authHeadersNoBody(operatorJwt)), + String.class); + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK); + } + + @Test + void ackFlow() throws Exception { + AlertInstance instance = seedInstance(envIdA); + + ResponseEntity ack = restTemplate.exchange( + "/api/v1/environments/" + envSlugA + "/alerts/" + instance.id() + "/ack", + HttpMethod.POST, + new HttpEntity<>(securityHelper.authHeaders(operatorJwt)), + String.class); + + assertThat(ack.getStatusCode()).isEqualTo(HttpStatus.OK); + JsonNode body = objectMapper.readTree(ack.getBody()); + assertThat(body.path("state").asText()).isEqualTo("ACKNOWLEDGED"); + } + + @Test + void readMarksSingleAlert() throws Exception { + AlertInstance instance = seedInstance(envIdA); + + ResponseEntity read = restTemplate.exchange( + "/api/v1/environments/" + envSlugA + "/alerts/" + instance.id() + "/read", + HttpMethod.POST, + new HttpEntity<>(securityHelper.authHeaders(operatorJwt)), + String.class); + + assertThat(read.getStatusCode()).isEqualTo(HttpStatus.OK); + } + + @Test + void bulkRead() throws Exception { + AlertInstance i1 = seedInstance(envIdA); + AlertInstance i2 = seedInstance(envIdA); + + String body = """ + {"instanceIds":["%s","%s"]} + """.formatted(i1.id(), i2.id()); + + ResponseEntity resp = restTemplate.exchange( + "/api/v1/environments/" + envSlugA + "/alerts/bulk-read", + HttpMethod.POST, + new HttpEntity<>(body, securityHelper.authHeaders(operatorJwt)), + String.class); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK); + } + + @Test + void viewerCanRead() { + ResponseEntity resp = restTemplate.exchange( + "/api/v1/environments/" + envSlugA + "/alerts", + HttpMethod.GET, + new HttpEntity<>(securityHelper.authHeadersNoBody(viewerJwt)), + String.class); + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private AlertInstance seedInstance(UUID envId) { + // target by userId so the inbox SQL (? = ANY(target_user_ids)) matches the test-operator JWT + // (JWT subject is "user:test-operator", stripped to "test-operator" by currentUserId()) + AlertInstance instance = new AlertInstance( + UUID.randomUUID(), null, null, envId, + AlertState.FIRING, AlertSeverity.WARNING, + Instant.now(), null, null, null, null, false, + 42.0, 1000.0, null, "Test alert", "Something happened", + List.of("test-operator"), List.of(), List.of()); + return instanceRepo.save(instance); + } + + private void seedUser(String userId) { + jdbcTemplate.update( + "INSERT INTO users (user_id, provider, email, display_name) VALUES (?, 'test', ?, ?) ON CONFLICT (user_id) DO NOTHING", + userId, userId + "@example.com", userId); + } +}