feat(alerting): per-severity breakdown on unread-count DTO
Spec §13 calls for the notification bell to colour-code by highest
unread severity (CRITICAL → error, WARNING → amber, INFO → muted).
The old { count } DTO forced the UI to pick one static colour, so
NotificationBell shipped with a TODO. Grow the contract instead:
UnreadCountResponse = { total, bySeverity: { CRITICAL, WARNING, INFO } }
Guarantees:
- every severity is always present with a >=0 value (no undefined
keys on the wire), so the UI can branch without defaults.
- total = sum of bySeverity values — kept explicit on the wire for
cheap top-line display, not recomputed client-side.
Backend
- AlertInstanceRepository: replaces countUnreadForUser(long) with
countUnreadBySeverityForUser returning Map<AlertSeverity, Long>.
One SQL round-trip per (env, user) — GROUP BY ai.severity over the
same NOT EXISTS(alert_reads) filter.
- UnreadCountResponse.from(Map) normalises and defensively copies;
missing severities default to 0.
- InAppInboxQuery.countUnread now returns the DTO, caches the full
response (still 5s TTL) so severity breakdown gets the same
hit-rate as the total did before.
- AlertController just hands the DTO back.
Breaking change — no backwards-compat shim: the `count` field is
gone. UI and tests updated in the same commit; there are no other
API consumers in the tree.
Frontend
- Regenerated openapi.json + schema.d.ts against a fresh build of
the new backend.
- NotificationBell branches badge colour on the highest unread
severity (CRITICAL > WARNING > INFO) via new CSS variants.
- Tests cover all four paths: zero, critical-present, warning-only,
info-only.
Tests: 7 unit tests + 12 ITs (incl. new grouping + empty-map)
+ 49 vitest (was 46; +3 severity-branch assertions).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -63,9 +63,7 @@ public class AlertController {
|
||||
|
||||
@GetMapping("/unread-count")
|
||||
public UnreadCountResponse unreadCount(@EnvPath Environment env) {
|
||||
String userId = currentUserId();
|
||||
long count = inboxQuery.countUnread(env.id(), userId);
|
||||
return new UnreadCountResponse(count);
|
||||
return inboxQuery.countUnread(env.id(), currentUserId());
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
|
||||
@@ -1,3 +1,29 @@
|
||||
package com.cameleer.server.app.alerting.dto;
|
||||
|
||||
public record UnreadCountResponse(long count) {}
|
||||
import com.cameleer.server.core.alerting.AlertSeverity;
|
||||
|
||||
import java.util.EnumMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Response shape for {@code GET /alerts/unread-count}.
|
||||
* <p>
|
||||
* {@code total} is the sum of {@code bySeverity} values. The UI branches bell colour on
|
||||
* the highest severity present, so callers can inspect the map directly.
|
||||
*/
|
||||
public record UnreadCountResponse(long total, Map<AlertSeverity, Long> bySeverity) {
|
||||
|
||||
public UnreadCountResponse {
|
||||
// Defensive copy + fill in missing severities as 0 so the UI never sees null/undefined.
|
||||
EnumMap<AlertSeverity, Long> normalized = new EnumMap<>(AlertSeverity.class);
|
||||
for (AlertSeverity s : AlertSeverity.values()) normalized.put(s, 0L);
|
||||
if (bySeverity != null) bySeverity.forEach((k, v) -> normalized.put(k, v == null ? 0L : v));
|
||||
bySeverity = Map.copyOf(normalized);
|
||||
}
|
||||
|
||||
public static UnreadCountResponse from(Map<AlertSeverity, Long> counts) {
|
||||
long total = counts == null ? 0L
|
||||
: counts.values().stream().filter(v -> v != null).mapToLong(Long::longValue).sum();
|
||||
return new UnreadCountResponse(total, counts == null ? Map.of() : counts);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package com.cameleer.server.app.alerting.notify;
|
||||
|
||||
import com.cameleer.server.app.alerting.dto.UnreadCountResponse;
|
||||
import com.cameleer.server.core.alerting.AlertInstance;
|
||||
import com.cameleer.server.core.alerting.AlertInstanceRepository;
|
||||
import com.cameleer.server.core.alerting.AlertSeverity;
|
||||
import com.cameleer.server.core.rbac.RbacService;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@@ -17,7 +19,8 @@ import java.util.concurrent.ConcurrentHashMap;
|
||||
* <p>
|
||||
* {@link #listInbox} returns alerts the user is allowed to see (targeted directly or via group/role).
|
||||
* {@link #countUnread} is memoized per {@code (envId, userId)} for 5 seconds to avoid hammering
|
||||
* the database on every page render.
|
||||
* the database on every page render. The memo caches the full per-severity breakdown so
|
||||
* the UI can branch bell colour on the highest unread severity without a second call.
|
||||
*/
|
||||
@Component
|
||||
public class InAppInboxQuery {
|
||||
@@ -31,8 +34,8 @@ public class InAppInboxQuery {
|
||||
/** Cache key for the unread count memo. */
|
||||
private record Key(UUID envId, String userId) {}
|
||||
|
||||
/** Cache entry: cached count + expiry timestamp. */
|
||||
private record Entry(long count, Instant expiresAt) {}
|
||||
/** Cache entry: cached response + expiry timestamp. */
|
||||
private record Entry(UnreadCountResponse response, Instant expiresAt) {}
|
||||
|
||||
private final ConcurrentHashMap<Key, Entry> memo = new ConcurrentHashMap<>();
|
||||
|
||||
@@ -57,20 +60,21 @@ public class InAppInboxQuery {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the count of unread (un-acked) alert instances visible to the user.
|
||||
* Returns the unread (un-acked) alert count for the user, broken down by severity.
|
||||
* <p>
|
||||
* The result is memoized for 5 seconds per {@code (envId, userId)}.
|
||||
* Memoized for 5 seconds per {@code (envId, userId)}.
|
||||
*/
|
||||
public long countUnread(UUID envId, String userId) {
|
||||
public UnreadCountResponse countUnread(UUID envId, String userId) {
|
||||
Key key = new Key(envId, userId);
|
||||
Instant now = Instant.now(clock);
|
||||
Entry cached = memo.get(key);
|
||||
if (cached != null && now.isBefore(cached.expiresAt())) {
|
||||
return cached.count();
|
||||
return cached.response();
|
||||
}
|
||||
long count = instanceRepo.countUnreadForUser(envId, userId);
|
||||
memo.put(key, new Entry(count, now.plusMillis(MEMO_TTL_MS)));
|
||||
return count;
|
||||
Map<AlertSeverity, Long> bySeverity = instanceRepo.countUnreadBySeverityForUser(envId, userId);
|
||||
UnreadCountResponse response = UnreadCountResponse.from(bySeverity);
|
||||
memo.put(key, new Entry(response, now.plusMillis(MEMO_TTL_MS)));
|
||||
return response;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@@ -118,18 +118,24 @@ public class PostgresAlertInstanceRepository implements AlertInstanceRepository
|
||||
}
|
||||
|
||||
@Override
|
||||
public long countUnreadForUser(UUID environmentId, String userId) {
|
||||
public Map<AlertSeverity, Long> countUnreadBySeverityForUser(UUID environmentId, String userId) {
|
||||
String sql = """
|
||||
SELECT COUNT(*) FROM alert_instances ai
|
||||
SELECT ai.severity::text AS severity, COUNT(*) AS cnt
|
||||
FROM alert_instances ai
|
||||
WHERE ai.environment_id = ?
|
||||
AND ? = ANY(ai.target_user_ids)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM alert_reads ar
|
||||
WHERE ar.user_id = ? AND ar.alert_instance_id = ai.id
|
||||
)
|
||||
GROUP BY ai.severity
|
||||
""";
|
||||
Long count = jdbc.queryForObject(sql, Long.class, environmentId, userId, userId);
|
||||
return count == null ? 0L : count;
|
||||
EnumMap<AlertSeverity, Long> counts = new EnumMap<>(AlertSeverity.class);
|
||||
for (AlertSeverity s : AlertSeverity.values()) counts.put(s, 0L);
|
||||
jdbc.query(sql, rs -> {
|
||||
counts.put(AlertSeverity.valueOf(rs.getString("severity")), rs.getLong("cnt"));
|
||||
}, environmentId, userId, userId);
|
||||
return counts;
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
Reference in New Issue
Block a user