Files
cameleer-server/docs/superpowers/plans/2026-04-21-alerts-inbox-redesign.md
hsiegeln 70bf59daca docs(alerts): implementation plan — inbox redesign (16 tasks)
16 TDD tasks covering V17 migration (drop ACKNOWLEDGED + add read_at/deleted_at +
drop alert_reads + rework open-rule index), backend repo/controller/endpoints
including /restore for undo-toast backing, OpenAPI regen, UI rebuild (single
filterable inbox, row/bulk actions, silence-rule quick menu, SilencesPage
?ruleId= prefill), concrete test bodies, and rules/CLAUDE.md updates.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 16:56:53 +02:00

66 KiB

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 Testcontainersmvn -pl cameleer-server-app -Dtest=<ClassName> test. The suite already bootstraps Postgres via @SpringBootTest.
  2. Commit per task. Commit message format: feat(alerts): <what> / refactor(alerts): <what> / test(alerts): <what>. 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

-- 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
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-migrationmvn -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

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:

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:
public record AlertInstance(
        UUID id,
        UUID ruleId,
        Map<String, Object> 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<String, Object> context,
        String title,
        String message,
        List<String> targetUserIds,
        List<UUID> targetGroupIds,
        List<String> 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 modulemvn -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 testsmvn -pl cameleer-server-core test. Expected: any direct AlertState.ACKNOWLEDGED references in AlertScopeTest → drop them or replace with FIRING.

  • Step 5: Commit

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:

@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<AlertInstance> 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:

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 suitemvn -pl cameleer-server-app -Dtest='*Alert*' test. Many still RED — fixed in later tasks.

  • Step 6: Commit

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:

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<AlertInstance> findById(UUID id);

    /** Open instance for a rule: state IN ('PENDING','FIRING') AND deleted_at IS NULL. */
    Optional<AlertInstance> findOpenForRule(UUID ruleId);

    /** Unfiltered inbox listing — convenience overload. */
    default List<AlertInstance> listForInbox(UUID environmentId,
                                             List<String> userGroupIdFilter,
                                             String userId,
                                             List<String> 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<AlertInstance> listForInbox(UUID environmentId,
                                     List<String> userGroupIdFilter,
                                     String userId,
                                     List<String> userRoleNames,
                                     List<AlertState> states,
                                     List<AlertSeverity> 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<AlertSeverity, Long> countUnreadBySeverity(UUID environmentId,
                                                    String userId,
                                                    List<String> groupIds,
                                                    List<String> 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<UUID> 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<UUID> 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<UUID> ids, String userId, Instant when);

    List<AlertInstance> 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.javarm 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 breakagemvn -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

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:

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:
@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:
// 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:
@Override
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 = """
        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<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, groupArray, roleArray);
    return counts;
}
  • Step 6: Implement new mutation methods:
@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);
}
  • Step 7: Update rowMapper — read read_at + deleted_at:
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:
git rm cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/storage/PostgresAlertReadRepository.java

In AlertingBeanConfig.java remove:

@Bean
public AlertReadRepository alertReadRepository(JdbcTemplate jdbc) { ... }

Remove the AlertReadRepository import too.

  • Step 9: Update PostgresAlertInstanceRepositoryIT — add tests:
@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 ITmvn -pl cameleer-server-app -Dtest=PostgresAlertInstanceRepositoryIT test. Expected: all green.

  • Step 11: Commit

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:

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<String, Object> 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:
public List<AlertInstance> listInbox(UUID envId,
                                     String userId,
                                     List<AlertState> states,
                                     List<AlertSeverity> severities,
                                     Boolean acked,
                                     Boolean read,
                                     int limit) {
    List<String> groupIds   = resolveGroupIds(userId);
    List<String> 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<String> groupIds  = resolveGroupIds(userId);
    List<String> roleNames = resolveRoleNames(userId);
    Map<AlertSeverity, Long> 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:
@GetMapping
public List<AlertDto> list(
        @EnvPath Environment env,
        @RequestParam(defaultValue = "50") int limit,
        @RequestParam(required = false) List<AlertState> state,
        @RequestParam(required = false) List<AlertSeverity> 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<UUID> 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<UUID> filtered = inEnvIds(req.instanceIds(), env.id());
    if (!filtered.isEmpty()) instanceRepo.bulkAck(filtered, currentUserId(), Instant.now());
}

@DeleteMapping("/{id}")
@PreAuthorize("hasAnyRole('OPERATOR','ADMIN')")
public ResponseEntity<Void> 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<UUID> filtered = inEnvIds(req.instanceIds(), env.id());
    if (!filtered.isEmpty()) instanceRepo.bulkSoftDelete(filtered, Instant.now());
}

@PostMapping("/{id}/restore")
@PreAuthorize("hasAnyRole('OPERATOR','ADMIN')")
public ResponseEntity<Void> 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<UUID> inEnvIds(List<UUID> ids, UUID envId) {
    return ids.stream()
        .filter(id -> instanceRepo.findById(id)
            .map(i -> i.environmentId().equals(envId) && i.deletedAt() == null)
            .orElse(false))
        .toList();
}

Rename BulkReadRequestBulkIdsRequest (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:
@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: Runmvn -pl cameleer-server-app -Dtest=AlertControllerIT test. Expected: green.

  • Step 6: Commit

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==ACKNOWLEDGEDackedAt != null.

  • Step 2: Update SecurityConfig matchers — add matchers for the new endpoints:
// 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 suitemvn -pl cameleer-server-app -Dtest='*Alert*' test. Expected: green.

  • Step 5: Full buildmvn clean verify. Expected: green.

  • Step 6: Commit

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 backendjava -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

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 appeargrep -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 breakagescd 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

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:

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:

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

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

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:

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-Kgrep -n "alerts/all\|alerts/history" ui/src. Expected: no hits. buildAlertSearchData in LayoutShell.tsx deep-links to /alerts/inbox/{id} regardless.

  • Step 5: Run typecheckcd ui && npx tsc --noEmit. Expected: only InboxPage.tsx errors left (next task).

  • Step 6: Commit

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:

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<HTMLButtonElement>(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 (
    <>
      <Button ref={anchor} size="sm" variant="ghost" onClick={() => setOpen(true)}>
        <BellOff size={14} /> {variant === 'bulk' ? 'Silence rules' : 'Silence rule…'}
      </Button>
      <Popover open={open} onClose={() => setOpen(false)} anchor={anchor}>
        <div style={{ display: 'flex', flexDirection: 'column', padding: 8, minWidth: 160 }}>
          {PRESETS.map((p) => (
            <Button
              key={p.hours}
              size="sm"
              variant="ghost"
              onClick={() => createWithDuration(p.hours)}
              disabled={create.isPending}
            >
              {p.label}
            </Button>
          ))}
          <Button
            size="sm"
            variant="ghost"
            onClick={() => {
              setOpen(false);
              navigate(`/alerts/silences?ruleId=${encodeURIComponent(ruleId)}`);
            }}
          >
            Custom
          </Button>
        </div>
      </Popover>
    </>
  );
}

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

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:

// Filter state:
const [severitySel, setSeveritySel] = useState<Set<string>>(new Set());
const [stateSel,    setStateSel]    = useState<Set<string>>(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):

<div className={css.pageActions}>
  <ButtonGroup items={SEVERITY_ITEMS} value={severitySel} onChange={setSeveritySel} />
  <ButtonGroup items={STATE_ITEMS}    value={stateSel}    onChange={setStateSel} />
  <Toggle label="Hide acked" checked={hideAcked} onChange={setHideAcked} />
  <Toggle label="Hide read"  checked={hideRead}  onChange={setHideRead} />
</div>

STATE_ITEMS omits ACKNOWLEDGED:

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):

{
  key: 'actions', header: '', width: '240px',
  render: (_, row) => (
    <div style={{ display: 'flex', gap: 4, justifyContent: 'flex-end' }}>
      {row.ackedAt == null && (
        <Button size="sm" variant="secondary" onClick={() => onAck(row.id, row.title)}>
          Acknowledge
        </Button>
      )}
      {row.readAt == null && (
        <Button size="sm" variant="ghost" onClick={() => markRead.mutate(row.id)}>
          Mark read
        </Button>
      )}
      {row.ruleId && (
        <SilenceRuleMenu ruleId={row.ruleId} ruleTitle={row.title ?? undefined} />
      )}
      {canDelete && (
        <Button size="sm" variant="ghost" onClick={() => onDeleteOne(row.id)}>
          <Trash2 size={14} />
        </Button>
      )}
    </div>
  ),
},

canDelete gated via useAuthStore() role check: roles.includes('OPERATOR') || roles.includes('ADMIN').

Single delete with undo toast (5s) — backed by useRestoreAlert:

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 <Button>Undo</Button> inside the toast description ReactNode — check @cameleer/design-system toast signature before choosing.

Bulk toolbar (the existing filterBar section):

{selectedIds.length > 0 ? (
  <>
    <Button size="sm" variant="primary"
            onClick={() => onBulkAck(selectedUnackedIds)}
            disabled={selectedUnackedIds.length === 0}>
      Acknowledge {selectedUnackedIds.length}
    </Button>
    <Button size="sm" variant="secondary"
            onClick={() => onBulkRead(selectedUnreadIds)}
            disabled={selectedUnreadIds.length === 0}>
      Mark {selectedUnreadIds.length} read
    </Button>
    <SilenceRulesForSelection ids={selectedIds} rows={rows} />
    {canDelete && (
      <Button size="sm" variant="danger" onClick={() => setDeletePending(selectedIds)}>
        Delete {selectedIds.length}
      </Button>
    )}
  </>
) : ( /* existing "Acknowledge all firing" + "Mark all read" */ )}

Bulk-delete confirmation modal:

<ConfirmDialog
  open={deletePending != null}
  onClose={() => 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 ruleIds, 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 runcd 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

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

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

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.

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(
    <QueryClientProvider client={qc}>
      <MemoryRouter initialEntries={['/alerts/inbox']}>
        <InboxPage />
      </MemoryRouter>
    </QueryClientProvider>,
  );
}

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: Runcd ui && npx vitest run src/pages/Alerts/InboxPage.test.tsx. Green.

  • Step 3: Commit

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.mdAlertState 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
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 sanitygrep -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 refreshnpx 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