diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/notify/InAppInboxQuery.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/notify/InAppInboxQuery.java new file mode 100644 index 00000000..9775e04f --- /dev/null +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/notify/InAppInboxQuery.java @@ -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. + *
+ * {@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
+ * 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
+ * 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
+ * Uses a controllable {@link Clock} to test the 5-second memoization of
+ * {@link InAppInboxQuery#countUnread}.
+ */
+@ExtendWith(MockitoExtension.class)
+class InAppInboxQueryTest {
+
+ @Mock private AlertInstanceRepository instanceRepo;
+ @Mock private RbacService rbacService;
+
+ /** Tick-able clock: each call to millis() returns the current value of this field. */
+ private final AtomicLong nowMillis = new AtomicLong(1_000_000L);
+
+ private Clock tickableClock;
+ private InAppInboxQuery query;
+
+ private static final UUID ENV_ID = UUID.randomUUID();
+ private static final String USER_ID = "user-123";
+
+ @BeforeEach
+ void setUp() {
+ // Build a Clock that delegates to the atomic counter so we can advance time precisely
+ tickableClock = new Clock() {
+ @Override public ZoneOffset getZone() { return ZoneOffset.UTC; }
+ @Override public Clock withZone(java.time.ZoneId zone) { return this; }
+ @Override public Instant instant() { return Instant.ofEpochMilli(nowMillis.get()); }
+ };
+
+ query = new InAppInboxQuery(instanceRepo, rbacService, tickableClock);
+
+ // RbacService stubs: return no groups/roles by default.
+ // Lenient: countUnread tests don't invoke listInbox → stubs would otherwise be flagged unused.
+ lenient().when(rbacService.getEffectiveGroupsForUser(anyString())).thenReturn(List.of());
+ lenient().when(rbacService.getEffectiveRolesForUser(anyString())).thenReturn(List.of());
+ }
+
+ // -------------------------------------------------------------------------
+ // listInbox
+ // -------------------------------------------------------------------------
+
+ @Test
+ void listInbox_delegatesWithResolvedGroupsAndRoles() {
+ UUID groupId = UUID.randomUUID();
+ UUID roleId = UUID.randomUUID();
+ when(rbacService.getEffectiveGroupsForUser(USER_ID))
+ .thenReturn(List.of(new GroupSummary(groupId, "ops-group")));
+ when(rbacService.getEffectiveRolesForUser(USER_ID))
+ .thenReturn(List.of(new RoleSummary(roleId, "OPERATOR", true, "direct")));
+
+ when(instanceRepo.listForInbox(eq(ENV_ID), eq(List.of(groupId.toString())),
+ eq(USER_ID), eq(List.of("OPERATOR")), eq(20)))
+ .thenReturn(List.of());
+
+ List