feat(alerts): controller — DELETE/bulk-delete/bulk-ack/restore + acked/read filters + readAt on DTO

- GET /alerts gains tri-state acked + read query params
- new endpoints: DELETE /{id} (soft-delete), POST /bulk-delete, POST /bulk-ack, POST /{id}/restore
- requireLiveInstance 404s on soft-deleted rows; restore() reads the row regardless
- BulkReadRequest → BulkIdsRequest (shared body for bulk read/ack/delete)
- AlertDto gains readAt; deletedAt stays off the wire
- InAppInboxQuery.listInbox threads acked/read through to the repo (7-arg, no more null placeholders)
- SecurityConfig: new matchers for bulk-ack (VIEWER+), DELETE/bulk-delete/restore (OPERATOR+)
- AlertControllerIT: persistence assertions on /read + /bulk-read; full coverage for new endpoints
- InAppInboxQueryTest: updated to 7-arg listInbox signature

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-21 18:15:16 +02:00
parent dd2a5536ab
commit efd8396045
9 changed files with 361 additions and 59 deletions

View File

@@ -1,7 +1,7 @@
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.BulkIdsRequest;
import com.cameleer.server.app.alerting.dto.UnreadCountResponse;
import com.cameleer.server.app.alerting.notify.InAppInboxQuery;
import com.cameleer.server.app.web.EnvPath;
@@ -13,8 +13,10 @@ 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.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
@@ -30,7 +32,7 @@ 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.
* VIEWER+ can read their own inbox; OPERATOR+ can soft-delete and restore alerts.
*/
@RestController
@RequestMapping("/api/v1/environments/{envSlug}/alerts")
@@ -38,8 +40,6 @@ import java.util.UUID;
@PreAuthorize("hasAnyRole('VIEWER','OPERATOR','ADMIN')")
public class AlertController {
private static final int DEFAULT_LIMIT = 50;
private final InAppInboxQuery inboxQuery;
private final AlertInstanceRepository instanceRepo;
@@ -54,10 +54,12 @@ public class AlertController {
@EnvPath Environment env,
@RequestParam(defaultValue = "50") int limit,
@RequestParam(required = false) List<AlertState> state,
@RequestParam(required = false) List<AlertSeverity> severity) {
@RequestParam(required = false) List<AlertSeverity> severity,
@RequestParam(required = false) Boolean acked,
@RequestParam(required = false) Boolean read) {
String userId = currentUserId();
int effectiveLimit = Math.min(limit, 200);
return inboxQuery.listInbox(env.id(), userId, state, severity, effectiveLimit)
return inboxQuery.listInbox(env.id(), userId, state, severity, acked, read, effectiveLimit)
.stream().map(AlertDto::from).toList();
}
@@ -68,13 +70,13 @@ public class AlertController {
@GetMapping("/{id}")
public AlertDto get(@EnvPath Environment env, @PathVariable UUID id) {
AlertInstance instance = requireInstance(id, env.id());
AlertInstance instance = requireLiveInstance(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());
AlertInstance instance = requireLiveInstance(id, env.id());
String userId = currentUserId();
instanceRepo.ack(id, userId, Instant.now());
// Re-fetch to return fresh state
@@ -84,37 +86,76 @@ public class AlertController {
@PostMapping("/{id}/read")
public void read(@EnvPath Environment env, @PathVariable UUID id) {
requireInstance(id, env.id());
requireLiveInstance(id, env.id());
instanceRepo.markRead(id, Instant.now());
}
@PostMapping("/bulk-read")
public void bulkRead(@EnvPath Environment env,
@Valid @RequestBody BulkReadRequest req) {
// 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();
@Valid @RequestBody BulkIdsRequest req) {
List<UUID> filtered = inEnvLiveIds(req.instanceIds(), env.id());
if (!filtered.isEmpty()) {
instanceRepo.bulkMarkRead(filtered, Instant.now());
}
}
@PostMapping("/bulk-ack")
public void bulkAck(@EnvPath Environment env,
@Valid @RequestBody BulkIdsRequest req) {
List<UUID> filtered = inEnvLiveIds(req.instanceIds(), env.id());
if (!filtered.isEmpty()) {
instanceRepo.bulkAck(filtered, currentUserId(), Instant.now());
}
}
@DeleteMapping("/{id}")
@PreAuthorize("hasAnyRole('OPERATOR','ADMIN')")
public ResponseEntity<Void> delete(@EnvPath Environment env, @PathVariable UUID id) {
requireLiveInstance(id, env.id());
instanceRepo.softDelete(id, Instant.now());
return ResponseEntity.noContent().build();
}
@PostMapping("/bulk-delete")
@PreAuthorize("hasAnyRole('OPERATOR','ADMIN')")
public void bulkDelete(@EnvPath Environment env,
@Valid @RequestBody BulkIdsRequest req) {
List<UUID> filtered = inEnvLiveIds(req.instanceIds(), env.id());
if (!filtered.isEmpty()) {
instanceRepo.bulkSoftDelete(filtered, Instant.now());
}
}
@PostMapping("/{id}/restore")
@PreAuthorize("hasAnyRole('OPERATOR','ADMIN')")
public ResponseEntity<Void> restore(@EnvPath Environment env, @PathVariable UUID id) {
// Unlike requireLiveInstance, restore explicitly targets soft-deleted rows
AlertInstance inst = instanceRepo.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Alert not found"));
if (!inst.environmentId().equals(env.id()))
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Alert not found in env");
instanceRepo.restore(id);
return ResponseEntity.noContent().build();
}
// -------------------------------------------------------------------------
// 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 AlertInstance requireLiveInstance(UUID id, UUID envId) {
AlertInstance i = instanceRepo.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Alert not found"));
if (!i.environmentId().equals(envId) || i.deletedAt() != null)
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Alert not found in env");
return i;
}
private List<UUID> inEnvLiveIds(List<UUID> ids, UUID envId) {
return ids.stream()
.filter(id -> instanceRepo.findById(id)
.map(i -> i.environmentId().equals(envId) && i.deletedAt() == null)
.orElse(false))
.toList();
}
private String currentUserId() {

View File

@@ -20,6 +20,7 @@ public record AlertDto(
Instant ackedAt,
String ackedBy,
Instant resolvedAt,
Instant readAt, // global "has anyone read this"
boolean silenced,
Double currentValue,
Double threshold,
@@ -29,6 +30,7 @@ public record AlertDto(
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());
i.resolvedAt(), i.readAt(), i.silenced(),
i.currentValue(), i.threshold(), i.context());
}
}

View File

@@ -0,0 +1,10 @@
package com.cameleer.server.app.alerting.dto;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.util.List;
import java.util.UUID;
/** Shared body for bulk-read / bulk-ack / bulk-delete requests. */
public record BulkIdsRequest(@NotNull @Size(min = 1, max = 500) List<UUID> instanceIds) {}

View File

@@ -1,12 +0,0 @@
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

@@ -49,28 +49,22 @@ public class InAppInboxQuery {
}
/**
* Returns the most recent {@code limit} alert instances visible to the given user.
* <p>
* Visibility: the instance must target this user directly, or target a group the user belongs to,
* or target a role the user holds. Empty target lists mean "broadcast to all".
*/
public List<AlertInstance> listInbox(UUID envId, String userId, int limit) {
return listInbox(envId, userId, null, null, limit);
}
/**
* Filtered variant of {@link #listInbox(UUID, String, int)}: optional {@code states}
* and {@code severities} narrow the result set. {@code null} or empty lists mean
* "no filter on that dimension".
* Full filtered variant: optional {@code states}, {@code severities}, {@code acked},
* and {@code read} narrow the result set. {@code null} or empty lists mean
* "no filter on that dimension". {@code acked}/{@code read} are tri-state:
* {@code null} = no filter, {@code TRUE} = only acked/read, {@code FALSE} = only unacked/unread.
*/
public List<AlertInstance> listInbox(UUID envId,
String userId,
List<AlertState> states,
List<AlertSeverity> severities,
Boolean acked,
Boolean read,
int limit) {
List<String> groupIds = resolveGroupIds(userId);
List<String> roleNames = resolveRoleNames(userId);
return instanceRepo.listForInbox(envId, groupIds, userId, roleNames, states, severities, null, null, limit);
return instanceRepo.listForInbox(envId, groupIds, userId, roleNames,
states, severities, acked, read, limit);
}
/**

View File

@@ -171,10 +171,15 @@ public class SecurityConfig {
.requestMatchers(HttpMethod.POST, "/api/v1/environments/*/alerts/silences/**").hasAnyRole("OPERATOR", "ADMIN")
.requestMatchers(HttpMethod.PUT, "/api/v1/environments/*/alerts/silences/**").hasAnyRole("OPERATOR", "ADMIN")
.requestMatchers(HttpMethod.DELETE, "/api/v1/environments/*/alerts/silences/**").hasAnyRole("OPERATOR", "ADMIN")
// Alerting — ack/read (VIEWER+ self-service)
// Alerting — ack/read/bulk-ack (VIEWER+ self-service)
.requestMatchers(HttpMethod.POST, "/api/v1/environments/*/alerts/*/ack").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
.requestMatchers(HttpMethod.POST, "/api/v1/environments/*/alerts/*/read").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
.requestMatchers(HttpMethod.POST, "/api/v1/environments/*/alerts/bulk-read").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
.requestMatchers(HttpMethod.POST, "/api/v1/environments/*/alerts/bulk-ack").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
// Alerting — soft-delete / restore (OPERATOR+)
.requestMatchers(HttpMethod.DELETE, "/api/v1/environments/*/alerts/*").hasAnyRole("OPERATOR", "ADMIN")
.requestMatchers(HttpMethod.POST, "/api/v1/environments/*/alerts/bulk-delete").hasAnyRole("OPERATOR", "ADMIN")
.requestMatchers(HttpMethod.POST, "/api/v1/environments/*/alerts/*/restore").hasAnyRole("OPERATOR", "ADMIN")
// Alerting — notification retry (flat path; notification IDs globally unique)
.requestMatchers(HttpMethod.POST, "/api/v1/alerts/notifications/*/retry").hasAnyRole("OPERATOR", "ADMIN")