feat(alerts): Postgres repo — read_at/deleted_at columns, filter params, new mutations

- save/rowMapper read+write read_at and deleted_at
- listForInbox: tri-state acked/read filters; always excludes deleted
- countUnreadBySeverity: rewire without alert_reads join, preserve zero-fill
- new: markRead/bulkMarkRead/softDelete/bulkSoftDelete/bulkAck/restore
- delete PostgresAlertReadRepository + its bean
- restore zero-fill Javadoc on interface
- mechanical compile-fixes in AlertController, InAppInboxQuery,
  AlertControllerIT, InAppInboxQueryTest; Task 6 owns the rewrite
- PostgresAlertReadRepositoryIT stubbed @Disabled; Task 7 owns migration

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-21 17:56:06 +02:00
parent 55b2a00458
commit da2819332c
10 changed files with 227 additions and 214 deletions

View File

@@ -3,7 +3,10 @@ package com.cameleer.server.app.alerting.config;
import com.cameleer.server.app.alerting.eval.PerKindCircuitBreaker; import com.cameleer.server.app.alerting.eval.PerKindCircuitBreaker;
import com.cameleer.server.app.alerting.metrics.AlertingMetrics; import com.cameleer.server.app.alerting.metrics.AlertingMetrics;
import com.cameleer.server.app.alerting.storage.*; import com.cameleer.server.app.alerting.storage.*;
import com.cameleer.server.core.alerting.*; import com.cameleer.server.core.alerting.AlertInstanceRepository;
import com.cameleer.server.core.alerting.AlertNotificationRepository;
import com.cameleer.server.core.alerting.AlertRuleRepository;
import com.cameleer.server.core.alerting.AlertSilenceRepository;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
@@ -41,11 +44,6 @@ public class AlertingBeanConfig {
return new PostgresAlertNotificationRepository(jdbc, om); return new PostgresAlertNotificationRepository(jdbc, om);
} }
@Bean
public AlertReadRepository alertReadRepository(JdbcTemplate jdbc) {
return new PostgresAlertReadRepository(jdbc);
}
@Bean @Bean
public Clock alertingClock() { public Clock alertingClock() {
return Clock.systemDefaultZone(); return Clock.systemDefaultZone();

View File

@@ -7,7 +7,6 @@ import com.cameleer.server.app.alerting.notify.InAppInboxQuery;
import com.cameleer.server.app.web.EnvPath; import com.cameleer.server.app.web.EnvPath;
import com.cameleer.server.core.alerting.AlertInstance; import com.cameleer.server.core.alerting.AlertInstance;
import com.cameleer.server.core.alerting.AlertInstanceRepository; 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.AlertSeverity;
import com.cameleer.server.core.alerting.AlertState; import com.cameleer.server.core.alerting.AlertState;
import com.cameleer.server.core.runtime.Environment; import com.cameleer.server.core.runtime.Environment;
@@ -43,14 +42,11 @@ public class AlertController {
private final InAppInboxQuery inboxQuery; private final InAppInboxQuery inboxQuery;
private final AlertInstanceRepository instanceRepo; private final AlertInstanceRepository instanceRepo;
private final AlertReadRepository readRepo;
public AlertController(InAppInboxQuery inboxQuery, public AlertController(InAppInboxQuery inboxQuery,
AlertInstanceRepository instanceRepo, AlertInstanceRepository instanceRepo) {
AlertReadRepository readRepo) {
this.inboxQuery = inboxQuery; this.inboxQuery = inboxQuery;
this.instanceRepo = instanceRepo; this.instanceRepo = instanceRepo;
this.readRepo = readRepo;
} }
@GetMapping @GetMapping
@@ -89,14 +85,12 @@ public class AlertController {
@PostMapping("/{id}/read") @PostMapping("/{id}/read")
public void read(@EnvPath Environment env, @PathVariable UUID id) { public void read(@EnvPath Environment env, @PathVariable UUID id) {
requireInstance(id, env.id()); requireInstance(id, env.id());
String userId = currentUserId(); instanceRepo.markRead(id, Instant.now());
readRepo.markRead(userId, id);
} }
@PostMapping("/bulk-read") @PostMapping("/bulk-read")
public void bulkRead(@EnvPath Environment env, public void bulkRead(@EnvPath Environment env,
@Valid @RequestBody BulkReadRequest req) { @Valid @RequestBody BulkReadRequest req) {
String userId = currentUserId();
// filter to only instances in this env // filter to only instances in this env
List<UUID> filtered = req.instanceIds().stream() List<UUID> filtered = req.instanceIds().stream()
.filter(instanceId -> instanceRepo.findById(instanceId) .filter(instanceId -> instanceRepo.findById(instanceId)
@@ -104,7 +98,7 @@ public class AlertController {
.orElse(false)) .orElse(false))
.toList(); .toList();
if (!filtered.isEmpty()) { if (!filtered.isEmpty()) {
readRepo.bulkMarkRead(userId, filtered); instanceRepo.bulkMarkRead(filtered, Instant.now());
} }
} }

View File

@@ -70,7 +70,7 @@ public class InAppInboxQuery {
int limit) { int limit) {
List<String> groupIds = resolveGroupIds(userId); List<String> groupIds = resolveGroupIds(userId);
List<String> roleNames = resolveRoleNames(userId); List<String> roleNames = resolveRoleNames(userId);
return instanceRepo.listForInbox(envId, groupIds, userId, roleNames, states, severities, limit); return instanceRepo.listForInbox(envId, groupIds, userId, roleNames, states, severities, null, null, limit);
} }
/** /**
@@ -85,7 +85,9 @@ public class InAppInboxQuery {
if (cached != null && now.isBefore(cached.expiresAt())) { if (cached != null && now.isBefore(cached.expiresAt())) {
return cached.response(); return cached.response();
} }
Map<AlertSeverity, Long> bySeverity = instanceRepo.countUnreadBySeverityForUser(envId, userId); List<String> groupIds = resolveGroupIds(userId);
List<String> roleNames = resolveRoleNames(userId);
Map<AlertSeverity, Long> bySeverity = instanceRepo.countUnreadBySeverity(envId, userId, groupIds, roleNames);
UnreadCountResponse response = UnreadCountResponse.from(bySeverity); UnreadCountResponse response = UnreadCountResponse.from(bySeverity);
memo.put(key, new Entry(response, now.plusMillis(MEMO_TTL_MS))); memo.put(key, new Entry(response, now.plusMillis(MEMO_TTL_MS)));
return response; return response;

View File

@@ -34,10 +34,12 @@ public class PostgresAlertInstanceRepository implements AlertInstanceRepository
INSERT INTO alert_instances ( INSERT INTO alert_instances (
id, rule_id, rule_snapshot, environment_id, state, severity, id, rule_id, rule_snapshot, environment_id, state, severity,
fired_at, acked_at, acked_by, resolved_at, last_notified_at, fired_at, acked_at, acked_by, resolved_at, last_notified_at,
read_at, deleted_at,
silenced, current_value, threshold, context, title, message, silenced, current_value, threshold, context, title, message,
target_user_ids, target_group_ids, target_role_names) target_user_ids, target_group_ids, target_role_names)
VALUES (?, ?, ?::jsonb, ?, ?::alert_state_enum, ?::severity_enum, VALUES (?, ?, ?::jsonb, ?, ?::alert_state_enum, ?::severity_enum,
?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
?, ?,
?, ?, ?, ?::jsonb, ?, ?, ?, ?, ?, ?::jsonb, ?, ?,
?, ?, ?) ?, ?, ?)
ON CONFLICT (id) DO UPDATE SET ON CONFLICT (id) DO UPDATE SET
@@ -46,6 +48,8 @@ public class PostgresAlertInstanceRepository implements AlertInstanceRepository
acked_by = EXCLUDED.acked_by, acked_by = EXCLUDED.acked_by,
resolved_at = EXCLUDED.resolved_at, resolved_at = EXCLUDED.resolved_at,
last_notified_at = EXCLUDED.last_notified_at, last_notified_at = EXCLUDED.last_notified_at,
read_at = EXCLUDED.read_at,
deleted_at = EXCLUDED.deleted_at,
silenced = EXCLUDED.silenced, silenced = EXCLUDED.silenced,
current_value = EXCLUDED.current_value, current_value = EXCLUDED.current_value,
threshold = EXCLUDED.threshold, threshold = EXCLUDED.threshold,
@@ -66,6 +70,7 @@ public class PostgresAlertInstanceRepository implements AlertInstanceRepository
i.environmentId(), i.state().name(), i.severity().name(), i.environmentId(), i.state().name(), i.severity().name(),
ts(i.firedAt()), ts(i.ackedAt()), i.ackedBy(), ts(i.firedAt()), ts(i.ackedAt()), i.ackedBy(),
ts(i.resolvedAt()), ts(i.lastNotifiedAt()), ts(i.resolvedAt()), ts(i.lastNotifiedAt()),
ts(i.readAt()), ts(i.deletedAt()),
i.silenced(), i.currentValue(), i.threshold(), i.silenced(), i.currentValue(), i.threshold(),
writeJson(i.context()), i.title(), i.message(), writeJson(i.context()), i.title(), i.message(),
userIds, groupIds, roleNames); userIds, groupIds, roleNames);
@@ -101,8 +106,9 @@ public class PostgresAlertInstanceRepository implements AlertInstanceRepository
List<String> userRoleNames, List<String> userRoleNames,
List<AlertState> states, List<AlertState> states,
List<AlertSeverity> severities, List<AlertSeverity> severities,
Boolean acked,
Boolean read,
int limit) { int limit) {
// Build arrays for group UUIDs and role names
Array groupArray = toUuidArrayFromStrings(userGroupIdFilter); Array groupArray = toUuidArrayFromStrings(userGroupIdFilter);
Array roleArray = toTextArray(userRoleNames); Array roleArray = toTextArray(userRoleNames);
@@ -127,7 +133,13 @@ public class PostgresAlertInstanceRepository implements AlertInstanceRepository
sql.append(" AND severity::text = ANY(?)"); sql.append(" AND severity::text = ANY(?)");
args.add(severityArray); args.add(severityArray);
} }
if (acked != null) {
sql.append(acked ? " AND acked_at IS NOT NULL" : " AND acked_at IS NULL");
}
if (read != null) {
sql.append(read ? " AND read_at IS NOT NULL" : " AND read_at IS NULL");
}
sql.append(" AND deleted_at IS NULL");
sql.append(" ORDER BY fired_at DESC LIMIT ?"); sql.append(" ORDER BY fired_at DESC LIMIT ?");
args.add(limit); args.add(limit);
@@ -135,23 +147,30 @@ public class PostgresAlertInstanceRepository implements AlertInstanceRepository
} }
@Override @Override
public Map<AlertSeverity, Long> countUnreadBySeverityForUser(UUID environmentId, String userId) { public Map<AlertSeverity, Long> countUnreadBySeverity(UUID environmentId,
String userId,
List<String> groupIds,
List<String> roleNames) {
Array groupArray = toUuidArrayFromStrings(groupIds);
Array roleArray = toTextArray(roleNames);
String sql = """ String sql = """
SELECT ai.severity::text AS severity, COUNT(*) AS cnt SELECT severity::text AS severity, COUNT(*) AS cnt
FROM alert_instances ai FROM alert_instances
WHERE ai.environment_id = ? WHERE environment_id = ?
AND ? = ANY(ai.target_user_ids) AND read_at IS NULL
AND NOT EXISTS ( AND deleted_at IS NULL
SELECT 1 FROM alert_reads ar AND (
WHERE ar.user_id = ? AND ar.alert_instance_id = ai.id ? = ANY(target_user_ids)
OR target_group_ids && ?
OR target_role_names && ?
) )
GROUP BY ai.severity GROUP BY severity
"""; """;
EnumMap<AlertSeverity, Long> counts = new EnumMap<>(AlertSeverity.class); EnumMap<AlertSeverity, Long> counts = new EnumMap<>(AlertSeverity.class);
for (AlertSeverity s : AlertSeverity.values()) counts.put(s, 0L); for (AlertSeverity s : AlertSeverity.values()) counts.put(s, 0L);
jdbc.query(sql, rs -> { jdbc.query(sql, (org.springframework.jdbc.core.RowCallbackHandler) rs -> counts.put(
counts.put(AlertSeverity.valueOf(rs.getString("severity")), rs.getLong("cnt")); AlertSeverity.valueOf(rs.getString("severity")), rs.getLong("cnt")
}, environmentId, userId, userId); ), environmentId, userId, groupArray, roleArray);
return counts; return counts;
} }
@@ -164,6 +183,56 @@ public class PostgresAlertInstanceRepository implements AlertInstanceRepository
""", Timestamp.from(when), userId, id); """, Timestamp.from(when), userId, id);
} }
@Override
public void markRead(UUID id, Instant when) {
jdbc.update("UPDATE alert_instances SET read_at = ? WHERE id = ? AND read_at IS NULL",
Timestamp.from(when), id);
}
@Override
public void bulkMarkRead(List<UUID> ids, Instant when) {
if (ids == null || ids.isEmpty()) return;
Array idArray = jdbc.execute((ConnectionCallback<Array>) c ->
c.createArrayOf("uuid", ids.toArray()));
jdbc.update("""
UPDATE alert_instances SET read_at = ?
WHERE id = ANY(?) AND read_at IS NULL
""", Timestamp.from(when), idArray);
}
@Override
public void softDelete(UUID id, Instant when) {
jdbc.update("UPDATE alert_instances SET deleted_at = ? WHERE id = ? AND deleted_at IS NULL",
Timestamp.from(when), id);
}
@Override
public void bulkSoftDelete(List<UUID> ids, Instant when) {
if (ids == null || ids.isEmpty()) return;
Array idArray = jdbc.execute((ConnectionCallback<Array>) c ->
c.createArrayOf("uuid", ids.toArray()));
jdbc.update("""
UPDATE alert_instances SET deleted_at = ?
WHERE id = ANY(?) AND deleted_at IS NULL
""", Timestamp.from(when), idArray);
}
@Override
public void restore(UUID id) {
jdbc.update("UPDATE alert_instances SET deleted_at = NULL WHERE id = ?", id);
}
@Override
public void bulkAck(List<UUID> ids, String userId, Instant when) {
if (ids == null || ids.isEmpty()) return;
Array idArray = jdbc.execute((ConnectionCallback<Array>) c ->
c.createArrayOf("uuid", ids.toArray()));
jdbc.update("""
UPDATE alert_instances SET acked_at = ?, acked_by = ?
WHERE id = ANY(?) AND acked_at IS NULL AND deleted_at IS NULL
""", Timestamp.from(when), userId, idArray);
}
@Override @Override
public void resolve(UUID id, Instant when) { public void resolve(UUID id, Instant when) {
jdbc.update(""" jdbc.update("""
@@ -215,6 +284,8 @@ public class PostgresAlertInstanceRepository implements AlertInstanceRepository
Timestamp ackedAt = rs.getTimestamp("acked_at"); Timestamp ackedAt = rs.getTimestamp("acked_at");
Timestamp resolvedAt = rs.getTimestamp("resolved_at"); Timestamp resolvedAt = rs.getTimestamp("resolved_at");
Timestamp lastNotifiedAt = rs.getTimestamp("last_notified_at"); Timestamp lastNotifiedAt = rs.getTimestamp("last_notified_at");
Timestamp readAt = rs.getTimestamp("read_at");
Timestamp deletedAt = rs.getTimestamp("deleted_at");
Object cvObj = rs.getObject("current_value"); Object cvObj = rs.getObject("current_value");
Double currentValue = cvObj == null ? null : ((Number) cvObj).doubleValue(); Double currentValue = cvObj == null ? null : ((Number) cvObj).doubleValue();
@@ -235,8 +306,8 @@ public class PostgresAlertInstanceRepository implements AlertInstanceRepository
rs.getString("acked_by"), rs.getString("acked_by"),
resolvedAt == null ? null : resolvedAt.toInstant(), resolvedAt == null ? null : resolvedAt.toInstant(),
lastNotifiedAt == null ? null : lastNotifiedAt.toInstant(), lastNotifiedAt == null ? null : lastNotifiedAt.toInstant(),
null, readAt == null ? null : readAt.toInstant(),
null, deletedAt == null ? null : deletedAt.toInstant(),
rs.getBoolean("silenced"), rs.getBoolean("silenced"),
currentValue, currentValue,
threshold, threshold,

View File

@@ -1,35 +0,0 @@
package com.cameleer.server.app.alerting.storage;
import com.cameleer.server.core.alerting.AlertReadRepository;
import org.springframework.jdbc.core.JdbcTemplate;
import java.util.List;
import java.util.UUID;
public class PostgresAlertReadRepository implements AlertReadRepository {
private final JdbcTemplate jdbc;
public PostgresAlertReadRepository(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
@Override
public void markRead(String userId, UUID alertInstanceId) {
jdbc.update("""
INSERT INTO alert_reads (user_id, alert_instance_id)
VALUES (?, ?)
ON CONFLICT (user_id, alert_instance_id) DO NOTHING
""", userId, alertInstanceId);
}
@Override
public void bulkMarkRead(String userId, List<UUID> alertInstanceIds) {
if (alertInstanceIds == null || alertInstanceIds.isEmpty()) {
return;
}
for (UUID id : alertInstanceIds) {
markRead(userId, id);
}
}
}

View File

@@ -5,7 +5,6 @@ import com.cameleer.server.app.TestSecurityHelper;
import com.cameleer.server.app.search.ClickHouseLogStore; import com.cameleer.server.app.search.ClickHouseLogStore;
import com.cameleer.server.core.alerting.AlertInstance; import com.cameleer.server.core.alerting.AlertInstance;
import com.cameleer.server.core.alerting.AlertInstanceRepository; 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.AlertSeverity;
import com.cameleer.server.core.alerting.AlertState; import com.cameleer.server.core.alerting.AlertState;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
@@ -35,7 +34,6 @@ class AlertControllerIT extends AbstractPostgresIT {
@Autowired private ObjectMapper objectMapper; @Autowired private ObjectMapper objectMapper;
@Autowired private TestSecurityHelper securityHelper; @Autowired private TestSecurityHelper securityHelper;
@Autowired private AlertInstanceRepository instanceRepo; @Autowired private AlertInstanceRepository instanceRepo;
@Autowired private AlertReadRepository readRepo;
private String operatorJwt; private String operatorJwt;
private String viewerJwt; private String viewerJwt;

View File

@@ -22,6 +22,8 @@ import java.util.Map;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicLong;
import org.mockito.ArgumentMatchers;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.*; import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*; import static org.mockito.Mockito.*;
@@ -75,13 +77,13 @@ class InAppInboxQueryTest {
.thenReturn(List.of(new RoleSummary(roleId, "OPERATOR", true, "direct"))); .thenReturn(List.of(new RoleSummary(roleId, "OPERATOR", true, "direct")));
when(instanceRepo.listForInbox(eq(ENV_ID), eq(List.of(groupId.toString())), when(instanceRepo.listForInbox(eq(ENV_ID), eq(List.of(groupId.toString())),
eq(USER_ID), eq(List.of("OPERATOR")), isNull(), isNull(), eq(20))) eq(USER_ID), eq(List.of("OPERATOR")), isNull(), isNull(), isNull(), isNull(), eq(20)))
.thenReturn(List.of()); .thenReturn(List.of());
List<AlertInstance> result = query.listInbox(ENV_ID, USER_ID, 20); List<AlertInstance> result = query.listInbox(ENV_ID, USER_ID, 20);
assertThat(result).isEmpty(); assertThat(result).isEmpty();
verify(instanceRepo).listForInbox(ENV_ID, List.of(groupId.toString()), verify(instanceRepo).listForInbox(ENV_ID, List.of(groupId.toString()),
USER_ID, List.of("OPERATOR"), null, null, 20); USER_ID, List.of("OPERATOR"), null, null, null, null, 20);
} }
@Test @Test
@@ -94,12 +96,12 @@ class InAppInboxQueryTest {
List<AlertSeverity> severities = List.of(AlertSeverity.CRITICAL, AlertSeverity.WARNING); List<AlertSeverity> severities = List.of(AlertSeverity.CRITICAL, AlertSeverity.WARNING);
when(instanceRepo.listForInbox(eq(ENV_ID), eq(List.of()), eq(USER_ID), eq(List.of()), when(instanceRepo.listForInbox(eq(ENV_ID), eq(List.of()), eq(USER_ID), eq(List.of()),
eq(states), eq(severities), eq(25))) eq(states), eq(severities), isNull(), isNull(), eq(25)))
.thenReturn(List.of()); .thenReturn(List.of());
query.listInbox(ENV_ID, USER_ID, states, severities, 25); query.listInbox(ENV_ID, USER_ID, states, severities, 25);
verify(instanceRepo).listForInbox(ENV_ID, List.of(), USER_ID, List.of(), verify(instanceRepo).listForInbox(ENV_ID, List.of(), USER_ID, List.of(),
states, severities, 25); states, severities, null, null, 25);
} }
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
@@ -108,7 +110,8 @@ class InAppInboxQueryTest {
@Test @Test
void countUnread_totalIsSumOfBySeverityValues() { void countUnread_totalIsSumOfBySeverityValues() {
when(instanceRepo.countUnreadBySeverityForUser(ENV_ID, USER_ID)) when(instanceRepo.countUnreadBySeverity(eq(ENV_ID), eq(USER_ID),
ArgumentMatchers.<List<String>>any(), ArgumentMatchers.<List<String>>any()))
.thenReturn(severities(4L, 2L, 1L)); .thenReturn(severities(4L, 2L, 1L));
UnreadCountResponse response = query.countUnread(ENV_ID, USER_ID); UnreadCountResponse response = query.countUnread(ENV_ID, USER_ID);
@@ -123,7 +126,8 @@ class InAppInboxQueryTest {
@Test @Test
void countUnread_fillsMissingSeveritiesWithZero() { void countUnread_fillsMissingSeveritiesWithZero() {
// Repository returns only CRITICAL — WARNING/INFO must default to 0. // Repository returns only CRITICAL — WARNING/INFO must default to 0.
when(instanceRepo.countUnreadBySeverityForUser(ENV_ID, USER_ID)) when(instanceRepo.countUnreadBySeverity(eq(ENV_ID), eq(USER_ID),
ArgumentMatchers.<List<String>>any(), ArgumentMatchers.<List<String>>any()))
.thenReturn(Map.of(AlertSeverity.CRITICAL, 3L)); .thenReturn(Map.of(AlertSeverity.CRITICAL, 3L));
UnreadCountResponse response = query.countUnread(ENV_ID, USER_ID); UnreadCountResponse response = query.countUnread(ENV_ID, USER_ID);
@@ -141,7 +145,8 @@ class InAppInboxQueryTest {
@Test @Test
void countUnread_secondCallWithin5sUsesCache() { void countUnread_secondCallWithin5sUsesCache() {
when(instanceRepo.countUnreadBySeverityForUser(ENV_ID, USER_ID)) when(instanceRepo.countUnreadBySeverity(eq(ENV_ID), eq(USER_ID),
ArgumentMatchers.<List<String>>any(), ArgumentMatchers.<List<String>>any()))
.thenReturn(severities(1L, 2L, 2L)); .thenReturn(severities(1L, 2L, 2L));
UnreadCountResponse first = query.countUnread(ENV_ID, USER_ID); UnreadCountResponse first = query.countUnread(ENV_ID, USER_ID);
@@ -150,12 +155,14 @@ class InAppInboxQueryTest {
assertThat(first.total()).isEqualTo(5L); assertThat(first.total()).isEqualTo(5L);
assertThat(second.total()).isEqualTo(5L); assertThat(second.total()).isEqualTo(5L);
verify(instanceRepo, times(1)).countUnreadBySeverityForUser(ENV_ID, USER_ID); verify(instanceRepo, times(1)).countUnreadBySeverity(eq(ENV_ID), eq(USER_ID),
ArgumentMatchers.<List<String>>any(), ArgumentMatchers.<List<String>>any());
} }
@Test @Test
void countUnread_callAfter5sRefreshesCache() { void countUnread_callAfter5sRefreshesCache() {
when(instanceRepo.countUnreadBySeverityForUser(ENV_ID, USER_ID)) when(instanceRepo.countUnreadBySeverity(eq(ENV_ID), eq(USER_ID),
ArgumentMatchers.<List<String>>any(), ArgumentMatchers.<List<String>>any()))
.thenReturn(severities(1L, 1L, 1L)) // first call — total 3 .thenReturn(severities(1L, 1L, 1L)) // first call — total 3
.thenReturn(severities(4L, 3L, 2L)); // after TTL — total 9 .thenReturn(severities(4L, 3L, 2L)); // after TTL — total 9
@@ -165,29 +172,36 @@ class InAppInboxQueryTest {
assertThat(first.total()).isEqualTo(3L); assertThat(first.total()).isEqualTo(3L);
assertThat(third.total()).isEqualTo(9L); assertThat(third.total()).isEqualTo(9L);
verify(instanceRepo, times(2)).countUnreadBySeverityForUser(ENV_ID, USER_ID); verify(instanceRepo, times(2)).countUnreadBySeverity(eq(ENV_ID), eq(USER_ID),
ArgumentMatchers.<List<String>>any(), ArgumentMatchers.<List<String>>any());
} }
@Test @Test
void countUnread_differentUsersDontShareCache() { void countUnread_differentUsersDontShareCache() {
when(instanceRepo.countUnreadBySeverityForUser(ENV_ID, "alice")) when(instanceRepo.countUnreadBySeverity(eq(ENV_ID), eq("alice"),
ArgumentMatchers.<List<String>>any(), ArgumentMatchers.<List<String>>any()))
.thenReturn(severities(0L, 1L, 1L)); .thenReturn(severities(0L, 1L, 1L));
when(instanceRepo.countUnreadBySeverityForUser(ENV_ID, "bob")) when(instanceRepo.countUnreadBySeverity(eq(ENV_ID), eq("bob"),
ArgumentMatchers.<List<String>>any(), ArgumentMatchers.<List<String>>any()))
.thenReturn(severities(2L, 2L, 4L)); .thenReturn(severities(2L, 2L, 4L));
assertThat(query.countUnread(ENV_ID, "alice").total()).isEqualTo(2L); assertThat(query.countUnread(ENV_ID, "alice").total()).isEqualTo(2L);
assertThat(query.countUnread(ENV_ID, "bob").total()).isEqualTo(8L); assertThat(query.countUnread(ENV_ID, "bob").total()).isEqualTo(8L);
verify(instanceRepo).countUnreadBySeverityForUser(ENV_ID, "alice"); verify(instanceRepo).countUnreadBySeverity(eq(ENV_ID), eq("alice"),
verify(instanceRepo).countUnreadBySeverityForUser(ENV_ID, "bob"); ArgumentMatchers.<List<String>>any(), ArgumentMatchers.<List<String>>any());
verify(instanceRepo).countUnreadBySeverity(eq(ENV_ID), eq("bob"),
ArgumentMatchers.<List<String>>any(), ArgumentMatchers.<List<String>>any());
} }
@Test @Test
void countUnread_differentEnvsDontShareCache() { void countUnread_differentEnvsDontShareCache() {
UUID envA = UUID.randomUUID(); UUID envA = UUID.randomUUID();
UUID envB = UUID.randomUUID(); UUID envB = UUID.randomUUID();
when(instanceRepo.countUnreadBySeverityForUser(envA, USER_ID)) when(instanceRepo.countUnreadBySeverity(eq(envA), eq(USER_ID),
ArgumentMatchers.<List<String>>any(), ArgumentMatchers.<List<String>>any()))
.thenReturn(severities(0L, 0L, 1L)); .thenReturn(severities(0L, 0L, 1L));
when(instanceRepo.countUnreadBySeverityForUser(envB, USER_ID)) when(instanceRepo.countUnreadBySeverity(eq(envB), eq(USER_ID),
ArgumentMatchers.<List<String>>any(), ArgumentMatchers.<List<String>>any()))
.thenReturn(severities(1L, 1L, 2L)); .thenReturn(severities(1L, 1L, 2L));
assertThat(query.countUnread(envA, USER_ID).total()).isEqualTo(1L); assertThat(query.countUnread(envA, USER_ID).total()).isEqualTo(1L);

View File

@@ -50,7 +50,6 @@ class PostgresAlertInstanceRepositoryIT extends AbstractPostgresIT {
@AfterEach @AfterEach
void cleanup() { void cleanup() {
jdbcTemplate.update("DELETE FROM alert_reads WHERE user_id = ?", userId);
jdbcTemplate.update("DELETE FROM alert_notifications WHERE alert_instance_id IN " + jdbcTemplate.update("DELETE FROM alert_notifications WHERE alert_instance_id IN " +
"(SELECT id FROM alert_instances WHERE environment_id = ?)", envId); "(SELECT id FROM alert_instances WHERE environment_id = ?)", envId);
jdbcTemplate.update("DELETE FROM alert_instances WHERE environment_id = ?", envId); jdbcTemplate.update("DELETE FROM alert_instances WHERE environment_id = ?", envId);
@@ -92,7 +91,7 @@ class PostgresAlertInstanceRepositoryIT extends AbstractPostgresIT {
repo.save(byRole); repo.save(byRole);
// User is member of the group AND has the role // User is member of the group AND has the role
var inbox = repo.listForInbox(envId, List.of(groupId), userId, List.of(roleName), 50); var inbox = repo.listForInbox(envId, List.of(groupId), userId, List.of(roleName), null, null, null, null, 50);
assertThat(inbox).extracting(AlertInstance::id) assertThat(inbox).extracting(AlertInstance::id)
.containsExactlyInAnyOrder(byUser.id(), byGroup.id(), byRole.id()); .containsExactlyInAnyOrder(byUser.id(), byGroup.id(), byRole.id());
} }
@@ -102,33 +101,30 @@ class PostgresAlertInstanceRepositoryIT extends AbstractPostgresIT {
var byUser = newInstance(ruleId, List.of(userId), List.of(), List.of()); var byUser = newInstance(ruleId, List.of(userId), List.of(), List.of());
repo.save(byUser); repo.save(byUser);
var inbox = repo.listForInbox(envId, List.of(), userId, List.of(), 50); var inbox = repo.listForInbox(envId, List.of(), userId, List.of(), null, null, null, null, 50);
assertThat(inbox).hasSize(1); assertThat(inbox).hasSize(1);
assertThat(inbox.get(0).id()).isEqualTo(byUser.id()); assertThat(inbox.get(0).id()).isEqualTo(byUser.id());
} }
@Test @Test
void countUnreadBySeverityForUser_decreasesAfterMarkRead() { void countUnreadBySeverity_decreasesAfterMarkRead() {
var inst = newInstance(ruleId, List.of(userId), List.of(), List.of()); var inst = newInstance(ruleId, List.of(userId), List.of(), List.of());
repo.save(inst); repo.save(inst);
var before = repo.countUnreadBySeverityForUser(envId, userId); var before = repo.countUnreadBySeverity(envId, userId, List.of(), List.of());
assertThat(before) assertThat(before)
.containsEntry(AlertSeverity.WARNING, 1L) .containsEntry(AlertSeverity.WARNING, 1L)
.containsEntry(AlertSeverity.CRITICAL, 0L) .containsEntry(AlertSeverity.CRITICAL, 0L)
.containsEntry(AlertSeverity.INFO, 0L); .containsEntry(AlertSeverity.INFO, 0L);
// Insert read record directly (AlertReadRepository not yet wired in this test) repo.markRead(inst.id(), Instant.now());
jdbcTemplate.update(
"INSERT INTO alert_reads (user_id, alert_instance_id) VALUES (?, ?) ON CONFLICT DO NOTHING",
userId, inst.id());
var after = repo.countUnreadBySeverityForUser(envId, userId); var after = repo.countUnreadBySeverity(envId, userId, List.of(), List.of());
assertThat(after.values()).allMatch(v -> v == 0L); assertThat(after.values()).allMatch(v -> v == 0L);
} }
@Test @Test
void countUnreadBySeverityForUser_groupsBySeverity() { void countUnreadBySeverity_groupsBySeverity() {
// Each open instance needs its own rule to satisfy V13's unique partial index. // Each open instance needs its own rule to satisfy V13's unique partial index.
UUID critRule = seedRuleWithSeverity("crit", AlertSeverity.CRITICAL); UUID critRule = seedRuleWithSeverity("crit", AlertSeverity.CRITICAL);
UUID warnRule = seedRuleWithSeverity("warn", AlertSeverity.WARNING); UUID warnRule = seedRuleWithSeverity("warn", AlertSeverity.WARNING);
@@ -138,7 +134,7 @@ class PostgresAlertInstanceRepositoryIT extends AbstractPostgresIT {
repo.save(newInstance(warnRule, AlertSeverity.WARNING, List.of(userId), List.of(), List.of())); repo.save(newInstance(warnRule, AlertSeverity.WARNING, List.of(userId), List.of(), List.of()));
repo.save(newInstance(infoRule, AlertSeverity.INFO, List.of(userId), List.of(), List.of())); repo.save(newInstance(infoRule, AlertSeverity.INFO, List.of(userId), List.of(), List.of()));
var counts = repo.countUnreadBySeverityForUser(envId, userId); var counts = repo.countUnreadBySeverity(envId, userId, List.of(), List.of());
assertThat(counts) assertThat(counts)
.containsEntry(AlertSeverity.CRITICAL, 1L) .containsEntry(AlertSeverity.CRITICAL, 1L)
@@ -147,10 +143,10 @@ class PostgresAlertInstanceRepositoryIT extends AbstractPostgresIT {
} }
@Test @Test
void countUnreadBySeverityForUser_emptyMapStillHasAllKeys() { void countUnreadBySeverity_emptyMapStillHasAllKeys() {
// No instances saved — every severity must still be present with value 0 // No instances saved — every severity must still be present with value 0
// so callers never deal with null/missing keys. // so callers never deal with null/missing keys.
var counts = repo.countUnreadBySeverityForUser(envId, userId); var counts = repo.countUnreadBySeverity(envId, userId, List.of(), List.of());
assertThat(counts).hasSize(3); assertThat(counts).hasSize(3);
assertThat(counts.values()).allMatch(v -> v == 0L); assertThat(counts.values()).allMatch(v -> v == 0L);
} }
@@ -269,7 +265,7 @@ class PostgresAlertInstanceRepositoryIT extends AbstractPostgresIT {
Long count = jdbcTemplate.queryForObject( Long count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM alert_instances " + "SELECT COUNT(*) FROM alert_instances " +
" WHERE rule_id = ? AND state IN ('PENDING','FIRING','ACKNOWLEDGED')", " WHERE rule_id = ? AND state IN ('PENDING','FIRING')",
Long.class, ruleId); Long.class, ruleId);
assertThat(count).isEqualTo(3L); assertThat(count).isEqualTo(3L);
} }
@@ -293,7 +289,7 @@ class PostgresAlertInstanceRepositoryIT extends AbstractPostgresIT {
Long count = jdbcTemplate.queryForObject( Long count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM alert_instances " + "SELECT COUNT(*) FROM alert_instances " +
" WHERE rule_id = ? AND state IN ('PENDING','FIRING','ACKNOWLEDGED')", " WHERE rule_id = ? AND state IN ('PENDING','FIRING')",
Long.class, ruleId); Long.class, ruleId);
assertThat(count).isEqualTo(1L); assertThat(count).isEqualTo(1L);
} }
@@ -308,8 +304,83 @@ class PostgresAlertInstanceRepositoryIT extends AbstractPostgresIT {
assertThat(repo.findById(inst.id()).orElseThrow().silenced()).isTrue(); assertThat(repo.findById(inst.id()).orElseThrow().silenced()).isTrue();
} }
@Test
void markRead_is_idempotent_and_sets_read_at() {
var inst = insertFreshFiring();
repo.markRead(inst.id(), Instant.parse("2026-04-21T10:00:00Z"));
repo.markRead(inst.id(), Instant.parse("2026-04-21T11:00:00Z")); // idempotent — no-op
var loaded = repo.findById(inst.id()).orElseThrow();
assertThat(loaded.readAt()).isEqualTo(Instant.parse("2026-04-21T10:00:00Z"));
}
@Test
void softDelete_excludes_from_listForInbox() {
var inst = insertFreshFiring();
repo.softDelete(inst.id(), Instant.parse("2026-04-21T10:00:00Z"));
var rows = repo.listForInbox(envId, List.of(), userId, List.of(),
null, null, null, null, 100);
assertThat(rows).extracting(AlertInstance::id).doesNotContain(inst.id());
}
@Test
void findOpenForRule_returns_acked_firing() {
var inst = insertFreshFiring();
repo.ack(inst.id(), userId, Instant.parse("2026-04-21T10:00:00Z"));
var open = repo.findOpenForRule(inst.ruleId());
assertThat(open).isPresent(); // ack no longer closes the open slot — state stays FIRING
}
@Test
void findOpenForRule_skips_soft_deleted() {
var inst = insertFreshFiring();
repo.softDelete(inst.id(), Instant.now());
assertThat(repo.findOpenForRule(inst.ruleId())).isEmpty();
}
@Test
void bulk_ack_only_touches_unacked_rows() {
var a = insertFreshFiring();
var b = insertFreshFiring();
// ack 'a' first with userId; bulkAck should leave 'a' untouched (already acked)
repo.ack(a.id(), userId, Instant.parse("2026-04-21T09:00:00Z"));
repo.bulkAck(List.of(a.id(), b.id()), userId, Instant.parse("2026-04-21T10:00:00Z"));
// a was already acked — acked_at stays at the first timestamp, not updated again
assertThat(repo.findById(a.id()).orElseThrow().ackedBy()).isEqualTo(userId);
assertThat(repo.findById(b.id()).orElseThrow().ackedBy()).isEqualTo(userId);
}
@Test
void listForInbox_acked_false_hides_acked_rows() {
var a = insertFreshFiring();
var b = insertFreshFiring();
repo.ack(a.id(), userId, Instant.now());
var rows = repo.listForInbox(envId, List.of(), userId, List.of(),
null, null, /*acked*/ false, null, 100);
assertThat(rows).extracting(AlertInstance::id).doesNotContain(a.id());
assertThat(rows).extracting(AlertInstance::id).contains(b.id());
}
@Test
void restore_clears_deleted_at() {
var inst = insertFreshFiring();
repo.softDelete(inst.id(), Instant.now());
repo.restore(inst.id());
var loaded = repo.findById(inst.id()).orElseThrow();
assertThat(loaded.deletedAt()).isNull();
var rows = repo.listForInbox(envId, List.of(), userId, List.of(),
null, null, null, null, 100);
assertThat(rows).extracting(AlertInstance::id).contains(inst.id());
}
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
/** Creates and saves a fresh FIRING instance targeted at the test userId with its own rule. */
private AlertInstance insertFreshFiring() {
UUID freshRuleId = seedRule("fresh-rule");
var inst = newInstance(freshRuleId, List.of(userId), List.of(), List.of());
return repo.save(inst);
}
private AlertInstance newInstance(UUID ruleId, private AlertInstance newInstance(UUID ruleId,
List<String> userIds, List<String> userIds,
List<UUID> groupIds, List<UUID> groupIds,

View File

@@ -1,120 +1,18 @@
package com.cameleer.server.app.alerting.storage; package com.cameleer.server.app.alerting.storage;
import com.cameleer.server.app.AbstractPostgresIT; import org.junit.jupiter.api.Disabled;
import com.cameleer.server.app.search.ClickHouseLogStore;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.boot.test.mock.mockito.MockBean;
import java.util.List; /**
import java.util.UUID; * Placeholder — PostgresAlertReadRepository was deleted in Task 5.
* Task 7 will rewrite this test class to cover the new read/delete mutation methods
import static org.assertj.core.api.Assertions.assertThat; * on {@link com.cameleer.server.app.alerting.storage.PostgresAlertInstanceRepository}.
import static org.assertj.core.api.Assertions.assertThatCode; */
@Disabled("Task 7: rewrite after AlertReadRepository removal")
class PostgresAlertReadRepositoryIT extends AbstractPostgresIT { class PostgresAlertReadRepositoryIT {
@MockBean(name = "clickHouseLogStore") ClickHouseLogStore clickHouseLogStore;
private PostgresAlertReadRepository repo;
private UUID envId;
private UUID instanceId1;
private UUID instanceId2;
private UUID instanceId3;
private final String userId = "read-user-" + UUID.randomUUID();
@BeforeEach
void setup() {
repo = new PostgresAlertReadRepository(jdbcTemplate);
envId = UUID.randomUUID();
instanceId1 = UUID.randomUUID();
instanceId2 = UUID.randomUUID();
instanceId3 = UUID.randomUUID();
jdbcTemplate.update(
"INSERT INTO environments (id, slug, display_name) VALUES (?, ?, ?)",
envId, "test-env-" + UUID.randomUUID(), "Test Env");
jdbcTemplate.update(
"INSERT INTO users (user_id, provider, email) VALUES ('sys-user', 'local', 'sys@example.com') ON CONFLICT (user_id) DO NOTHING");
jdbcTemplate.update(
"INSERT INTO users (user_id, provider, email) VALUES (?, 'local', ?) ON CONFLICT (user_id) DO NOTHING",
userId, userId + "@example.com");
// Each open alert_instance needs its own rule_id — the alert_instances_open_rule_uq
// partial unique forbids multiple open instances sharing the same rule_id + exchange
// discriminator (V13/V15). Three separate rules let all three instances coexist
// in FIRING state so alert_reads tests can target each one independently.
for (UUID instanceId : List.of(instanceId1, instanceId2, instanceId3)) {
UUID ruleId = UUID.randomUUID();
jdbcTemplate.update(
"INSERT INTO alert_rules (id, environment_id, name, severity, condition_kind, condition, " +
"notification_title_tmpl, notification_message_tmpl, created_by, updated_by) " +
"VALUES (?, ?, ?, 'WARNING', 'AGENT_STATE', '{}'::jsonb, 't', 'm', 'sys-user', 'sys-user')",
ruleId, envId, "rule-" + instanceId);
jdbcTemplate.update(
"INSERT INTO alert_instances (id, rule_id, rule_snapshot, environment_id, state, severity, " +
"fired_at, context, title, message) VALUES (?, ?, '{}'::jsonb, ?, 'FIRING', 'WARNING', " +
"now(), '{}'::jsonb, 'title', 'msg')",
instanceId, ruleId, envId);
}
}
@AfterEach
void cleanup() {
jdbcTemplate.update("DELETE FROM alert_reads WHERE user_id = ?", userId);
jdbcTemplate.update("DELETE FROM alert_instances WHERE environment_id = ?", envId);
jdbcTemplate.update("DELETE FROM alert_rules WHERE environment_id = ?", envId);
jdbcTemplate.update("DELETE FROM environments WHERE id = ?", envId);
jdbcTemplate.update("DELETE FROM users WHERE user_id = ?", userId);
}
@Test @Test
void markRead_insertsReadRecord() { void placeholder() {
repo.markRead(userId, instanceId1); // replaced in Task 7
int count = jdbcTemplate.queryForObject(
"SELECT count(*) FROM alert_reads WHERE user_id = ? AND alert_instance_id = ?",
Integer.class, userId, instanceId1);
assertThat(count).isEqualTo(1);
}
@Test
void markRead_isIdempotent() {
repo.markRead(userId, instanceId1);
// second call should not throw
assertThatCode(() -> repo.markRead(userId, instanceId1)).doesNotThrowAnyException();
int count = jdbcTemplate.queryForObject(
"SELECT count(*) FROM alert_reads WHERE user_id = ? AND alert_instance_id = ?",
Integer.class, userId, instanceId1);
assertThat(count).isEqualTo(1);
}
@Test
void bulkMarkRead_marksMultiple() {
repo.bulkMarkRead(userId, List.of(instanceId1, instanceId2, instanceId3));
int count = jdbcTemplate.queryForObject(
"SELECT count(*) FROM alert_reads WHERE user_id = ?",
Integer.class, userId);
assertThat(count).isEqualTo(3);
}
@Test
void bulkMarkRead_emptyListDoesNotThrow() {
assertThatCode(() -> repo.bulkMarkRead(userId, List.of())).doesNotThrowAnyException();
}
@Test
void bulkMarkRead_isIdempotent() {
repo.bulkMarkRead(userId, List.of(instanceId1, instanceId2));
assertThatCode(() -> repo.bulkMarkRead(userId, List.of(instanceId1, instanceId2)))
.doesNotThrowAnyException();
int count = jdbcTemplate.queryForObject(
"SELECT count(*) FROM alert_reads WHERE user_id = ?",
Integer.class, userId);
assertThat(count).isEqualTo(2);
} }
} }

View File

@@ -43,6 +43,8 @@ public interface AlertInstanceRepository {
* Count unread alert instances visible to the user, grouped by severity. * Count unread alert instances visible to the user, grouped by severity.
* Visibility: targets user directly, or via one of the given groups/roles. * Visibility: targets user directly, or via one of the given groups/roles.
* "Unread" = {@code read_at IS NULL AND deleted_at IS NULL}. * "Unread" = {@code read_at IS NULL AND deleted_at IS NULL}.
* Always returns a map with an entry for every {@link AlertSeverity} (value 0 if no rows),
* so callers never need null-checks.
*/ */
Map<AlertSeverity, Long> countUnreadBySeverity(UUID environmentId, Map<AlertSeverity, Long> countUnreadBySeverity(UUID environmentId,
String userId, String userId,