feat(alerting): AlertController in-app inbox with ack/read/bulk-read (Task 33)

- 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) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-19 21:28:55 +02:00
parent c1b34f592b
commit 841793d7b9
5 changed files with 389 additions and 0 deletions

View File

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

View File

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

View File

@@ -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<UUID> instanceIds) {
public BulkReadRequest {
instanceIds = instanceIds == null ? List.of() : List.copyOf(instanceIds);
}
}

View File

@@ -0,0 +1,3 @@
package com.cameleer.server.app.alerting.dto;
public record UnreadCountResponse(long count) {}

View File

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