diff --git a/docs/superpowers/plans/2026-04-21-alerts-inbox-redesign.md b/docs/superpowers/plans/2026-04-21-alerts-inbox-redesign.md new file mode 100644 index 00000000..8bc3d3b7 --- /dev/null +++ b/docs/superpowers/plans/2026-04-21-alerts-inbox-redesign.md @@ -0,0 +1,1615 @@ +# Alerts Inbox Redesign Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Collapse the Inbox / All / History alert pages into a single filterable inbox; make `read`/`ack`/`delete` global timestamp flags; add Silence-rule and Delete row/bulk actions. + +**Architecture:** Drop `ACKNOWLEDGED` from `AlertState` (orthogonal `acked_at` flag already exists); add `read_at` + `deleted_at` columns; drop the `alert_reads` table; rework V13 open-rule unique index predicate to `state IN ('PENDING','FIRING') AND deleted_at IS NULL`. Rewire `/read`, `/bulk-read`, `countUnread` to update `alert_instances` directly. Add `DELETE /alerts/{id}`, `POST /alerts/{id}/restore` (undo-toast backing), `POST /alerts/bulk-delete`, `POST /alerts/bulk-ack`. Rebuild `InboxPage.tsx` with a 4-filter bar; delete `AllAlertsPage` + `HistoryPage`; trim sidebar. + +**Tech Stack:** Java 17 / Spring Boot 3.4.3 / Flyway / PostgreSQL 16 / TypeScript / React 18 / React Router v6 / TanStack Query / `@cameleer/design-system`. + +**Spec:** `docs/superpowers/specs/2026-04-21-alerts-inbox-redesign-design.md` + +--- + +## File structure + +**Backend (core):** +- Modify: `cameleer-server-core/src/main/java/com/cameleer/server/core/alerting/AlertState.java` — drop `ACKNOWLEDGED` +- Modify: `cameleer-server-core/src/main/java/com/cameleer/server/core/alerting/AlertInstance.java` — add `readAt`, `deletedAt` +- Modify: `cameleer-server-core/src/main/java/com/cameleer/server/core/alerting/AlertInstanceRepository.java` — add `acked`/`read` filter params, `markRead`/`bulkMarkRead`/`softDelete`/`bulkSoftDelete`, `countUnread(envId, userId, groupIds, roleNames)` (replaces old single-target countUnreadBySeverityForUser) +- Delete: `cameleer-server-core/src/main/java/com/cameleer/server/core/alerting/AlertReadRepository.java` + +**Backend (app):** +- Create: `cameleer-server-app/src/main/resources/db/migration/V17__alerts_drop_acknowledged.sql` +- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/storage/PostgresAlertInstanceRepository.java` +- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/eval/AlertStateTransitions.java` — drop `ACKNOWLEDGED` case +- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/controller/AlertController.java` — DELETE, bulk-delete, bulk-ack, acked/read filter params, rewire `/read` + `/bulk-read` +- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/notify/InAppInboxQuery.java` — add filter params, countUnread rewire +- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/dto/AlertDto.java` — add `readAt` +- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/config/AlertingBeanConfig.java` — drop `alertReadRepository` bean +- Delete: `cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/storage/PostgresAlertReadRepository.java` + +**UI:** +- Regenerate: `ui/src/api/schema.d.ts`, `ui/src/api/openapi.json` +- Modify: `ui/src/api/queries/alerts.ts` — new mutations, new filter params +- Delete: `ui/src/pages/Alerts/AllAlertsPage.tsx`, `ui/src/pages/Alerts/HistoryPage.tsx` +- Modify: `ui/src/router.tsx` — drop `/alerts/all`, `/alerts/history` +- Modify: `ui/src/components/sidebar-utils.ts` — trim `buildAlertsTreeNodes` +- Modify: `ui/src/pages/Alerts/InboxPage.tsx` — new filter bar + actions +- Create: `ui/src/pages/Alerts/SilenceRuleMenu.tsx` — small duration-picker menu +- Modify: `ui/src/pages/Alerts/SilencesPage.tsx` — read `?ruleId=` URL param + +**Tests:** +- Modify: `cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/storage/PostgresAlertInstanceRepositoryIT.java` +- Modify: `cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/controller/AlertControllerIT.java` +- Modify: `cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/eval/AlertStateTransitionsTest.java` +- Modify: `cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/AlertingFullLifecycleIT.java` +- Create: `cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/storage/V17MigrationIT.java` +- Create: `ui/src/pages/Alerts/InboxPage.test.tsx` + +**Docs/rules:** +- Modify: `.claude/rules/app-classes.md`, `.claude/rules/ui.md`, `.claude/rules/core-classes.md` +- Modify: `CLAUDE.md` + +--- + +## Working rules (every task) + +1. **Run migration integration tests via Testcontainers** — `mvn -pl cameleer-server-app -Dtest= test`. The suite already bootstraps Postgres via `@SpringBootTest`. +2. **Commit per task.** Commit message format: `feat(alerts): ` / `refactor(alerts): ` / `test(alerts): `. End with the Claude co-author trailer. +3. **After any backend REST change**, regenerate OpenAPI in one step: `cd ui && (curl -sS http://localhost:8081/api/v1/api-docs > src/api/openapi.json) && npx openapi-typescript src/api/openapi.json -o src/api/schema.d.ts`. This requires the backend running. +4. **Run `gitnexus_impact` before editing any symbol touched by ≥2 callers**, per project rule. Report blast radius. Run `gitnexus_detect_changes` pre-commit. +5. **Don't batch.** One task = one commit. Mark TODO items complete as you go. + +--- + +## Task 1: V17 migration — drop ACKNOWLEDGED, add read_at + deleted_at, drop alert_reads, rework open-rule index + +**Files:** +- Create: `cameleer-server-app/src/main/resources/db/migration/V17__alerts_drop_acknowledged.sql` +- Create: `cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/storage/V17MigrationIT.java` + +- [ ] **Step 1: Write the migration SQL** + +```sql +-- V17 — Alerts: drop ACKNOWLEDGED state, add read_at/deleted_at, drop alert_reads, +-- rework open-rule unique index predicate to survive ack (acked no longer "closed"). + +-- 1. Coerce ACKNOWLEDGED rows → FIRING (acked_at already set on these rows) +UPDATE alert_instances SET state = 'FIRING' WHERE state = 'ACKNOWLEDGED'; + +-- 2. Swap alert_state_enum to remove ACKNOWLEDGED (Postgres can't drop enum values in place) +CREATE TYPE alert_state_enum_v2 AS ENUM ('PENDING','FIRING','RESOLVED'); +ALTER TABLE alert_instances + ALTER COLUMN state TYPE alert_state_enum_v2 + USING state::text::alert_state_enum_v2; +DROP TYPE alert_state_enum; +ALTER TYPE alert_state_enum_v2 RENAME TO alert_state_enum; + +-- 3. New orthogonal flag columns +ALTER TABLE alert_instances + ADD COLUMN read_at timestamptz NULL, + ADD COLUMN deleted_at timestamptz NULL; + +CREATE INDEX alert_instances_unread_idx + ON alert_instances (environment_id, read_at) + WHERE read_at IS NULL AND deleted_at IS NULL; + +CREATE INDEX alert_instances_deleted_idx + ON alert_instances (deleted_at) + WHERE deleted_at IS NOT NULL; + +-- 4. Rework the V13/V15/V16 open-rule uniqueness index: +-- - drop ACKNOWLEDGED from the predicate (ack no longer "closes") +-- - add "AND deleted_at IS NULL" so a soft-deleted row frees the slot +DROP INDEX IF EXISTS alert_instances_open_rule_uq; +CREATE UNIQUE INDEX alert_instances_open_rule_uq + ON alert_instances (rule_id, (COALESCE( + context->>'_subjectFingerprint', + context->'exchange'->>'id', + ''))) + WHERE rule_id IS NOT NULL + AND state IN ('PENDING','FIRING') + AND deleted_at IS NULL; + +-- 5. Drop the per-user reads table — read is now global on alert_instances.read_at +DROP TABLE alert_reads; +``` + +- [ ] **Step 2: Write the failing migration IT** + +```java +package com.cameleer.server.app.alerting.storage; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@ActiveProfiles("test") +class V17MigrationIT { + + @Autowired JdbcTemplate jdbc; + + @Test + void alert_state_enum_drops_acknowledged() { + var values = jdbc.queryForList(""" + SELECT unnest(enum_range(NULL::alert_state_enum))::text AS v + """, String.class); + assertThat(values).containsExactlyInAnyOrder("PENDING", "FIRING", "RESOLVED"); + } + + @Test + void read_at_and_deleted_at_columns_exist() { + var cols = jdbc.queryForList(""" + SELECT column_name FROM information_schema.columns + WHERE table_name = 'alert_instances' + AND column_name IN ('read_at','deleted_at') + """, String.class); + assertThat(cols).containsExactlyInAnyOrder("read_at", "deleted_at"); + } + + @Test + void alert_reads_table_is_gone() { + Integer count = jdbc.queryForObject(""" + SELECT COUNT(*)::int FROM information_schema.tables + WHERE table_name = 'alert_reads' + """, Integer.class); + assertThat(count).isZero(); + } + + @Test + void open_rule_index_predicate_is_reworked() { + String def = jdbc.queryForObject(""" + SELECT pg_get_indexdef(indexrelid) + FROM pg_index + JOIN pg_class ON pg_class.oid = pg_index.indexrelid + WHERE pg_class.relname = 'alert_instances_open_rule_uq' + """, String.class); + assertThat(def).contains("state = ANY (ARRAY['PENDING'::alert_state_enum, 'FIRING'::alert_state_enum])"); + assertThat(def).contains("deleted_at IS NULL"); + } +} +``` + +- [ ] **Step 3: Run the tests — expect 4 failures** (migration not applied yet) + +Run: `mvn -pl cameleer-server-app -Dtest=V17MigrationIT test` +Expected: 4 failures — enum still has ACKNOWLEDGED, columns don't exist, alert_reads still exists, index predicate wrong. + +- [ ] **Step 4: Run the full suite to verify clean up-migration** — `mvn -pl cameleer-server-app verify -DskipITs=false`. Expected: V17 test class green; many existing tests RED because they reference `ACKNOWLEDGED`. That's fine — they get fixed in subsequent tasks. + +- [ ] **Step 5: Commit** + +```bash +git add cameleer-server-app/src/main/resources/db/migration/V17__alerts_drop_acknowledged.sql \ + cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/storage/V17MigrationIT.java +git commit -m "feat(alerts): V17 migration — drop ACKNOWLEDGED, add read_at + deleted_at" +``` + +--- + +## Task 2: Drop ACKNOWLEDGED from AlertState + add read_at/deleted_at to AlertInstance + +**Files:** +- Modify: `cameleer-server-core/src/main/java/com/cameleer/server/core/alerting/AlertState.java` +- Modify: `cameleer-server-core/src/main/java/com/cameleer/server/core/alerting/AlertInstance.java` + +- [ ] **Step 1: Update `AlertState.java`** to exactly: + +```java +package com.cameleer.server.core.alerting; + +public enum AlertState { PENDING, FIRING, RESOLVED } +``` + +- [ ] **Step 2: Add `readAt` + `deletedAt` to `AlertInstance`** — insert the fields after `lastNotifiedAt` (keep insertion order stable for DTOs), update compact ctor if needed, add witherr helpers `withReadAt`, `withDeletedAt`: + +```java +public record AlertInstance( + UUID id, + UUID ruleId, + Map ruleSnapshot, + UUID environmentId, + AlertState state, + AlertSeverity severity, + Instant firedAt, + Instant ackedAt, + String ackedBy, + Instant resolvedAt, + Instant lastNotifiedAt, + Instant readAt, // NEW — global "someone has seen this" + Instant deletedAt, // NEW — soft delete + boolean silenced, + Double currentValue, + Double threshold, + Map context, + String title, + String message, + List targetUserIds, + List targetGroupIds, + List targetRoleNames) { + // ... existing compact ctor unchanged (list/map copies) ... + + public AlertInstance withReadAt(Instant i) { /* new record with readAt=i */ } + public AlertInstance withDeletedAt(Instant i) { /* new record with deletedAt=i */ } + // ... update every existing wither to include readAt/deletedAt in positional args ... +} +``` + +All existing `withXxx` helpers must be updated to pass `readAt, deletedAt` positionally to the new-record construction. This is mechanical. Any call sites in the codebase that construct `AlertInstance` directly need `null, null` inserted at those positions. + +- [ ] **Step 3: Build the module** — `mvn -pl cameleer-server-core compile`. Fix any compile errors in callers of the constructor (seed data / tests). Expected caller: `AlertStateTransitions.newInstance` — add `null, null` for readAt/deletedAt. + +- [ ] **Step 4: Run the core unit tests** — `mvn -pl cameleer-server-core test`. Expected: any direct `AlertState.ACKNOWLEDGED` references in `AlertScopeTest` → drop them or replace with `FIRING`. + +- [ ] **Step 5: Commit** + +```bash +git add cameleer-server-core/src/main/java/com/cameleer/server/core/alerting/AlertState.java \ + cameleer-server-core/src/main/java/com/cameleer/server/core/alerting/AlertInstance.java \ + cameleer-server-core/src/test/java/com/cameleer/server/core/alerting/AlertScopeTest.java \ + cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/eval/AlertStateTransitions.java +git commit -m "refactor(alerts): drop ACKNOWLEDGED from AlertState, add readAt/deletedAt to AlertInstance" +``` + +--- + +## Task 3: AlertStateTransitions — drop ACKNOWLEDGED branch + +**Files:** +- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/eval/AlertStateTransitions.java` +- Modify: `cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/eval/AlertStateTransitionsTest.java` + +- [ ] **Step 1: Write the failing test case** — append to `AlertStateTransitionsTest.java`: + +```java +@Test +void firing_with_ack_stays_firing_on_next_firing_tick() { + // Pre-redesign this was the "ACKNOWLEDGED stays ACK" case. Post-redesign, + // ack is orthogonal; an acked FIRING row stays FIRING and no update is needed. + AlertInstance current = newInstance(AlertState.FIRING) + .withAck("alice", Instant.parse("2026-04-21T10:00:00Z")); + Optional out = AlertStateTransitions.apply( + current, new EvalResult.Firing(1.0, null, Map.of()), rule, NOW); + assertThat(out).isEmpty(); +} +``` + +(Use existing `newInstance` helper and `rule` field in the test class.) + +- [ ] **Step 2: Run test — expect FAIL with `NoSuchFieldError: ACKNOWLEDGED`** (switch-case references removed enum value). Run: `mvn -pl cameleer-server-app -Dtest=AlertStateTransitionsTest test`. + +- [ ] **Step 3: Update `AlertStateTransitions.onFiring`** — replace the switch: + +```java +return switch (current.state()) { + case PENDING -> { + Instant promoteAt = current.firedAt().plusSeconds(rule.forDurationSeconds()); + if (!promoteAt.isAfter(now)) { + yield Optional.of(current + .withState(AlertState.FIRING) + .withFiredAt(now)); + } + yield Optional.empty(); + } + case FIRING -> Optional.empty(); + case RESOLVED -> Optional.empty(); +}; +``` + +Also update `onClear` comment to remove the `/ ACKNOWLEDGED` mention. + +- [ ] **Step 4: Run test — expect PASS** + any existing test in this class that asserted the ACKNOWLEDGED branch needs to be dropped or converted. The old "ack stays ack" case becomes the new "ack-with-fire stays firing (no update)" case covered above. + +- [ ] **Step 5: Run the full alerting test suite** — `mvn -pl cameleer-server-app -Dtest='*Alert*' test`. Many still RED — fixed in later tasks. + +- [ ] **Step 6: Commit** + +```bash +git add cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/eval/AlertStateTransitions.java \ + cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/eval/AlertStateTransitionsTest.java +git commit -m "refactor(alerts): state machine — acked is orthogonal, no transition on ack" +``` + +--- + +## Task 4: AlertInstanceRepository interface — filter params + new methods, drop AlertReadRepository + +**Files:** +- Modify: `cameleer-server-core/src/main/java/com/cameleer/server/core/alerting/AlertInstanceRepository.java` +- Delete: `cameleer-server-core/src/main/java/com/cameleer/server/core/alerting/AlertReadRepository.java` + +- [ ] **Step 1: Rewrite `AlertInstanceRepository`** to add filter params + new methods: + +```java +package com.cameleer.server.core.alerting; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +public interface AlertInstanceRepository { + AlertInstance save(AlertInstance instance); + Optional findById(UUID id); + + /** Open instance for a rule: state IN ('PENDING','FIRING') AND deleted_at IS NULL. */ + Optional findOpenForRule(UUID ruleId); + + /** Unfiltered inbox listing — convenience overload. */ + default List listForInbox(UUID environmentId, + List userGroupIdFilter, + String userId, + List userRoleNames, + int limit) { + return listForInbox(environmentId, userGroupIdFilter, userId, userRoleNames, + null, null, null, null, limit); + } + + /** + * Inbox listing with optional filters. {@code null} or empty lists mean no filter. + * {@code acked} and {@code read} are tri-state: {@code null} = no filter, + * {@code TRUE} = only acked/read, {@code FALSE} = only unacked/unread. + * Always excludes soft-deleted rows ({@code deleted_at IS NOT NULL}). + */ + List listForInbox(UUID environmentId, + List userGroupIdFilter, + String userId, + List userRoleNames, + List states, + List severities, + Boolean acked, + Boolean read, + int limit); + + /** + * Count unread alert instances visible to the user, grouped by severity. + * Visibility: targets user directly, or via one of the given groups/roles. + * "Unread" = {@code read_at IS NULL AND deleted_at IS NULL}. + */ + Map countUnreadBySeverity(UUID environmentId, + String userId, + List groupIds, + List roleNames); + + void ack(UUID id, String userId, Instant when); + void resolve(UUID id, Instant when); + void markSilenced(UUID id, boolean silenced); + void deleteResolvedBefore(Instant cutoff); + + /** Set {@code read_at = when} if currently null. Idempotent. */ + void markRead(UUID id, Instant when); + /** Bulk variant — single UPDATE. */ + void bulkMarkRead(List ids, Instant when); + + /** Set {@code deleted_at = when} if currently null. Idempotent. */ + void softDelete(UUID id, Instant when); + /** Bulk variant — single UPDATE. */ + void bulkSoftDelete(List ids, Instant when); + + /** Clear {@code deleted_at}. Undo for soft-delete. Idempotent. */ + void restore(UUID id); + + /** Bulk ack — single UPDATE. Each row gets {@code acked_at=when, acked_by=userId} if unacked. */ + void bulkAck(List ids, String userId, Instant when); + + List listFiringDueForReNotify(Instant now); +} +``` + +**Breaking changes vs prior signature:** +- `listForInbox` gains `acked` and `read` params (tri-state Boolean). +- `countUnreadBySeverityForUser(envId, userId)` → `countUnreadBySeverity(envId, userId, groupIds, roleNames)`. Renamed + extended scope. +- New: `markRead`, `bulkMarkRead`, `softDelete`, `bulkSoftDelete`, `bulkAck`. + +- [ ] **Step 2: Delete `AlertReadRepository.java`** — `rm cameleer-server-core/src/main/java/com/cameleer/server/core/alerting/AlertReadRepository.java`. Read/ack/delete all live on `AlertInstanceRepository` now. + +- [ ] **Step 3: Compile — expect caller breakage** — `mvn -pl cameleer-server-core compile`. Then `mvn -pl cameleer-server-app compile` will surface every broken call site. Known breakages (fixed in later tasks, do **not** patch them here): + - `PostgresAlertInstanceRepository` (Task 5) + - `AlertController` (Task 6) + - `InAppInboxQuery` (Task 6) + - `AlertingBeanConfig` (Task 5) + - `PostgresAlertReadRepository` (Task 5) + +- [ ] **Step 4: Commit** + +```bash +git add cameleer-server-core/src/main/java/com/cameleer/server/core/alerting/AlertInstanceRepository.java +git rm cameleer-server-core/src/main/java/com/cameleer/server/core/alerting/AlertReadRepository.java +git commit -m "feat(alerts): core repo — filter params + markRead/softDelete/bulkAck; drop AlertReadRepository" +``` + +--- + +## Task 5: PostgresAlertInstanceRepository — save/rowMapper, new methods, listForInbox filters, countUnread + +**Files:** +- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/storage/PostgresAlertInstanceRepository.java` +- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/config/AlertingBeanConfig.java` +- Delete: `cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/storage/PostgresAlertReadRepository.java` +- Modify: `cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/storage/PostgresAlertInstanceRepositoryIT.java` + +- [ ] **Step 1: Update `save` SQL** — add `read_at`, `deleted_at` to INSERT column list, values list, `ON CONFLICT DO UPDATE` list. Add `Timestamp.from(i.readAt())`, `Timestamp.from(i.deletedAt())` to the `jdbc.update(...)` args (via the existing `ts()` helper that null-safes). Follow the same pattern as `ackedAt`. + +- [ ] **Step 2: Update `findOpenForRule` predicate**: + +```java +var list = jdbc.query(""" + SELECT * FROM alert_instances + WHERE rule_id = ? + AND state IN ('PENDING','FIRING') + AND deleted_at IS NULL + LIMIT 1 + """, rowMapper(), ruleId); +``` + +- [ ] **Step 3: Update `ack` — no state change anymore**: + +```java +@Override +public void ack(UUID id, String userId, Instant when) { + jdbc.update(""" + UPDATE alert_instances + SET acked_at = ?, acked_by = ? + WHERE id = ? AND acked_at IS NULL AND deleted_at IS NULL + """, Timestamp.from(when), userId, id); +} +``` + +- [ ] **Step 4: Implement `listForInbox` with tri-state filters** — add to existing dynamic SQL: + +```java +// after severity filter block: +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 ?"); +``` + +Signature matches the interface (`Boolean acked`, `Boolean read`). + +- [ ] **Step 5: Rewrite `countUnreadBySeverity`** — removes the `alert_reads` join: + +```java +@Override +public Map countUnreadBySeverity(UUID environmentId, + String userId, + List groupIds, + List roleNames) { + Array groupArray = toUuidArrayFromStrings(groupIds); + Array roleArray = toTextArray(roleNames); + String sql = """ + SELECT severity::text AS severity, COUNT(*) AS cnt + FROM alert_instances + WHERE environment_id = ? + AND read_at IS NULL + AND deleted_at IS NULL + AND ( + ? = ANY(target_user_ids) + OR target_group_ids && ? + OR target_role_names && ? + ) + GROUP BY severity + """; + EnumMap 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, groupArray, roleArray); + return counts; +} +``` + +- [ ] **Step 6: Implement new mutation methods**: + +```java +@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 ids, Instant when) { + if (ids == null || ids.isEmpty()) return; + Array idArray = jdbc.execute((ConnectionCallback) 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 ids, Instant when) { + if (ids == null || ids.isEmpty()) return; + Array idArray = jdbc.execute((ConnectionCallback) 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 ids, String userId, Instant when) { + if (ids == null || ids.isEmpty()) return; + Array idArray = jdbc.execute((ConnectionCallback) 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); +} +``` + +- [ ] **Step 7: Update `rowMapper`** — read `read_at` + `deleted_at`: + +```java +Timestamp readAt = rs.getTimestamp("read_at"); +Timestamp deletedAt = rs.getTimestamp("deleted_at"); +// ... and pass: +readAt == null ? null : readAt.toInstant(), +deletedAt == null ? null : deletedAt.toInstant(), +``` + +Insert at the position matching the record's field order (after `lastNotifiedAt`, before `silenced`). + +- [ ] **Step 8: Delete `PostgresAlertReadRepository.java`** + remove its bean from `AlertingBeanConfig`: + +```bash +git rm cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/storage/PostgresAlertReadRepository.java +``` + +In `AlertingBeanConfig.java` remove: +```java +@Bean +public AlertReadRepository alertReadRepository(JdbcTemplate jdbc) { ... } +``` +Remove the `AlertReadRepository` import too. + +- [ ] **Step 9: Update `PostgresAlertInstanceRepositoryIT`** — add tests: + +```java +@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(ENV_ID, List.of(), USER_ID, 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(), USER_ID, Instant.parse("2026-04-21T10:00:00Z")); + var open = repo.findOpenForRule(inst.ruleId()); + assertThat(open).isPresent(); // ack no longer closes the open slot +} + +@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(); + repo.ack(a.id(), "alice", Instant.parse("2026-04-21T09:00:00Z")); + repo.bulkAck(List.of(a.id(), b.id()), "bob", Instant.parse("2026-04-21T10:00:00Z")); + assertThat(repo.findById(a.id()).orElseThrow().ackedBy()).isEqualTo("alice"); + assertThat(repo.findById(b.id()).orElseThrow().ackedBy()).isEqualTo("bob"); +} + +@Test +void listForInbox_acked_false_hides_acked_rows() { + var a = insertFreshFiring(); + var b = insertFreshFiring(); + repo.ack(a.id(), "alice", Instant.now()); + var rows = repo.listForInbox(ENV_ID, List.of(), USER_ID, List.of(), + null, null, /*acked*/ false, null, 100); + assertThat(rows).extracting(AlertInstance::id).containsExactly(b.id()); +} +``` + +(Use the test class's existing `insertFreshFiring()` helper or add a small factory. `ENV_ID` / `USER_ID` are existing fixture constants.) + +Also update any existing test asserting `state == ACKNOWLEDGED` after `ack()` — instead assert `ackedAt != null && state == FIRING`. + +- [ ] **Step 10: Run the repo IT** — `mvn -pl cameleer-server-app -Dtest=PostgresAlertInstanceRepositoryIT test`. Expected: all green. + +- [ ] **Step 11: Commit** + +```bash +git add cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/storage/PostgresAlertInstanceRepository.java \ + cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/config/AlertingBeanConfig.java \ + cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/storage/PostgresAlertInstanceRepositoryIT.java +git rm cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/storage/PostgresAlertReadRepository.java +git commit -m "feat(alerts): Postgres repo — markRead/softDelete/bulkAck, acked/read filters, countUnread via read_at" +``` + +--- + +## Task 6: InAppInboxQuery + AlertController — new endpoints, filter params, rewire read + +**Files:** +- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/notify/InAppInboxQuery.java` +- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/controller/AlertController.java` +- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/dto/AlertDto.java` +- Modify: `cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/controller/AlertControllerIT.java` + +- [ ] **Step 1: Update `AlertDto`** — add `readAt`: + +```java +public record AlertDto( + UUID id, + UUID ruleId, + UUID environmentId, + AlertState state, + AlertSeverity severity, + String title, + String message, + Instant firedAt, + Instant ackedAt, + String ackedBy, + Instant resolvedAt, + Instant readAt, // NEW + boolean silenced, + Double currentValue, + Double threshold, + Map context +) { + public static AlertDto from(AlertInstance i) { + 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.readAt(), i.silenced(), + i.currentValue(), i.threshold(), i.context()); + } +} +``` + +`deletedAt` is intentionally absent — soft-deleted rows never reach the wire. + +- [ ] **Step 2: Update `InAppInboxQuery`** — new filter signature, rewired countUnread: + +```java +public List listInbox(UUID envId, + String userId, + List states, + List severities, + Boolean acked, + Boolean read, + int limit) { + List groupIds = resolveGroupIds(userId); + List roleNames = resolveRoleNames(userId); + return instanceRepo.listForInbox(envId, groupIds, userId, roleNames, + states, severities, acked, read, limit); +} + +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.response(); + List groupIds = resolveGroupIds(userId); + List roleNames = resolveRoleNames(userId); + Map bySeverity = + instanceRepo.countUnreadBySeverity(envId, userId, groupIds, roleNames); + UnreadCountResponse response = UnreadCountResponse.from(bySeverity); + memo.put(key, new Entry(response, now.plusMillis(MEMO_TTL_MS))); + return response; +} +``` + +Drop the deprecated 4-arg `listInbox` overload — the controller will use the full form. + +- [ ] **Step 3: Update `AlertController`** — add DELETE, bulk-delete, bulk-ack, acked/read filter params, rewire read/bulk-read: + +```java +@GetMapping +public List list( + @EnvPath Environment env, + @RequestParam(defaultValue = "50") int limit, + @RequestParam(required = false) List state, + @RequestParam(required = false) List 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, acked, read, effectiveLimit) + .stream().map(AlertDto::from).toList(); +} + +@PostMapping("/{id}/read") +public void read(@EnvPath Environment env, @PathVariable UUID id) { + requireLiveInstance(id, env.id()); + instanceRepo.markRead(id, Instant.now()); +} + +@PostMapping("/bulk-read") +public void bulkRead(@EnvPath Environment env, @Valid @RequestBody BulkIdsRequest req) { + List filtered = inEnvIds(req.instanceIds(), env.id()); + if (!filtered.isEmpty()) instanceRepo.bulkMarkRead(filtered, Instant.now()); +} + +@PostMapping("/bulk-ack") +@PreAuthorize("hasAnyRole('VIEWER','OPERATOR','ADMIN')") +public void bulkAck(@EnvPath Environment env, @Valid @RequestBody BulkIdsRequest req) { + List filtered = inEnvIds(req.instanceIds(), env.id()); + if (!filtered.isEmpty()) instanceRepo.bulkAck(filtered, currentUserId(), Instant.now()); +} + +@DeleteMapping("/{id}") +@PreAuthorize("hasAnyRole('OPERATOR','ADMIN')") +public ResponseEntity 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 filtered = inEnvIds(req.instanceIds(), env.id()); + if (!filtered.isEmpty()) instanceRepo.bulkSoftDelete(filtered, Instant.now()); +} + +@PostMapping("/{id}/restore") +@PreAuthorize("hasAnyRole('OPERATOR','ADMIN')") +public ResponseEntity restore(@EnvPath Environment env, @PathVariable UUID id) { + // Must find the row regardless of deleted_at (requireLiveInstance would 404 on a deleted row). + 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 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 inEnvIds(List ids, UUID envId) { + return ids.stream() + .filter(id -> instanceRepo.findById(id) + .map(i -> i.environmentId().equals(envId) && i.deletedAt() == null) + .orElse(false)) + .toList(); +} +``` + +Rename `BulkReadRequest` → `BulkIdsRequest` (the body shape `{ instanceIds: [...] }` is identical for read/ack/delete; one DTO covers all three). Or keep `BulkReadRequest` and add `BulkAckRequest` + `BulkDeleteRequest` as sealed aliases — pick **rename to `BulkIdsRequest`** for DRY. Update references accordingly. + +Drop the `AlertReadRepository` field + constructor param. + +- [ ] **Step 4: Update `AlertControllerIT`** — add cases: + +```java +@Test +void bulkDelete_softDeletes_matching_rows_in_env_only() { /* two rows in env, one out; bulk-delete both; only in-env row is deleted */ } + +@Test +void list_respects_read_filter() { /* three rows, two unread; ?read=false returns two */ } + +@Test +void list_respects_acked_filter() { /* similar */ } + +@Test +void delete_non_operator_returns_403() { /* VIEWER role → 403 */ } + +@Test +void get_returns_404_for_soft_deleted() { /* delete then GET → 404 */ } +``` + +Use existing test harness (`@WebMvcTest` or full `@SpringBootTest`, whichever the current IT uses). + +Also fix every existing IT assertion that expects `state=ACKNOWLEDGED` — switch to `ackedAt != null`. + +- [ ] **Step 5: Run** — `mvn -pl cameleer-server-app -Dtest=AlertControllerIT test`. Expected: green. + +- [ ] **Step 6: Commit** + +```bash +git add cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/controller/AlertController.java \ + cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/notify/InAppInboxQuery.java \ + cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/dto/AlertDto.java \ + cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/dto/BulkIdsRequest.java \ + cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/controller/AlertControllerIT.java +git rm cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/dto/BulkReadRequest.java +git commit -m "feat(alerts): controller — DELETE + bulk-delete/bulk-ack, acked/read filters, read via instance" +``` + +--- + +## Task 7: Fix remaining backend tests + SecurityConfig matchers + +**Files:** +- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityConfig.java` (RBAC matchers if needed) +- Modify: `cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/AlertingFullLifecycleIT.java` +- Modify: any other test referencing `ACKNOWLEDGED` / `AlertReadRepository` +- Modify: Grep-find all: `grep -rn "ACKNOWLEDGED\|AlertReadRepository\|alert_reads" cameleer-server-app/src cameleer-server-core/src` + +- [ ] **Step 1: List remaining ACKNOWLEDGED references** + +Run: `grep -rn "ACKNOWLEDGED\|AlertReadRepository\|alert_reads" cameleer-server-app/src cameleer-server-core/src` +Expected: only test + doc fixture references now. Patch each assertion from `state==ACKNOWLEDGED` → `ackedAt != null`. + +- [ ] **Step 2: Update `SecurityConfig` matchers** — add matchers for the new endpoints: + +```java +// Ack/read/bulk-read/bulk-ack — VIEWER+ (matches existing) +.requestMatchers(HttpMethod.POST, "/api/v1/environments/*/alerts/*/ack", "/api/v1/environments/*/alerts/*/read", + "/api/v1/environments/*/alerts/bulk-read", "/api/v1/environments/*/alerts/bulk-ack") + .hasAnyRole("VIEWER","OPERATOR","ADMIN") +// Delete/bulk-delete — OPERATOR+ +.requestMatchers(HttpMethod.DELETE, "/api/v1/environments/*/alerts/*") + .hasAnyRole("OPERATOR","ADMIN") +.requestMatchers(HttpMethod.POST, "/api/v1/environments/*/alerts/bulk-delete") + .hasAnyRole("OPERATOR","ADMIN") +``` + +Verify the existing config — the `@PreAuthorize` annotations on the controller methods cover this, but add URL matchers if the existing config relies on them (inspect before copy-paste). + +- [ ] **Step 3: Update `AlertingFullLifecycleIT`** — any flow that did `ack → state=ACKNOWLEDGED → resolve` now skips the state change on ack. Patch asserts. + +- [ ] **Step 4: Run the whole alerting suite** — `mvn -pl cameleer-server-app -Dtest='*Alert*' test`. Expected: green. + +- [ ] **Step 5: Full build** — `mvn clean verify`. Expected: green. + +- [ ] **Step 6: Commit** + +```bash +git add -A +git commit -m "test(alerts): migrate fixtures off ACKNOWLEDGED + alert_reads" +``` + +--- + +## Task 8: Regenerate OpenAPI schema + +**Files:** +- Regenerate: `ui/src/api/schema.d.ts`, `ui/src/api/openapi.json` + +- [ ] **Step 1: Start backend** — `java -jar cameleer-server-app/target/cameleer-server-app-1.0-SNAPSHOT.jar` in a separate terminal, or via Docker per your local workflow. Wait for `:8081` to accept. + +- [ ] **Step 2: Regenerate schema** + +```bash +cd ui +curl -sS http://localhost:8081/api/v1/api-docs > src/api/openapi.json +npx openapi-typescript src/api/openapi.json -o src/api/schema.d.ts +``` + +- [ ] **Step 3: Verify new endpoints appear** — `grep -E "bulk-delete|bulk-ack|\"delete\"" src/api/schema.d.ts | head -20`. Expected: DELETE on `/alerts/{id}`, POST on bulk-delete + bulk-ack. `AlertDto` includes `readAt`. `/alerts` GET has `acked`/`read` query params. + +- [ ] **Step 4: Surface TS breakages** — `cd ui && npx tsc --noEmit`. Expected: errors in `alerts.ts`, `InboxPage.tsx`, `AllAlertsPage.tsx`, `HistoryPage.tsx` — all fixed in subsequent tasks. Do **not** patch here. + +- [ ] **Step 5: Commit** + +```bash +git add ui/src/api/openapi.json ui/src/api/schema.d.ts +git commit -m "chore(ui): regenerate OpenAPI schema for alerts inbox redesign" +``` + +--- + +## Task 9: UI alerts.ts — new mutations + useAlerts filter params + +**Files:** +- Modify: `ui/src/api/queries/alerts.ts` + +- [ ] **Step 1: Update `AlertsFilter` + `useAlerts`** — add `acked?: boolean`, `read?: boolean`: + +```ts +export interface AlertsFilter { + state?: AlertState | AlertState[]; + severity?: AlertSeverity | AlertSeverity[]; + acked?: boolean; + read?: boolean; + ruleId?: string; + limit?: number; +} + +// in useAlerts queryFn, after severity handling: +if (filter.acked !== undefined) query.acked = filter.acked; +if (filter.read !== undefined) query.read = filter.read; + +// queryKey must include acked/read: +queryKey: ['alerts', env, 'list', fetchLimit, stateKey, severityKey, filter.acked ?? null, filter.read ?? null], +``` + +- [ ] **Step 2: Rewire `useBulkReadAlerts` body** — body shape is identical (`{ instanceIds }`), but fix the TS reference if the generated type renamed it to `BulkIdsRequest`. + +- [ ] **Step 3: Add four new mutations**: + +```ts +export function useBulkAckAlerts() { + const env = useSelectedEnv(); + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (ids: string[]) => { + if (!env) throw new Error('no env'); + const { error } = await apiClient.POST( + '/environments/{envSlug}/alerts/bulk-ack', + { params: { path: { envSlug: env } }, body: { instanceIds: ids } } as any); + if (error) throw error; + }, + onSuccess: () => qc.invalidateQueries({ queryKey: ['alerts', env] }), + }); +} + +export function useDeleteAlert() { + const env = useSelectedEnv(); + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (id: string) => { + if (!env) throw new Error('no env'); + const { error } = await apiClient.DELETE( + '/environments/{envSlug}/alerts/{id}', + { params: { path: { envSlug: env, id } } } as any); + if (error) throw error; + }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['alerts', env] }); + qc.invalidateQueries({ queryKey: ['alerts', env, 'unread-count'] }); + }, + }); +} + +export function useBulkDeleteAlerts() { + const env = useSelectedEnv(); + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (ids: string[]) => { + if (!env) throw new Error('no env'); + const { error } = await apiClient.POST( + '/environments/{envSlug}/alerts/bulk-delete', + { params: { path: { envSlug: env } }, body: { instanceIds: ids } } as any); + if (error) throw error; + }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['alerts', env] }); + qc.invalidateQueries({ queryKey: ['alerts', env, 'unread-count'] }); + }, + }); +} + +export function useRestoreAlert() { + const env = useSelectedEnv(); + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (id: string) => { + if (!env) throw new Error('no env'); + const { error } = await apiClient.POST( + '/environments/{envSlug}/alerts/{id}/restore', + { params: { path: { envSlug: env, id } } } as any); + if (error) throw error; + }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['alerts', env] }); + qc.invalidateQueries({ queryKey: ['alerts', env, 'unread-count'] }); + }, + }); +} +``` + +- [ ] **Step 4: `npx tsc --noEmit`** — expected: only `AllAlertsPage.tsx`, `HistoryPage.tsx`, `InboxPage.tsx` errors remain. Those pages are rewritten/deleted in the next tasks. + +- [ ] **Step 5: Commit** + +```bash +git add ui/src/api/queries/alerts.ts +git commit -m "feat(ui/alerts): hooks for bulk-ack, delete, bulk-delete; acked/read filter params" +``` + +--- + +## Task 10: Remove AllAlertsPage, HistoryPage, router routes, sidebar trim + +**Files:** +- Delete: `ui/src/pages/Alerts/AllAlertsPage.tsx` +- Delete: `ui/src/pages/Alerts/HistoryPage.tsx` +- Modify: `ui/src/router.tsx` +- Modify: `ui/src/components/sidebar-utils.ts` + +- [ ] **Step 1: Delete the two pages** + +```bash +git rm ui/src/pages/Alerts/AllAlertsPage.tsx ui/src/pages/Alerts/HistoryPage.tsx +``` + +- [ ] **Step 2: Trim `router.tsx`** — drop lines 27, 28 (lazy imports) and the two route entries at lines 87, 88 (`/alerts/all`, `/alerts/history`). Keep the `/alerts` → `/alerts/inbox` redirect on line 85 (sidebar click lands here). + +- [ ] **Step 3: Trim `buildAlertsTreeNodes`** in `ui/src/components/sidebar-utils.ts`: + +```ts +export function buildAlertsTreeNodes(): SidebarTreeNode[] { + const icon = (el: ReactNode) => el; + return [ + { id: 'alerts-inbox', label: 'Inbox', path: '/alerts/inbox', icon: icon(createElement(Inbox, { size: 14 })) }, + { id: 'alerts-rules', label: 'Rules', path: '/alerts/rules', icon: icon(createElement(AlertTriangle, { size: 14 })) }, + { id: 'alerts-silences', label: 'Silences', path: '/alerts/silences', icon: icon(createElement(BellOff, { size: 14 })) }, + ]; +} +``` + +Remove `List`, `ScrollText` from the `lucide-react` import (if no other user). + +- [ ] **Step 4: Check CMD-K** — `grep -n "alerts/all\|alerts/history" ui/src`. Expected: no hits. `buildAlertSearchData` in `LayoutShell.tsx` deep-links to `/alerts/inbox/{id}` regardless. + +- [ ] **Step 5: Run typecheck** — `cd ui && npx tsc --noEmit`. Expected: only `InboxPage.tsx` errors left (next task). + +- [ ] **Step 6: Commit** + +```bash +git add ui/src/router.tsx ui/src/components/sidebar-utils.ts +git rm ui/src/pages/Alerts/AllAlertsPage.tsx ui/src/pages/Alerts/HistoryPage.tsx +git commit -m "refactor(ui/alerts): single inbox — remove AllAlerts + History pages, trim sidebar" +``` + +--- + +## Task 11: Create Silence-rule quick menu component + +**Files:** +- Create: `ui/src/pages/Alerts/SilenceRuleMenu.tsx` + +- [ ] **Step 1: Write the component** — a small popover/menu with three preset buttons + a "Custom…" link: + +```tsx +import { useState, useRef } from 'react'; +import { useNavigate } from 'react-router'; +import { Button, Popover } from '@cameleer/design-system'; +import { BellOff } from 'lucide-react'; +import { useCreateSilence } from '../../api/queries/alertSilences'; +import { useToast } from '@cameleer/design-system'; + +const PRESETS: Array<{ label: string; hours: number }> = [ + { label: '1 hour', hours: 1 }, + { label: '8 hours', hours: 8 }, + { label: '24 hours', hours: 24 }, +]; + +interface Props { + ruleId: string; + ruleTitle?: string; + onDone?: () => void; + variant?: 'row' | 'bulk'; +} + +export function SilenceRuleMenu({ ruleId, ruleTitle, onDone, variant = 'row' }: Props) { + const [open, setOpen] = useState(false); + const anchor = useRef(null); + const create = useCreateSilence(); + const navigate = useNavigate(); + const { toast } = useToast(); + + const createWithDuration = async (hours: number) => { + const now = new Date(); + const endsAt = new Date(now.getTime() + hours * 3600_000); + try { + await create.mutateAsync({ + matcher: { ruleId }, + reason: `Silenced from inbox${ruleTitle ? ` (${ruleTitle})` : ''}`, + startsAt: now.toISOString(), + endsAt: endsAt.toISOString(), + }); + toast({ title: `Silenced for ${hours}h`, variant: 'success' }); + setOpen(false); + onDone?.(); + } catch (e) { + toast({ title: 'Silence failed', description: String(e), variant: 'error' }); + } + }; + + return ( + <> + + setOpen(false)} anchor={anchor}> +
+ {PRESETS.map((p) => ( + + ))} + +
+
+ + ); +} +``` + +If the DS doesn't export `Popover`, fall back to the existing dropdown/menu pattern used elsewhere — search `ui/src` for "Popover\|DropdownMenu\|PopoverContent" and match an established pattern. + +- [ ] **Step 2: `npx tsc --noEmit`** — expected clean. + +- [ ] **Step 3: Commit** + +```bash +git add ui/src/pages/Alerts/SilenceRuleMenu.tsx +git commit -m "feat(ui/alerts): silence-rule quick menu (1h/8h/24h/custom)" +``` + +--- + +## Task 12: Rewrite InboxPage — filter bar, row actions, bulk toolbar + +**Files:** +- Modify: `ui/src/pages/Alerts/InboxPage.tsx` + +- [ ] **Step 1: Rewrite `InboxPage.tsx`** — full replacement. Key shape: + +```tsx +// Filter state: +const [severitySel, setSeveritySel] = useState>(new Set()); +const [stateSel, setStateSel] = useState>(new Set(['FIRING'])); // default FIRING only +const [hideAcked, setHideAcked] = useState(true); +const [hideRead, setHideRead] = useState(true); + +const { data } = useAlerts({ + severity: severitySel.size ? ([...severitySel] as AlertSeverity[]) : undefined, + state: stateSel.size ? ([...stateSel] as AlertState[]) : undefined, + acked: hideAcked ? false : undefined, + read: hideRead ? false : undefined, + limit: 200, +}); +``` + +**Filter-bar JSX** (in the page header actions column): + +```tsx +
+ + + + +
+``` + +`STATE_ITEMS` omits `ACKNOWLEDGED`: + +```tsx +const STATE_ITEMS: ButtonGroupItem[] = [ + { value: 'PENDING', label: 'Pending', color: 'var(--text-muted)' }, + { value: 'FIRING', label: 'Firing', color: 'var(--error)' }, + { value: 'RESOLVED', label: 'Resolved', color: 'var(--success)' }, +]; +``` + +**Row actions column** (replace the existing `ack` column): + +```tsx +{ + key: 'actions', header: '', width: '240px', + render: (_, row) => ( +
+ {row.ackedAt == null && ( + + )} + {row.readAt == null && ( + + )} + {row.ruleId && ( + + )} + {canDelete && ( + + )} +
+ ), +}, +``` + +`canDelete` gated via `useAuthStore()` role check: `roles.includes('OPERATOR') || roles.includes('ADMIN')`. + +**Single delete with undo toast** (5s) — backed by `useRestoreAlert`: + +```tsx +const del = useDeleteAlert(); +const restore = useRestoreAlert(); + +const onDeleteOne = async (id: string) => { + try { + await del.mutateAsync(id); + toast({ + title: 'Deleted', + variant: 'success', + duration: 5000, + action: { + label: 'Undo', + onClick: () => restore.mutateAsync(id).then( + () => toast({ title: 'Restored', variant: 'success' }), + (e) => toast({ title: 'Undo failed', description: String(e), variant: 'error' }) + ), + }, + }); + } catch (e) { + toast({ title: 'Delete failed', description: String(e), variant: 'error' }); + } +}; +``` + +If the DS `toast` API doesn't support an `action` slot, render a secondary `` inside the toast `description` ReactNode — check `@cameleer/design-system` toast signature before choosing. + +**Bulk toolbar** (the existing `filterBar` section): + +```tsx +{selectedIds.length > 0 ? ( + <> + + + + {canDelete && ( + + )} + +) : ( /* existing "Acknowledge all firing" + "Mark all read" */ )} +``` + +**Bulk-delete confirmation modal:** + +```tsx + setDeletePending(null)} + onConfirm={async () => { + if (!deletePending) return; + await bulkDelete.mutateAsync(deletePending); + toast({ title: `Deleted ${deletePending.length}`, variant: 'success' }); + setDeletePending(null); + setSelected(new Set()); + }} + title="Delete alerts?" + message={`Delete ${deletePending?.length ?? 0} alerts? This affects all users.`} + confirmText="Delete" + variant="danger" +/> +``` + +`SilenceRulesForSelection` is a helper that walks `rows` filtered to `selectedIds`, collects unique `ruleId`s, and renders a menu that creates one silence per ruleId with the chosen duration — composed from `SilenceRuleMenu` or a similar inline control. + +- [ ] **Step 2: Manual TS check + dev run** — `cd ui && npx tsc --noEmit`, expected green. Run `npm run dev`; navigate to `/alerts/inbox`, verify: + - Filter toggles work (changing hides/reveals rows) + - Ack button disappears after ack; row stays visible (hide-acked OFF) + - Silence menu opens, "1 hour" creates a silence and shows success toast + - Delete button (as OPERATOR) soft-deletes; row disappears; bell count updates + +- [ ] **Step 3: Commit** + +```bash +git add ui/src/pages/Alerts/InboxPage.tsx +git commit -m "feat(ui/alerts): single inbox — filter bar, silence/delete row actions, bulk toolbar" +``` + +--- + +## Task 13: SilencesPage — prefill `?ruleId=` from URL + +**Files:** +- Modify: `ui/src/pages/Alerts/SilencesPage.tsx` + +- [ ] **Step 1: Add `useSearchParams` import + effect** + +```tsx +import { useSearchParams } from 'react-router'; +// inside component: +const [searchParams] = useSearchParams(); +useEffect(() => { + const r = searchParams.get('ruleId'); + if (r) setMatcherRuleId(r); +}, [searchParams]); +``` + +That's the entire change. `matcherRuleId` already feeds the create-silence form. + +- [ ] **Step 2: Manual test** — navigate to `/alerts/silences?ruleId=abc-123`; the Rule ID field is prefilled. + +- [ ] **Step 3: Commit** + +```bash +git add ui/src/pages/Alerts/SilencesPage.tsx +git commit -m "feat(ui/alerts): SilencesPage — prefill Rule ID from ?ruleId= query param" +``` + +--- + +## Task 14: UI InboxPage tests + +**Files:** +- Create: `ui/src/pages/Alerts/InboxPage.test.tsx` + +- [ ] **Step 1: Write Vitest + RTL tests** with concrete bodies. Mocks hoisted at top so hook calls return deterministic data. + +```tsx +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { MemoryRouter } from 'react-router'; +import InboxPage from './InboxPage'; +import type { AlertDto } from '../../api/queries/alerts'; + +const alertsMock = vi.fn(); +const deleteMock = vi.fn().mockResolvedValue(undefined); +const bulkDelete = vi.fn().mockResolvedValue(undefined); +const ackMock = vi.fn().mockResolvedValue(undefined); +const markReadMock = vi.fn(); +const authRolesMock = vi.fn<[], string[]>(); + +vi.mock('../../api/queries/alerts', () => ({ + useAlerts: (...args: unknown[]) => ({ data: alertsMock(...args), isLoading: false, error: null }), + useAckAlert: () => ({ mutateAsync: ackMock, isPending: false }), + useMarkAlertRead: () => ({ mutate: markReadMock }), + useBulkReadAlerts: () => ({ mutateAsync: vi.fn(), isPending: false }), + useBulkAckAlerts: () => ({ mutateAsync: vi.fn(), isPending: false }), + useDeleteAlert: () => ({ mutateAsync: deleteMock, isPending: false }), + useBulkDeleteAlerts:() => ({ mutateAsync: bulkDelete, isPending: false }), + useRestoreAlert: () => ({ mutateAsync: vi.fn() }), +})); + +vi.mock('../../auth/auth-store', () => ({ + useAuthStore: () => ({ roles: authRolesMock() }), +})); + +function mount() { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + + + , + ); +} + +const ROW_FIRING: AlertDto = { + id: '11111111-1111-1111-1111-111111111111', + ruleId: 'rrrrrrrr-rrrr-rrrr-rrrr-rrrrrrrrrrrr', + state: 'FIRING', severity: 'CRITICAL', + title: 'Order pipeline down', message: 'msg', + firedAt: '2026-04-21T10:00:00Z', + ackedAt: null, ackedBy: null, resolvedAt: null, readAt: null, + silenced: false, currentValue: null, threshold: null, + environmentId: 'eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee', + context: {}, +}; +const ROW_ACKED: AlertDto = { ...ROW_FIRING, id: '22222222-2222-2222-2222-222222222222', ackedAt: '2026-04-21T10:05:00Z', ackedBy: 'alice' }; + +beforeEach(() => { + vi.clearAllMocks(); + authRolesMock.mockReturnValue(['OPERATOR']); + alertsMock.mockReturnValue([ROW_FIRING]); +}); + +describe('InboxPage', () => { + it('calls useAlerts with default filters: state=[FIRING], acked=false, read=false', () => { + mount(); + expect(alertsMock).toHaveBeenCalledWith(expect.objectContaining({ + state: ['FIRING'], + acked: false, + read: false, + })); + }); + + it('unchecking "Hide acked" removes the acked filter', async () => { + mount(); + await userEvent.click(screen.getByRole('checkbox', { name: /hide acked/i })); + const lastCall = alertsMock.mock.calls.at(-1)?.[0]; + expect(lastCall).not.toHaveProperty('acked'); + }); + + it('shows Acknowledge button only on rows where ackedAt is null', () => { + alertsMock.mockReturnValue([ROW_FIRING, ROW_ACKED]); + mount(); + expect(screen.getAllByRole('button', { name: /acknowledge/i })).toHaveLength(1); + }); + + it('opens bulk-delete confirmation with the correct count', async () => { + alertsMock.mockReturnValue([ROW_FIRING, ROW_ACKED]); + mount(); + // Select both rows + for (const cb of screen.getAllByRole('checkbox', { name: /^select/i })) { + await userEvent.click(cb); + } + await userEvent.click(screen.getByRole('button', { name: /delete 2/i })); + expect(screen.getByRole('dialog')).toHaveTextContent(/delete 2 alerts/i); + }); + + it('hides Delete buttons when user lacks OPERATOR role', () => { + authRolesMock.mockReturnValue(['VIEWER']); + mount(); + expect(screen.queryByRole('button', { name: /delete/i })).toBeNull(); + }); + + it('clicking row Delete invokes useDeleteAlert and shows an Undo toast', async () => { + mount(); + await userEvent.click(within(screen.getAllByRole('row')[1]).getByRole('button', { name: /delete/i })); + expect(deleteMock).toHaveBeenCalledWith(ROW_FIRING.id); + expect(await screen.findByText(/deleted/i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /undo/i })).toBeInTheDocument(); + }); +}); +``` + +Adjust selectors if design-system renders checkboxes/buttons with different accessible names — prefer role+name queries over test-ids. + +- [ ] **Step 2: Run** — `cd ui && npx vitest run src/pages/Alerts/InboxPage.test.tsx`. Green. + +- [ ] **Step 3: Commit** + +```bash +git add ui/src/pages/Alerts/InboxPage.test.tsx +git commit -m "test(ui/alerts): InboxPage filter + action coverage" +``` + +--- + +## Task 15: Rules docs + CLAUDE.md + +**Files:** +- Modify: `.claude/rules/app-classes.md` — AlertController endpoint table +- Modify: `.claude/rules/core-classes.md` — AlertState enum, drop AlertReadRepository mention +- Modify: `.claude/rules/ui.md` — Alerts section +- Modify: `CLAUDE.md` — V17 migration entry + +- [ ] **Step 1: Update `.claude/rules/app-classes.md`** — find the `AlertController` bullet and replace: + +``` +- `AlertController` — `/api/v1/environments/{envSlug}/alerts`. GET list (inbox filtered by userId/groupIds/roleNames via `InAppInboxQuery`; query params: multi-value `state` (PENDING|FIRING|RESOLVED) + `severity`, tri-state `acked`/`read`; always `deleted_at IS NULL`) / GET `/unread-count` / GET `{id}` (404 if deleted) / POST `{id}/ack` (sets acked_at only, no state change) / POST `{id}/read` (sets read_at) / POST `/bulk-read` / POST `/bulk-ack` (VIEWER+) / DELETE `{id}` (OPERATOR+, soft-delete) / POST `/bulk-delete` (OPERATOR+). VIEWER+ for reads and ack/read. Inbox SQL: `? = ANY(target_user_ids) OR target_group_ids && ? OR target_role_names && ?` — requires at least one matching target. +``` + +Also drop any `AlertReadRepository` bean mention from the `config/` or `storage/` sub-bullet. + +- [ ] **Step 2: Update `.claude/rules/core-classes.md`** — `AlertState` enum: + +``` +- `AlertState` — enum: PENDING, FIRING, RESOLVED. Ack is orthogonal (`acked_at` on `alert_instances`), not a state. +- `AlertInstanceRepository` — interface: save/findById/findOpenForRule (state IN ('PENDING','FIRING') AND deleted_at IS NULL); `listForInbox(env, groups, user, roles, states, severities, acked, read, limit)`; `countUnreadBySeverity(env, user, groups, roles)`; `ack`/`resolve`/`markSilenced`/`markRead`/`bulkMarkRead`/`softDelete`/`bulkSoftDelete`/`bulkAck`/`deleteResolvedBefore`/`listFiringDueForReNotify`. +``` + +Remove the `AlertReadRepository` bullet entirely. + +- [ ] **Step 3: Update `.claude/rules/ui.md`** — Alerts section: + +``` +- **Sidebar section** (`buildAlertsTreeNodes` in `ui/src/components/sidebar-utils.ts`) — Inbox, Rules, Silences. +- **Routes** in `ui/src/router.tsx`: `/alerts` (redirect to inbox), `/alerts/inbox`, `/alerts/rules`, `/alerts/rules/new`, `/alerts/rules/:id`, `/alerts/silences`. +- **Pages** under `ui/src/pages/Alerts/`: + - `InboxPage.tsx` — single filterable inbox. Filters: severity (multi), state (PENDING/FIRING/RESOLVED, default FIRING), Hide acked (default on), Hide read (default on). Row actions: Acknowledge, Mark read, Silence rule… (quick menu: 1h/8h/24h/Custom), Delete (OPERATOR+, soft-delete). Bulk toolbar: Ack N · Mark N read · Silence rules · Delete N (confirmation modal). + - `RulesListPage.tsx`, `RuleEditor/RuleEditorWizard.tsx` (unchanged). + - `SilencesPage.tsx` — matcher-based create + end-early. Reads `?ruleId=` search param to prefill the Rule ID field (used by InboxPage "Silence rule… → Custom"). + - `SilenceRuleMenu.tsx` (new) — popover duration picker used by row/bulk silence action. +``` + +- [ ] **Step 4: Update `CLAUDE.md`** — append to the V16 line: + +``` +- V17 — Alerts: drop ACKNOWLEDGED from AlertState (ack is now orthogonal via acked_at), add read_at + deleted_at (global, no per-user tracking), drop alert_reads table, rework V13/V15/V16 open-rule unique index predicate to `state IN ('PENDING','FIRING') AND deleted_at IS NULL`. +``` + +- [ ] **Step 5: Commit** + +```bash +git add .claude/rules/app-classes.md .claude/rules/core-classes.md .claude/rules/ui.md CLAUDE.md +git commit -m "docs(alerts): rules + CLAUDE.md — inbox redesign, V17 migration" +``` + +--- + +## Task 16: Final verification + +- [ ] **Step 1: `mvn clean verify`** — full backend build + ITs. Expected: green. +- [ ] **Step 2: `cd ui && npm run build`** — TS strict + bundle. Expected: green. +- [ ] **Step 3: `cd ui && npx vitest run`** — all UI tests. Expected: green. +- [ ] **Step 4: Grep sanity** — `grep -rn "ACKNOWLEDGED\|AlertReadRepository\|alert_reads" cameleer-server-app cameleer-server-core ui/src`. Expected: only this plan file + spec. +- [ ] **Step 5: Manual smoke**: + - `/alerts/inbox` default view shows only unread-firing alerts + - Toggle "Hide acked" OFF — acked rows appear + - Ack a row — it stays visible while "Hide acked" is OFF; disappears when re-toggled ON + - Click "Silence rule… → 1 hour" on a row — success toast, new silence visible on `/alerts/silences` + - Bell badge decreases after marking rows as read + - As VIEWER, Delete button is absent + - As OPERATOR, Delete button works, soft-deletes, confirmation modal on bulk +- [ ] **Step 6: Index refresh** — `npx gitnexus analyze --embeddings` to keep the code graph fresh for future sessions. + +--- + +## Self-review notes + +Covered spec sections: +- ✅ Data model changes → Task 1, 2 +- ✅ V17 migration (enum swap + columns + drop alert_reads + rework index) → Task 1 +- ✅ `AlertStateTransitions` ACKNOWLEDGED removal → Task 3 +- ✅ `AlertInstanceRepository` interface changes → Task 4 +- ✅ `PostgresAlertInstanceRepository` save/rowMapper/findOpenForRule/ack/listForInbox/countUnread/new mutations → Task 5 +- ✅ Drop `AlertReadRepository` + impl + bean → Task 4, 5 +- ✅ `AlertController` DELETE/bulk-delete/bulk-ack + acked/read filters + rewire read → Task 6 +- ✅ `InAppInboxQuery` new signature + countUnread → Task 6 +- ✅ `AlertDto.readAt` → Task 6 +- ✅ Test-fixture migration off ACKNOWLEDGED → Task 7 +- ✅ `SecurityConfig` matchers for new endpoints → Task 7 +- ✅ OpenAPI regen → Task 8 +- ✅ UI hooks: `useDeleteAlert`, `useBulkDeleteAlerts`, `useBulkAckAlerts`, `useAlerts` filter params → Task 9 +- ✅ Delete `AllAlertsPage` + `HistoryPage` + router + sidebar → Task 10 +- ✅ `SilenceRuleMenu` component → Task 11 +- ✅ `InboxPage` rewrite → Task 12 +- ✅ `SilencesPage` `?ruleId=` prefill → Task 13 +- ✅ UI tests → Task 14 +- ✅ Rules + `CLAUDE.md` → Task 15 +- ✅ Final verification → Task 16