feat(alerting): InAppInboxQuery with 5s unread-count memoization

listInbox resolves user groups+roles via RbacService.getEffectiveGroupsForUser
/ getEffectiveRolesForUser then delegates to AlertInstanceRepository.
countUnread memoized per (envId, userId) with 5s TTL via ConcurrentHashMap
using a controllable Clock. 6 unit tests covering delegation, cache hit,
TTL expiry, and isolation between users/envs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-19 20:25:00 +02:00
parent 6b48bc63bf
commit d3dd8882bd
2 changed files with 250 additions and 0 deletions

View File

@@ -0,0 +1,93 @@
package com.cameleer.server.app.alerting.notify;
import com.cameleer.server.core.alerting.AlertInstance;
import com.cameleer.server.core.alerting.AlertInstanceRepository;
import com.cameleer.server.core.rbac.RbacService;
import org.springframework.stereotype.Component;
import java.time.Clock;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
/**
* Server-side query helper for the in-app alert inbox.
* <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.
*/
@Component
public class InAppInboxQuery {
private static final long MEMO_TTL_MS = 5_000L;
private final AlertInstanceRepository instanceRepo;
private final RbacService rbacService;
private final Clock clock;
/** 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) {}
private final ConcurrentHashMap<Key, Entry> memo = new ConcurrentHashMap<>();
public InAppInboxQuery(AlertInstanceRepository instanceRepo,
RbacService rbacService,
Clock alertingClock) {
this.instanceRepo = instanceRepo;
this.rbacService = rbacService;
this.clock = alertingClock;
}
/**
* 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) {
List<String> groupIds = resolveGroupIds(userId);
List<String> roleNames = resolveRoleNames(userId);
return instanceRepo.listForInbox(envId, groupIds, userId, roleNames, limit);
}
/**
* Returns the count of unread (un-acked) alert instances visible to the user.
* <p>
* The result is memoized for 5 seconds per {@code (envId, userId)}.
*/
public long 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();
}
long count = instanceRepo.countUnreadForUser(envId, userId);
memo.put(key, new Entry(count, now.plusMillis(MEMO_TTL_MS)));
return count;
}
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
private List<String> resolveGroupIds(String userId) {
return rbacService.getEffectiveGroupsForUser(userId)
.stream()
.map(g -> g.id().toString())
.toList();
}
private List<String> resolveRoleNames(String userId) {
return rbacService.getEffectiveRolesForUser(userId)
.stream()
.map(r -> r.name())
.toList();
}
}