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:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package com.cameleer.server.app.alerting.dto;
|
||||
|
||||
public record UnreadCountResponse(long count) {}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user