Skip to content

feat(alerts): two-tier integrations — global credentials + per-rule routing#85

Open
AbianS wants to merge 3 commits into
mainfrom
feat/alert-two-tier-integrations
Open

feat(alerts): two-tier integrations — global credentials + per-rule routing#85
AbianS wants to merge 3 commits into
mainfrom
feat/alert-two-tier-integrations

Conversation

@AbianS
Copy link
Copy Markdown
Owner

@AbianS AbianS commented May 21, 2026

Summary

  • Introduces alert_integrations table (global credentials only) replacing notification_channels; adds routing_override JSONB to alert_rule_channels junction for per-rule routing
  • Renames API routes from /api/alert-channels/api/integrations
  • Implements validate_routing_override enforcing provider-specific routing rules (Slack bot_token requires channel, Email requires recipients with @, Webhook requires a URL somewhere)
  • POST /api/integrations/{id}/test now accepts optional { routing_override } body and validates before dispatch
  • Migrates existing data non-destructively (Postgres + SQLite migrations); alert_history.channel_id renamed to integration_id, FKs preserved
  • Renames AlertRuleResponse.channel_idsintegration_ids; deduplicates channels before INSERT
  • Frontend + client package update deferred (D-11, D-12)

Test plan

  • cargo build — zero compile errors
  • cargo test — all tests pass (149 unit tests, 95 integration, 71 ignored)
  • cargo clippy -- -D warnings — zero warnings
  • sqlx migrate run on a DB with existing notification_channels data completes without error
  • GET /api/integrations/{id} for Slack bot_token returns "token": "xoxb-****"
  • POST /api/projects/{id}/alert-rules with Slack bot_token + routing_override:{channel:"#fe"} returns 201
  • POST /api/projects/{id}/alert-rules with Webhook and no URL in credentials or routing returns 422
  • POST /api/integrations/{id}/test with optional routing_override body works end-to-end
  • Disabled integration (is_enabled: false) does not receive dispatched alerts

Summary by CodeRabbit

Release Notes

  • New Features

    • Redesigned alert configuration system with separation of global provider integrations from per-rule routing settings, enabling centralized credential management and per-rule customization.
  • API Changes

    • Renamed alert channel endpoints from /api/alert-channels to /api/integrations with updated request/response schemas.
    • Alert rules now support per-rule routing overrides for customized channel behavior per integration.

Review Change Stack

AbianS added 3 commits May 21, 2026 13:45
…outing

Introduce alert_integrations (global provider credentials) and routing_override
JSONB on alert_rule_channels so one Slack bot token or SMTP config can serve
multiple projects/channels without duplication. Non-destructive migration
extracts credentials from notification_channels, backfills routing_override per
provider type, renames channel_id→integration_id in alert_rule_channels and
alert_history, then drops notification_channels. API routes renamed from
/api/alert-channels to /api/integrations; dispatcher send() updated to accept
(integration, routing, payload); 422 validation enforced at rule-create time.
- Add validate_routing_override(provider_type, credentials, routing):
  Slack bot_token requires non-empty channel; Email requires non-empty
  recipients with '@'; Webhook requires URL in credentials or routing,
  rejects non-http/https routing URL
- Call validate in create_rule, update_rule, and test_channel handlers
  (test_channel now accepts optional TestIntegrationBody with routing_override)
- Rename AlertRuleResponse.channel_ids → integration_ids
- Dedup integration_id before INSERT in create_rule and update_rule (ECH-4)
- Spec: specLoopIteration 3, SCL-2 changelog entry
- Deferred: append D-15 (header injection), D-16 (credential exposure),
  D-17 (SMTP recipient cap), D-18 (retry counter)
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 21, 2026

Walkthrough

This PR refactors the alert notification system from a single notification_channels model to a two-tier architecture: global alert_integrations for provider credentials plus per-rule routing_override JSON in alert_rule_channels for routing decisions. Database migrations (PostgreSQL/SQLite) establish the new schema. Models introduce ProviderType, AlertIntegration, and flat routing override structs. The API renames /api/alert-channels to /api/integrations with updated schemas. Alert service CRUD and dispatch logic refactor to manage integrations and validate routing overrides. Notification dispatchers (Email/Slack/Webhook) now accept integration + routing parameters and implement provider-specific routing behavior. Tests are updated throughout to reflect the new model.

Changes

Alert Two-Tier Integrations Architecture

Layer / File(s) Summary
Design specification and investigation
_bmad-output/implementation-artifacts/investigations/alert-two-tier-config-investigation.md, _bmad-output/implementation-artifacts/spec-alert-two-tier-integrations.md, _bmad-output/implementation-artifacts/deferred-work.md
Design documents consolidate evidence and requirements for the two-tier split: global credentials (integrations) + per-rule routing overrides. Deferred work logs capture follow-up findings from review loops (client package updates, frontend UI changes, security/validation concerns).
Database schema migrations
apps/server/migrations/postgres/20260521131726_alert_integrations.up.sql, apps/server/migrations/postgres/20260521131726_alert_integrations.down.sql, apps/server/migrations/sqlite/...
Both Postgres and SQLite migrations create alert_integrations table, add routing_override to alert_rule_channels, rename channel_id to integration_id in dependent tables, update foreign keys, and drop legacy notification_channels. Down migrations fully reverse the schema.
Data models and type definitions
apps/server/src/models/alert.rs
Introduces ProviderType enum (replaces ChannelType), AlertIntegration struct (replaces NotificationChannel), flat routing override structs for Slack/Email/Webhook without provider discriminators, AlertRuleChannel junction with routing overrides, and updates rule/history models to use integration_id. Includes legacy type aliases for compatibility.
OpenAPI schema and HTTP endpoint definitions
apps/server/openapi.json
Renames API endpoints from /api/alert-channels to /api/integrations, updates request/response schemas to use AlertIntegration and AlertRuleChannelInput, adds TestIntegrationBody for optional routing override testing, adds 422 validation error responses, removes legacy NotificationChannel schemas.
HTTP route handlers and validation logic
apps/server/src/routes/alerts.rs
Implements /api/integrations endpoints (list/get/create/patch/delete), adds validate_routing_override function enforcing provider-specific constraints (Slack channel presence, Email recipients, Webhook URL format), updates test endpoint to accept optional routing override, validates routing overrides in rule handlers before creation/update.
Alert service integration CRUD and rule-channel management
apps/server/src/services/alert.rs (lines 1–236)
Refactors AlertService to manage alert_integrations instead of notification_channels, implements rule-channel linking via alert_rule_channels with integration_id and routing_override, adds deduplication by integration ID, fetches enabled integrations for alert dispatch, updates rule creation/update to handle both new channels input and legacy channel_ids.
Alert dispatch orchestration and history tracking
apps/server/src/services/alert.rs (lines 237–846)
Updates alert triggering to fetch enabled AlertRuleChannel records joined against enabled integrations, refactors dispatch loop to spawn parallel tasks per rule-channel integration, changes history idempotency to use alert_id + integration_id, switches post-dispatch stats updates from notification_channels to alert_integrations, updates retry queue to use integration-linked fields.
Email/Slack/Webhook notification dispatchers refactored for routing overrides
apps/server/src/services/notification/mod.rs, apps/server/src/services/notification/email.rs, apps/server/src/services/notification/slack.rs, apps/server/src/services/notification/webhook.rs
Updates NotificationDispatcher trait to accept AlertIntegration + routing_override JSON + payload. Email notifier extracts recipients from flat routing, builds SMTP transport with global fallbacks. Slack notifier parses routing override and applies per-message fields over credentials. Webhook notifier selects effective URL from routing or credentials and applies routing extra_headers. All dispatchers short-circuit when integration.is_enabled is false.
Integration and unit tests updated for two-tier model
apps/server/tests/integration/alerts_api_test.rs, apps/server/tests/unit/notification_test.rs
Updates all alert/dispatcher tests to use provider_type/credentials instead of channel_type/config, adds channels fields to rule fixtures, adds tests for disabled integration skip behavior, relaxes validation expectations (bot-token channel no longer required, email recipients moved to routing, webhook URL optional in credentials).
Public type re-exports
apps/server/src/models/mod.rs
Expands public re-exports to include AlertIntegration, AlertRuleChannel, AlertRuleChannelInput, routing override structs, ProviderType, and corresponding create/update DTOs; maintains legacy ChannelType alias.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Possibly related PRs

  • AbianS/rustrak#19: Implements the initial alerts feature and notification-channel-based architecture on which this PR's two-tier refactor builds.
  • AbianS/rustrak#71: Modifies the Slack configuration path in alert models and dispatchers, directly overlapping with this PR's Slack bot-token routing override work.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The pull request title clearly and specifically describes the main feature: a refactoring of the alert system architecture to use a two-tier model with global credentials and per-rule routing overrides.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/alert-two-tier-integrations

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented May 21, 2026

Greptile Summary

This PR introduces a two-tier integration model: global alert_integrations stores provider credentials only, while per-rule routing moves into routing_override JSONB on alert_rule_channels. Routes are renamed from /api/alert-channels to /api/integrations, and both Postgres and SQLite migrations migrate existing data non-destructively.

  • Routing validation (validate_routing_override) enforces provider-specific constraints but only when clients use the new channels field; the legacy channel_ids path skips validation, leaving Email integrations reachable with empty recipient lists that dispatch silently.
  • All three dispatchers gain an is_enabled short-circuit and now accept a flat routing JSON argument alongside the integration record.
  • get_rule_channel_records was introduced as a helper but is never called \u2014 fire_alert inlines the same query directly.

Confidence Score: 3/5

Not safe to merge without addressing the silent Email dispatch failure on the legacy channel_ids path.

The core two-tier model is well-designed and migrations look correct, but validate_routing_override is only called for the new channels request field. Any caller using channel_ids with an Email integration stores an empty recipients list and receives a success response without any email being sent.

apps/server/src/routes/alerts.rs (create_rule and update_rule handlers need validation on the legacy channel_ids path) and apps/server/src/services/notification/email.rs (send_to_recipients always returns success).

Important Files Changed

Filename Overview
apps/server/src/routes/alerts.rs Adds validate_routing_override but only applies it to the new channels field; legacy channel_ids path is unvalidated, causing silent email dispatch failures.
apps/server/src/services/alert.rs Migrates CRUD and dispatch to AlertIntegration/AlertRuleChannel; get_rule_channel_records is dead code — fire_alert inlines the same query.
apps/server/src/services/notification/email.rs Recipients moved to routing_override; send_to_recipients always returns success regardless of whether any email was sent.
apps/server/src/services/notification/slack.rs Channel moved to routing_override with credential fallback; is_enabled guard added; override priority correct.
apps/server/src/services/notification/webhook.rs URL and extra headers can come from routing_override; effective_url priority correct; validate_config correctly relaxes URL requirement.
apps/server/migrations/postgres/20260521131726_alert_integrations.up.sql Non-destructive migration with correct operation ordering: backfills routing_override before dropping notification_channels.
apps/server/migrations/sqlite/20260521131726_alert_integrations.up.sql SQLite table-recreation pattern; json_patch null semantics match Postgres jsonb_strip_nulls correctly.

Sequence Diagram

sequenceDiagram
    participant Client
    participant Route as alerts.rs
    participant Service as AlertService
    participant DB
    participant Dispatcher as Notifier

    Note over Client,Dispatcher: New channels field path
    Client->>Route: "{channels: [{integration_id, routing_override}]}"
    Route->>DB: get_channel(integration_id)
    DB-->>Route: AlertIntegration
    Route->>Route: validate_routing_override()
    Route->>Service: create_rule(body)
    Service->>DB: INSERT alert_rule_channels (routing_override)

    Note over Client,Dispatcher: Legacy channel_ids path
    Client->>Route: "{channel_ids: [1]}"
    Route->>Route: validation skipped
    Route->>Service: create_rule(body)
    Service->>DB: "INSERT alert_rule_channels (routing_override={})"

    Note over Client,Dispatcher: Alert firing
    Service->>DB: SELECT integration_id, routing_override WHERE is_enabled
    DB-->>Service: Vec AlertRuleChannel
    loop for each rule_channel
        Service->>DB: get_channel(integration_id)
        DB-->>Service: AlertIntegration
        Service->>Dispatcher: send(integration, routing_override, payload)
        Dispatcher-->>Service: NotificationResult
        Service->>DB: UPDATE alert_integrations and alert_history
    end
Loading

Comments Outside Diff (1)

  1. apps/server/src/services/alert.rs, line 241-258 (link)

    P2 get_rule_channel_records is defined but never called

    This method centralises the fetch of AlertRuleChannel records for dispatch, but fire_alert inlines an identical query directly rather than calling it. The public method is unreachable dead code that will trigger a Clippy warning.

Reviews (1): Last reviewed commit: "fix(alerts): wire validate_routing_overr..." | Re-trigger Greptile

Comment on lines 392 to +407
// Verify project exists
let _ = ProjectService::get_by_id(pool.get_ref(), project_id).await?;

let rule = AlertService::create_rule(pool.get_ref(), project_id, body.into_inner()).await?;
let channel_ids = AlertService::get_rule_channels(pool.get_ref(), rule.id).await?;
let body = body.into_inner();

// Validate routing_override for each channel before linking
for ch in &body.channels {
let integration = AlertService::get_channel(pool.get_ref(), ch.integration_id).await?;
validate_routing_override(
integration.provider_type,
&integration.credentials,
&ch.routing_override,
)?;
}

let rule = AlertService::create_rule(pool.get_ref(), project_id, body).await?;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Legacy channel_ids path bypasses routing validation — Email dispatches silently with no recipients

In both create_rule and update_rule, routing validation only runs over body.channels. When a caller submits the backward-compat field channel_ids: [1] (with channels absent or empty), the validation loop is skipped entirely. The service maps each legacy ID to routing_override: {}, which for an Email integration deserializes to EmailRoutingOverride { recipients: [] }. send_to_recipients with an empty slice loops zero times and returns NotificationResult::success(None) — the alert appears dispatched but no email is ever sent.

Comment on lines 326 to 330

NotificationResult::success(None)
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 sent_any is computed but never checked — function always returns success

send_to_recipients tracks sent_any across the per-recipient loop but unconditionally returns NotificationResult::success(None) regardless of its value. A total SMTP failure still returns success, preventing history records from being marked failed and suppressing retry logic.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (5)
apps/server/tests/unit/notification_test.rs (1)

188-199: 💤 Low value

Duplicate of test_slack_bot_token_validate_config_valid.

test_slack_bot_token_validate_config_channel_is_routing_concern exercises the same path as test_slack_bot_token_validate_config_valid (line 162): a bot_token config with no channel field is expected to validate Ok. Only the token literal differs. Consider folding this into the existing valid test (or adding a meaningfully distinct case, e.g., an explicit "channel": "" or extra unknown field, to assert that channel is genuinely ignored rather than just absent).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/server/tests/unit/notification_test.rs` around lines 188 - 199, The test
test_slack_bot_token_validate_config_channel_is_routing_concern duplicates
test_slack_bot_token_validate_config_valid; remove the duplicate or change it to
exercise a distinct behavior: either delete
test_slack_bot_token_validate_config_channel_is_routing_concern, or modify it to
include an explicit `"channel": ""` (or another extra unknown field) in the
config JSON and assert dispatcher.validate_config(&config).is_ok() to prove
channel is ignored; locate the test by the function name and the use of
create_dispatcher(ChannelType::Slack) and validate_config to make the change.
apps/server/migrations/sqlite/20260521131726_alert_integrations.up.sql (1)

74-87: 💤 Low value

Minor cosmetic difference from Postgres migration in Slack routing override backfill.

The SQLite version uses json_patch('{}', json_object('channel', ..., 'username', ..., 'icon_emoji', ...)) which will include explicit null values (e.g. {"channel":null,"username":null,"icon_emoji":null}) when the source config fields are null, whereas the Postgres version uses jsonb_strip_nulls(jsonb_build_object(...)) to produce {} in the same scenario.

Impact: This is a cosmetic difference only. The Rust routing override structs use #[serde(default)] on all fields, so deserializing {"channel":null} and {} both produce None for the channel field. The behavior is functionally identical.

🔧 Optional: Match Postgres behavior by filtering nulls

If you want exact JSON parity between databases, you could wrap the json_object result in a custom null-filtering expression, but SQLite doesn't have a built-in equivalent to jsonb_strip_nulls. Since this doesn't affect functionality, it's safe to leave as-is.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/server/migrations/sqlite/20260521131726_alert_integrations.up.sql`
around lines 74 - 87, Replace the Slack branch that currently returns
json_patch('{}', json_object(...)) with an expression that builds the
json_object into a temporary value and then removes keys whose values are null
so the result matches Postgres' jsonb_strip_nulls; specifically, in the CASE
branch that checks nc.channel_type = 'slack' and json_extract(nc.config,
'$.method') = 'bot_token', build obj := json_object('channel',
json_extract(nc.config, '$.channel'), 'username', json_extract(nc.config,
'$.username'), 'icon_emoji', json_extract(nc.config, '$.icon_emoji')) and then
apply json_remove(obj, '$.channel') if json_extract(obj, '$.channel') IS NULL
(and similarly for '$.username' and '$.icon_emoji') so null-valued keys are
removed rather than serialized as null.
apps/server/openapi.json (2)

2886-2892: ⚡ Quick win

TestIntegrationBody.routing_override lacks a type definition.

The routing_override property has no type specified (just an empty object {}). This should include "type": "object" or "type": ["object", "null"] for proper schema validation and client generation.

Suggested fix
       "TestIntegrationBody": {
         "type": "object",
         "description": "Request body for test endpoint (routing_override is optional)",
         "properties": {
-          "routing_override": {}
+          "routing_override": {
+            "type": ["object", "null"]
+          }
         }
       },
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/server/openapi.json` around lines 2886 - 2892, The TestIntegrationBody
schema defines routing_override with an empty object; update the property so it
has an explicit type (e.g., add "type": "object" or "type": ["object", "null"]
if nullable) to enable proper validation and client generation; locate
TestIntegrationBody -> properties -> routing_override in the OpenAPI JSON and
add the chosen type and any needed "additionalProperties" or "properties"
constraints as appropriate.

22-22: 💤 Low value

Operation IDs still reference "channel" terminology.

The paths have been renamed to /api/integrations but the operationId values still use the old naming (list_channels, create_channel, get_channel, etc.). This inconsistency may confuse SDK generators and API consumers.

Also applies to: 59-59, 115-115, 171-171, 220-220, 288-288

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/server/openapi.json` at line 22, OperationId values still use "channel"
names (e.g., "list_channels", "create_channel", "get_channel", "update_channel",
"delete_channel") while the paths were renamed to /api/integrations; update each
operationId to match the new resource naming (e.g., "list_integrations",
"create_integration", "get_integration", "update_integration",
"delete_integration") so SDK generators and consumers are consistent—locate the
operationId fields in the OpenAPI JSON (the entries currently set to
"list_channels", "create_channel", "get_channel", etc.) and replace them with
the corresponding "integration" variants.
apps/server/src/routes/alerts.rs (1)

157-166: 💤 Low value

OpenAPI tags still reference "Alert Channels" but paths use /api/integrations.

The tag attribute in utoipa path macros still says "Alert Channels" while the actual paths have been renamed to /api/integrations. This will create inconsistent grouping in generated API documentation.

Also applies to: 174-183, 195-205, 217-228, 242-252, 264-274

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/server/src/routes/alerts.rs` around lines 157 - 166, The utoipa::path
attribute tags still read "Alert Channels" while the route paths are under
"/api/integrations"; update the tag string in each utoipa::path attribute that
targets "/api/integrations" to a consistent value like "Integrations" (e.g.,
change the tag in the utoipa::path macros attached to the GET/POST/PUT/DELETE
handlers for the integrations endpoints in alerts.rs so all occurrences that
currently say "Alert Channels" instead say "Integrations").
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/server/openapi.json`:
- Around line 91-100: The OpenAPI contract contains an incorrect 422 response
for POST /api/integrations (create_channel); remove the "422 Invalid routing
override" response block from apps/server/openapi.json for that operation (or
change it to the correct 400 mapping) so the schema matches the create_channel
utoipa annotation and the AppError::Validation -> 400 mapping; ensure the
operation only references the existing 400 ErrorResponse and that no
routing_override validation response remains.

In `@apps/server/src/services/notification/slack.rs`:
- Around line 138-167: format_bot_message currently inserts an empty string for
"channel" when both routing.channel and creds.channel are None, which allows a
request to be sent to Slack and elicit a channel_not_found error; update the
dispatch to fail fast: in send_bot_token (and the similar dispatcher used around
lines 289-324) check for a resolved channel by computing the same precedence
used in format_bot_message (routing.channel.or(creds.channel)) and return a
clear error (rather than sending) when that result is None or empty; keep
format_bot_message as a pure formatter but remove the unwrap_or_default() usage
or make it return Result so the caller can enforce the presence of a channel and
surface a descriptive error to the caller before any HTTP request is made.

In `@apps/server/tests/unit/notification_test.rs`:
- Around line 220-228: The test test_email_validate_config_no_smtp_host_fails is
flaky because it doesn't ensure SMTP_HOST is unset or hold SMTP_ENV_LOCK for the
test duration; change the test to acquire the SMTP_ENV_LOCK for the whole test
and explicitly remove SMTP_HOST from the environment before calling
dispatcher.validate_config so the environment state is deterministic. Also
update SmtpHostGuard so it actually owns the MutexGuard (store a field like
_lock: std::sync::MutexGuard<'static, ()> in the struct returned by
SmtpHostGuard::set and SmtpHostGuard::unset) so the lock is held while the guard
exists, and adjust callers (including test_email_validate_config_valid and
test_email_validate_config_recipients_are_routing_concern) to use the guard's
lifetime to serialize access to the SMTP_HOST env var.

---

Nitpick comments:
In `@apps/server/migrations/sqlite/20260521131726_alert_integrations.up.sql`:
- Around line 74-87: Replace the Slack branch that currently returns
json_patch('{}', json_object(...)) with an expression that builds the
json_object into a temporary value and then removes keys whose values are null
so the result matches Postgres' jsonb_strip_nulls; specifically, in the CASE
branch that checks nc.channel_type = 'slack' and json_extract(nc.config,
'$.method') = 'bot_token', build obj := json_object('channel',
json_extract(nc.config, '$.channel'), 'username', json_extract(nc.config,
'$.username'), 'icon_emoji', json_extract(nc.config, '$.icon_emoji')) and then
apply json_remove(obj, '$.channel') if json_extract(obj, '$.channel') IS NULL
(and similarly for '$.username' and '$.icon_emoji') so null-valued keys are
removed rather than serialized as null.

In `@apps/server/openapi.json`:
- Around line 2886-2892: The TestIntegrationBody schema defines routing_override
with an empty object; update the property so it has an explicit type (e.g., add
"type": "object" or "type": ["object", "null"] if nullable) to enable proper
validation and client generation; locate TestIntegrationBody -> properties ->
routing_override in the OpenAPI JSON and add the chosen type and any needed
"additionalProperties" or "properties" constraints as appropriate.
- Line 22: OperationId values still use "channel" names (e.g., "list_channels",
"create_channel", "get_channel", "update_channel", "delete_channel") while the
paths were renamed to /api/integrations; update each operationId to match the
new resource naming (e.g., "list_integrations", "create_integration",
"get_integration", "update_integration", "delete_integration") so SDK generators
and consumers are consistent—locate the operationId fields in the OpenAPI JSON
(the entries currently set to "list_channels", "create_channel", "get_channel",
etc.) and replace them with the corresponding "integration" variants.

In `@apps/server/src/routes/alerts.rs`:
- Around line 157-166: The utoipa::path attribute tags still read "Alert
Channels" while the route paths are under "/api/integrations"; update the tag
string in each utoipa::path attribute that targets "/api/integrations" to a
consistent value like "Integrations" (e.g., change the tag in the utoipa::path
macros attached to the GET/POST/PUT/DELETE handlers for the integrations
endpoints in alerts.rs so all occurrences that currently say "Alert Channels"
instead say "Integrations").

In `@apps/server/tests/unit/notification_test.rs`:
- Around line 188-199: The test
test_slack_bot_token_validate_config_channel_is_routing_concern duplicates
test_slack_bot_token_validate_config_valid; remove the duplicate or change it to
exercise a distinct behavior: either delete
test_slack_bot_token_validate_config_channel_is_routing_concern, or modify it to
include an explicit `"channel": ""` (or another extra unknown field) in the
config JSON and assert dispatcher.validate_config(&config).is_ok() to prove
channel is ignored; locate the test by the function name and the use of
create_dispatcher(ChannelType::Slack) and validate_config to make the change.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 5cbb459e-4b47-4e7a-ab68-1e03d294406e

📥 Commits

Reviewing files that changed from the base of the PR and between 384671d and 1d6d457.

📒 Files selected for processing (18)
  • _bmad-output/implementation-artifacts/deferred-work.md
  • _bmad-output/implementation-artifacts/investigations/alert-two-tier-config-investigation.md
  • _bmad-output/implementation-artifacts/spec-alert-two-tier-integrations.md
  • apps/server/migrations/postgres/20260521131726_alert_integrations.down.sql
  • apps/server/migrations/postgres/20260521131726_alert_integrations.up.sql
  • apps/server/migrations/sqlite/20260521131726_alert_integrations.down.sql
  • apps/server/migrations/sqlite/20260521131726_alert_integrations.up.sql
  • apps/server/openapi.json
  • apps/server/src/models/alert.rs
  • apps/server/src/models/mod.rs
  • apps/server/src/routes/alerts.rs
  • apps/server/src/services/alert.rs
  • apps/server/src/services/notification/email.rs
  • apps/server/src/services/notification/mod.rs
  • apps/server/src/services/notification/slack.rs
  • apps/server/src/services/notification/webhook.rs
  • apps/server/tests/integration/alerts_api_test.rs
  • apps/server/tests/unit/notification_test.rs

Comment thread apps/server/openapi.json
Comment on lines +91 to 100
"422": {
"description": "Invalid routing override",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check if create_channel handler in routes/alerts.rs calls validate_routing_override
rg -n -A 20 'pub async fn create_channel' apps/server/src/routes/alerts.rs | head -40

Repository: AbianS/rustrak

Length of output: 965


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== 1) Find which /api/integrations operation includes 422 Invalid routing override =="
rg -n --context=3 '"422"\s*:\s*\{' apps/server/openapi.json | head -n 40
rg -n --context=3 '"/api/integrations"' apps/server/openapi.json | head -n 80

echo
echo "== 2) Find the Rust route handler(s) for /api/integrations =="
rg -n --context=3 '(/api/integrations|\"/api/integrations|path = "/api/integrations' apps/server/src/routes apps/server/src -S || true

echo
echo "== 3) Find all routing override validation call sites =="
rg -n --context=2 'validate_routing_override' apps/server/src || true
rg -n --context=2 'Invalid routing override' apps/server/src || true

Repository: AbianS/rustrak

Length of output: 8798


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== A) Show surrounding OpenAPI context for each 422 Invalid routing override occurrence =="
# Print ~40 lines around each match, including the likely surrounding operation block.
rg -n '"422"\s*:\s*\{\s*' apps/server/openapi.json | rg -n '"description"\s*:\s*"Invalid routing override"' -n >/dev/null 2>&1 || true
# Directly fetch context blocks around the description text.
rg -n --context=18 '"description"\s*:\s*"Invalid routing override"' apps/server/openapi.json

echo
echo "== B) Locate any Rust route definitions containing /api/integrations =="
rg -n '\/api\/integrations' apps/server/src/routes apps/server/src -S || true

echo
echo "== C) Specifically search for POST /api/integrations in Rust (UTOIPA attributes or route macros) =="
rg -n 'post,\s*\n\s*path\s*=\s*"/api/integrations|path\s*=\s*"/api/integrations".*post|path\s*=\s*"/api/integrations"' apps/server/src/routes apps/server/src -S || true

echo
echo "== D) Search for error mapping/response code 422 in openapi near integrations section =="
# Find the integrations path and scan for 422 status blocks within its operation range.
start=$(rg -n '"\/api\/integrations"' apps/server/openapi.json | head -n1 | cut -d: -f1)
echo "First /api/integrations occurrence at line: $start"
sed -n "${start},$((start+260))p" apps/server/openapi.json | rg -n '"422"|' || true

echo
echo "== E) Search for 'Invalid routing override' string in Rust (response text / error variants) =="
rg -n 'Invalid routing override' apps/server/src -S || true

Repository: AbianS/rustrak

Length of output: 4025


🏁 Script executed:

#!/bin/bash
set -euo pipefail
python3 - <<'PY'
import json,sys
p="apps/server/openapi.json"
with open(p,"r",encoding="utf-8") as f:
    data=json.load(f)

paths=data.get("paths",{})
def show(path):
    ops=paths.get(path)
    if not ops:
        print(f"{path}: <missing>")
        return
    print(f"\n{path}:")
    for method in ["get","post","put","patch","delete","options","head"]:
        if method in ops:
            resp=ops[method].get("responses",{})
            has_422="422" in resp
            desc=None
            if has_422:
                desc=resp["422"].get("description")
            print(f"  {method.upper()}: responses has 422? {has_422} desc={desc}")
show("/api/integrations")
show("/api/integrations/{id}")

# Also find any operation that uses the "Invalid routing override" description.
needle="Invalid routing override"
hits=[]
for path,ops in paths.items():
    for method,op in ops.items():
        for code,r in (op.get("responses") or {}).items():
            if isinstance(r,dict) and r.get("description")==needle:
                hits.append((path,method,code))
print("\nOccurrences of description='Invalid routing override':")
for t in sorted(hits):
    print(f"  {t[1].upper()} {t[0]} -> {t[2]}")
PY

echo
echo "== Rust: find handlers mentioning /api/integrations =="
rg -n '"/api/integrations' apps/server/src -S || true
rg -n 'path\s*=\s*"/api/integrations' apps/server/src -S || true
rg -n '\/api\/integrations' apps/server/src -S || true

echo
echo "== Rust: find any openapi utoipa path attribute for /api/integrations =="
rg -n 'utoipa::path\(|responses\(|responses\(\s*' apps/server/src/routes -S || true

Repository: AbianS/rustrak

Length of output: 6030


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== 1) Inspect utoipa::path blocks for /api/integrations in alerts.rs =="
sed -n '130,230p' apps/server/src/routes/alerts.rs

echo
echo "== 2) Inspect the router wiring for /api/integrations scope =="
sed -n '600,720p' apps/server/src/routes/alerts.rs

echo
echo "== 3) Locate AlertService::create_channel implementation and inspect for routing override validation =="
rg -n "create_channel\\(" apps/server/src | head -n 50
# Print likely implementation file(s)
files=$(rg -l "fn create_channel" apps/server/src || true)
echo "Files containing fn create_channel: "
echo "$files"
for f in $files; do
  echo
  echo "---- $f (context) ----"
  # show ~80 lines around the function
  line=$(rg -n "fn create_channel" "$f" | head -n 1 | cut -d: -f1)
  start=$((line-40)); end=$((line+120))
  sed -n "${start},${end}p" "$f"
done

echo
echo "== 4) Search for the 422 mapping or error text for 'Invalid routing override' in Rust =="
rg -n "Invalid routing override" apps/server/src || true
rg -n "UNPROCESSABLE_ENTITY|StatusCode::UNPROCESSABLE_ENTITY|unprocessable" apps/server/src || true

echo
echo "== 5) Search for routing_override usage during create_channel =="
rg -n "routing_override" apps/server/src/routes apps/server/src/services apps/server/src/models || true

Repository: AbianS/rustrak

Length of output: 28143


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== 1) Find where 422 / UNPROCESSABLE_ENTITY is produced =="
rg -n 'UNPROCESSABLE_ENTITY|StatusCode::UNPROCESSABLE_ENTITY|unprocessable' apps/server/src -S || true
rg -n 'AppError::Validation|Validation\(' apps/server/src -S || true
rg -n 'Invalid routing override' apps/server/src -S || true

echo
echo "== 2) Locate AppError->HTTP mapping (error handler / responder) =="
rg -n 'impl .*ResponseError|status_code\(|into_response|map_err|match .*AppError' apps/server/src -S || true

echo
echo "== 3) Show any error mapping code around Validation -> status =="
rg -n 'Validation.*(UNPROCESSABLE|422|StatusCode::UNPROCESSABLE)|UNPROCESSABLE.*Validation|422.*Validation' apps/server/src -S || true

Repository: AbianS/rustrak

Length of output: 12866


🏁 Script executed:

#!/bin/bash
set -euo pipefail
sed -n '1,120p' apps/server/src/error.rs
echo
sed -n '50,170p' apps/server/src/routes/alerts.rs

Repository: AbianS/rustrak

Length of output: 7379


Remove/fix the 422 Invalid routing override response for POST /api/integrations in apps/server/openapi.json.
POST /api/integrations (create_channel) doesn’t validate any routing_override (it only validates provider credentials), and routing-override validation returns AppError::Validation, which maps to 400. The utoipa annotation for create_channel also omits 422, so the OpenAPI contract is currently out of sync.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/server/openapi.json` around lines 91 - 100, The OpenAPI contract
contains an incorrect 422 response for POST /api/integrations (create_channel);
remove the "422 Invalid routing override" response block from
apps/server/openapi.json for that operation (or change it to the correct 400
mapping) so the schema matches the create_channel utoipa annotation and the
AppError::Validation -> 400 mapping; ensure the operation only references the
existing 400 ErrorResponse and that no routing_override validation response
remains.

Comment on lines +138 to 167
fn format_bot_message(
creds: &SlackBotTokenConfig,
routing: &SlackRoutingOverride,
payload: &AlertPayload,
) -> serde_json::Value {
let mut body = Self::format_webhook_message(payload);
body["channel"] = json!(cfg.channel);
if let Some(ref username) = cfg.username {
body["username"] = json!(username);

// Channel: routing wins over credentials
let channel = routing
.channel
.as_deref()
.or(creds.channel.as_deref())
.unwrap_or_default();
body["channel"] = json!(channel);

let username = routing.username.as_deref().or(creds.username.as_deref());
if let Some(u) = username {
body["username"] = json!(u);
}
if let Some(ref icon_emoji) = cfg.icon_emoji {
body["icon_emoji"] = json!(icon_emoji);

let icon_emoji = routing
.icon_emoji
.as_deref()
.or(creds.icon_emoji.as_deref());
if let Some(e) = icon_emoji {
body["icon_emoji"] = json!(e);
}

body
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Bot-token dispatch silently sends with empty channel when neither routing nor credentials provide one.

At Line 146-151, when both routing.channel and creds.channel are None, unwrap_or_default() produces "" and the request body becomes "channel": "". Combined with validate_bot_token_config no longer requiring a channel in credentials (Line 364-372) and validate_config for routing being best-effort (Line 310-316 does unwrap_or(...) and swallows parse failures), the dispatcher can reach Slack with an empty channel and only get an opaque channel_not_found from the API.

The validate_routing_override route-layer check guards rule creation, but the POST /api/integrations/{id}/test path may pass an empty routing body, and any malformed routing JSON here falls back to all-None. Suggest failing fast at dispatch time with a clear error before hitting Slack.

🛡️ Proposed defensive check in send_bot_token
     async fn send_bot_token(
         &self,
         cfg: &SlackBotTokenConfig,
         routing: &SlackRoutingOverride,
         payload: &AlertPayload,
     ) -> NotificationResult {
+        // bot_token requires a channel from routing or credentials.
+        if routing.channel.as_deref().or(cfg.channel.as_deref()).unwrap_or("").is_empty() {
+            return NotificationResult::failure(
+                "Slack bot_token requires a channel in routing_override or credentials".to_string(),
+                None,
+            );
+        }
         let body = Self::format_bot_message(cfg, routing, payload);

Also applies to: 289-324

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/server/src/services/notification/slack.rs` around lines 138 - 167,
format_bot_message currently inserts an empty string for "channel" when both
routing.channel and creds.channel are None, which allows a request to be sent to
Slack and elicit a channel_not_found error; update the dispatch to fail fast: in
send_bot_token (and the similar dispatcher used around lines 289-324) check for
a resolved channel by computing the same precedence used in format_bot_message
(routing.channel.or(creds.channel)) and return a clear error (rather than
sending) when that result is None or empty; keep format_bot_message as a pure
formatter but remove the unwrap_or_default() usage or make it return Result so
the caller can enforce the presence of a channel and surface a descriptive error
to the caller before any HTTP request is made.

Comment on lines 220 to 228
#[test]
fn test_email_validate_config_empty_recipients() {
let _guard = SmtpHostGuard::set("smtp.example.com");

fn test_email_validate_config_no_smtp_host_fails() {
// No global SMTP and no integration SMTP should fail validation
let dispatcher = create_dispatcher(ChannelType::Email);
let config = json!({
"recipients": []
});
let config = json!({});

let result = dispatcher.validate_config(&config);
assert!(result.is_err());
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Flaky test: depends on SMTP_HOST being unset but doesn't enforce it.

test_email_validate_config_no_smtp_host_fails asserts that validate_config errors when no global SMTP is configured, but it neither acquires SMTP_ENV_LOCK nor removes SMTP_HOST from the environment. Cargo runs unit tests in parallel, and the sibling tests test_email_validate_config_valid (line 210) and test_email_validate_config_recipients_are_routing_concern (line 233) both call SmtpHostGuard::set("smtp.example.com"). Worse, SmtpHostGuard::set only holds SMTP_ENV_LOCK for the duration of set() itself (the _lock is dropped before the guard is returned), so the mutex doesn't actually serialize the env-var window across tests. If this test runs while another has SMTP_HOST set, validate_config will see the host and return Ok, and assert!(result.is_err()) will fail nondeterministically.

Two issues are entangled; consider fixing both together:

  1. Make this test explicitly clear SMTP_HOST (and hold the lock for the test's lifetime).
  2. Have SmtpHostGuard actually own the MutexGuard so the lock spans the whole test.
🔒 Suggested fix sketch
// Change SmtpHostGuard to own the lock guard:
struct SmtpHostGuard {
    previous: Option<String>,
    _lock: std::sync::MutexGuard<'static, ()>,
}

impl SmtpHostGuard {
    fn set(value: &str) -> Self {
        let lock = SMTP_ENV_LOCK.lock().expect("SMTP env lock poisoned");
        let previous = std::env::var("SMTP_HOST").ok();
        std::env::set_var("SMTP_HOST", value);
        Self { previous, _lock: lock }
    }

    fn unset() -> Self {
        let lock = SMTP_ENV_LOCK.lock().expect("SMTP env lock poisoned");
        let previous = std::env::var("SMTP_HOST").ok();
        std::env::remove_var("SMTP_HOST");
        Self { previous, _lock: lock }
    }
}
 #[test]
 fn test_email_validate_config_no_smtp_host_fails() {
+    let _guard = SmtpHostGuard::unset();
     // No global SMTP and no integration SMTP should fail validation
     let dispatcher = create_dispatcher(ChannelType::Email);
     let config = json!({});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/server/tests/unit/notification_test.rs` around lines 220 - 228, The test
test_email_validate_config_no_smtp_host_fails is flaky because it doesn't ensure
SMTP_HOST is unset or hold SMTP_ENV_LOCK for the test duration; change the test
to acquire the SMTP_ENV_LOCK for the whole test and explicitly remove SMTP_HOST
from the environment before calling dispatcher.validate_config so the
environment state is deterministic. Also update SmtpHostGuard so it actually
owns the MutexGuard (store a field like _lock: std::sync::MutexGuard<'static,
()> in the struct returned by SmtpHostGuard::set and SmtpHostGuard::unset) so
the lock is held while the guard exists, and adjust callers (including
test_email_validate_config_valid and
test_email_validate_config_recipients_are_routing_concern) to use the guard's
lifetime to serialize access to the SMTP_HOST env var.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant