Skip to content

feat: add voice alert notification endpoint and DB layer#709

Open
cmd-err wants to merge 1 commit into
juspay:releasefrom
cmd-err:feat/alerting
Open

feat: add voice alert notification endpoint and DB layer#709
cmd-err wants to merge 1 commit into
juspay:releasefrom
cmd-err:feat/alerting

Conversation

@cmd-err
Copy link
Copy Markdown
Contributor

@cmd-err cmd-err commented Apr 15, 2026

Standalone alerting service for firing voice calls to on-call groups.

  • POST /alerts/fire endpoint with Redis SETNX deduplication
  • alert_groups DB table (migration 023) + queries + accessors
  • ALERT_SYSTEM role added to UserRole enum for JWT auth
  • Fully parameterized via request body (no config dependencies)
  • Reuses existing BB template/lead machinery end-to-end

Summary by CodeRabbit

  • New Features

    • Introduced a voice alert notification system with a new endpoint to trigger alerts to designated recipient groups.
    • Implemented built-in deduplication to prevent duplicate alerts within configurable time windows.
    • Added alert group management for organizing alert recipients by name.
  • Documentation

    • Added comprehensive design documentation for the voice alert notification system including architecture and usage guidelines.

Copilot AI review requested due to automatic review settings April 15, 2026 02:21
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 15, 2026

Important

Review skipped

Auto incremental reviews are disabled on this repository.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: c3f87fbf-09dd-4581-bcc5-6e171805618a

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Walkthrough

This PR introduces a Voice Alert Notification System featuring a new POST /alerts/fire endpoint that accepts alert events, authenticates via ALERT_SYSTEM role, deduplicates alerts using Redis, resolves recipient phone numbers from an alert_groups database table, validates templates and call configurations, and creates backlog leads for each group member.

Changes

Cohort / File(s) Summary
Router Registration
app/api/routers/breeze_buddy/__init__.py
Registers the new alerts router with root API endpoints and applies ["alerts"] tag.
Alert Endpoint & Schema
app/api/routers/breeze_buddy/alerts/__init__.py
Defines POST /alerts/fire endpoint (HTTP 202) with role-based access control (ADMIN or ALERT_SYSTEM). Exports AlertFireRequest Pydantic model with fields for alert identification, template configuration, deduplication TTL, and optional extra payload.
Alert Business Logic
app/api/routers/breeze_buddy/alerts/handlers.py
Implements fire_alert_handler with Redis deduplication (SET with NX and TTL), alert group lookup, template/config validation, per-member lead creation with call tracking, and per-lead success/failure tracking.
Database Schema & Queries
app/database/migrations/023_create_alert_groups_table.sql, app/database/queries/breeze_buddy/alert_groups.py
Creates alert_groups table with UUID primary key, unique name, JSONB members array, and timestamps. Provides parameterized query builders for select and upsert operations.
Database Accessors
app/database/accessor/breeze_buddy/alert_groups.py, app/database/accessor/__init__.py
Implements async accessor functions get_alert_group_by_name() and upsert_alert_group() with result decoding and exception handling. Exports functions in module __all__ list.
Authentication
app/schemas/breeze_buddy/auth.py
Adds new ALERT_SYSTEM enum value to UserRole for RBAC gating of the alerts endpoint.
Documentation
docs/plans/2026-04-10-voice-alert-notification.md
Comprehensive design document detailing architecture, endpoint specification, alert group management, deduplication mechanism, error handling, unit test cases, health monitoring integration, and extensibility roadmap.

Sequence Diagram

sequenceDiagram
    participant Client
    participant API as POST /alerts/fire
    participant Redis
    participant DB as Alert Groups DB
    participant ValidSvc as Template/Config Validator
    participant LeadSvc as Lead Tracking Service
    
    Client->>API: AlertFireRequest (alert_id, group_name, template, ...)
    API->>API: Verify ADMIN/ALERT_SYSTEM role
    API->>Redis: SET dedup_key NX with TTL
    alt Dedup key exists
        Redis-->>API: Key already exists
        API-->>Client: HTTP 202 {status: "deduplicated"}
    else Dedup key set
        Redis-->>API: Key set successfully
        API->>DB: Fetch alert_groups by name
        alt Group not found
            DB-->>API: No record
            API->>Redis: DEL dedup_key
            API-->>Client: HTTP 404 Alert group not found
        else Group found
            DB-->>API: {id, members: [phone1, phone2, ...]}
            API->>ValidSvc: Validate template + config
            alt Validation fails
                ValidSvc-->>API: Error
                API->>Redis: DEL dedup_key
                API-->>Client: HTTP 404 Template/config invalid
            else Validation passes
                ValidSvc-->>API: OK
                loop For each group member
                    API->>LeadSvc: create_lead_call_tracker(payload, phone)
                    alt Lead creation succeeds
                        LeadSvc-->>API: lead_id
                        API->>API: Track queued lead
                    else Lead creation fails
                        LeadSvc-->>API: Error/None
                        API->>API: Track failed member
                    end
                end
                API-->>Client: HTTP 202 {status: "queued", queued: [...], failed: [...]}
            end
        end
    end
Loading

Estimated Code Review Effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Poem

🐰 A hop through the alerts we go,
With Redis keys that dance so slow,
Groups of voices, calls take flight,
Dedup magic makes them right!
From fire endpoint to the phone,
No duplicate alarms will moan!

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title accurately and specifically summarizes the main change: introducing a voice alert notification endpoint with database layer support, which is the core focus across all modified files.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

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

🧹 Nitpick comments (4)
docs/plans/2026-04-10-voice-alert-notification.md (2)

686-691: Use Redis namespace in the dedup snippet.

The snippet uses redis.set(...) without namespace, which can cause cross-service key collisions in shared Redis deployments.

As per coding guidelines "Always use namespace parameter in redis_get/redis_set calls to prevent key collisions across services".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/plans/2026-04-10-voice-alert-notification.md` around lines 686 - 691,
The dedup snippet calls redis.set(...) without a namespace which can cause
cross-service key collisions; update the call to include the namespace parameter
(e.g., namespace=req.redis_namespace or a service-specific constant) when
setting the dedup key so that the Redis operation is namespaced; locate the
redis.set invocation that uses dedup_key (and the surrounding dedup logic) and
add the namespace argument accordingly.

893-911: Update config snippet to avoid direct os.environ reads.

The static config example should use the project’s env helper pattern for mandatory vars rather than direct os.environ access.

As per coding guidelines "Load ALL configuration from app/core/config/static.py using get_required_env() for mandatory variables; never import directly from os.environ elsewhere".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/plans/2026-04-10-voice-alert-notification.md` around lines 893 - 911,
Replace direct os.environ reads in this config block with the project's env
helpers from app.core.config.static: import get_required_env for mandatory vars
and get_env (or equivalent) for optional ones, then replace
ENABLE_HEALTH_MONITOR, HEALTH_MONITOR_INTERVAL_SECONDS, ALERT_SYSTEM_JWT_TOKEN,
ALERT_RESELLER_ID, ALERT_MERCHANT_ID, ALERT_TEMPLATE, and ALERT_GROUP_NAME to
use those helpers; use get_required_env for truly mandatory values (e.g.,
ALERT_SYSTEM_JWT_TOKEN and any required IDs), use the optional helper with
default values for flags and intervals (convert the returned string to bool/int
for ENABLE_HEALTH_MONITOR and HEALTH_MONITOR_INTERVAL_SECONDS), and ensure no
direct os.environ usage remains in this module.
app/database/queries/breeze_buddy/alert_groups.py (1)

22-22: Tighten the members type annotation.

members: list is too generic; use a concrete typed shape (e.g., List[Dict[str, str]] or a typed alias) to improve static checks.

As per coding guidelines "Use type hints on all function signatures (Optional[T], List[T], Dict[str, Any], Union)".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/database/queries/breeze_buddy/alert_groups.py` at line 22, The function
upsert_alert_group_query currently types members as a bare list; change its
signature to a concrete typed collection (e.g., members: List[Dict[str, str]] or
a typed alias like AlertMember = Dict[str, str]) and update imports (from typing
import List, Dict or the alias) so static checkers can validate member shape;
ensure any references inside upsert_alert_group_query and its callers use the
chosen concrete type.
app/database/accessor/breeze_buddy/alert_groups.py (1)

16-27: Use a typed decoder model for alert groups.

Returning raw Dict[str, Any] from _decode_alert_group makes field shape drift harder to catch. Prefer decoding into a Pydantic model for the DB decoder layer.

As per coding guidelines "Follow three-layer database pattern: queries (SQL builders) -> accessor (business logic) -> decoder (row to Pydantic)".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/database/accessor/breeze_buddy/alert_groups.py` around lines 16 - 27, The
_decode_alert_group function currently returns a raw Dict[str, Any]; change it
to construct and return a typed Pydantic model (e.g., AlertGroup) instead so the
decoder layer guarantees shape. Update the function signature to -> AlertGroup,
parse members the same way (json.loads if str), build a dict with id, name,
members, created_at, updated_at and pass it to AlertGroup.parse_obj (or
AlertGroup(**data)) and return that instance; ensure you import or define the
AlertGroup model with matching fields so callers of _decode_alert_group receive
a validated model.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/api/routers/breeze_buddy/alerts/__init__.py`:
- Around line 32-67: The string fields alert_id, alert_group_name, template,
reseller_id, merchant_id and alert_message currently accept empty or
whitespace-only values; add validation to reject blank identifiers by either
switching their types to constrained strings (e.g., use
pydantic.constr(strip_whitespace=True, min_length=1) for alert_id,
alert_group_name, template, reseller_id and alert_message and
Optional[constr(...)] for merchant_id) or add a `@validator` on those fields that
strips whitespace and raises ValueError if the result is empty, so
dedup/group/template lookups never receive empty keys.

In `@app/api/routers/breeze_buddy/alerts/handlers.py`:
- Around line 40-48: The dedup key is currently built only from req.alert_id and
redis.set is called without a namespace, allowing cross-tenant/service
collisions; update the dedup key construction and redis call to include
tenant/service context (e.g., incorporate req.reseller_id or req.tenant_id into
dedup_key alongside req.alert_id) and pass the namespace argument to the Redis
helper (use the project's redis_set/redis_get wrapper or add
namespace="breeze_buddy.alerts" to redis.set) so keys are both namespaced and
scoped per-tenant; adjust references to dedup_key and the redis.set call
accordingly.
- Around line 75-84: The early return when no members (checking members and
returning status "no_members") and the final return when leads_queued == 0 must
release the dedup lock so retries aren't suppressed; locate the dedup key
variable (dedup_key or similar) and the dedup store API used elsewhere (e.g.,
set_dedup_key/remove_dedup_key or release_dedup_lock) and call the appropriate
release/remove function just before each return (both in the members-empty
branch and the final block that returns leads_queued 0). Ensure you reference
the same dedup identifier used when the lock was set so the lock is cleared
reliably on these zero-leads exit paths.
- Around line 39-43: The code eagerly calls get_redis_service() even when dedup
is disabled, which can cause Redis outages to block alert handling; change the
logic so get_redis_service() is only called when req.dedup_ttl_seconds > 0
(i.e., move the redis = await get_redis_service() and dedup_key creation into
the dedup branch), use get_redis_service() and dedup_key only inside the dedup
block that checks req.dedup_ttl_seconds, and ensure any later references to
redis/dedup_key are guarded by that same condition.
- Around line 165-177: The logs currently emit raw phone numbers in logger.info
and logger.error; replace those uses of the phone variable with a masked value
(e.g., keep only last 4 digits and replace the rest with asterisks) by
creating/using a small helper like mask_phone_number(phone) and assign
masked_phone = mask_phone_number(phone) and then use masked_phone in the
logger.info(...) and logger.error(...) calls (and optionally in the
failed.append dict) so PII is not written to logs.

In `@app/database/migrations/023_create_alert_groups_table.sql`:
- Line 13: Remove the redundant index creation for alert_groups.name: the column
is declared as UNIQUE (name TEXT NOT NULL UNIQUE) which already creates a unique
index, so delete the CREATE INDEX IF NOT EXISTS idx_alert_groups_name ON
alert_groups (name); line from the migration (referencing idx_alert_groups_name
and table alert_groups) to avoid duplicate indexing and extra write overhead.

In `@docs/plans/2026-04-10-voice-alert-notification.md`:
- Around line 1404-1426: The markdown under "Part 4" uses level-3 headings (###)
but should use level-2 (##) to avoid an MD001 heading level jump; update the
headings "Adding a Twilio Balance Poller", "Adding sequential call mode
(escalation)", "Adding a pre-recorded fallback MP3", and "Admin API for alert
groups" to be level-2 (prefix with '##') so they are directly under the "# Part
4" header and conform to the lint rule.
- Line 21: Several fenced code blocks in the markdown (currently just triple
backticks ``` with no language) are missing language identifiers and trigger
MD040; update each ``` block in
docs/plans/2026-04-10-voice-alert-notification.md to include an appropriate
language tag (for example `text`, `yaml`, `python`, `sql`, etc.) matching the
block content so the linter accepts them (this applies to the code fences
flagged by the review and any other triple-backtick blocks in the same file).

---

Nitpick comments:
In `@app/database/accessor/breeze_buddy/alert_groups.py`:
- Around line 16-27: The _decode_alert_group function currently returns a raw
Dict[str, Any]; change it to construct and return a typed Pydantic model (e.g.,
AlertGroup) instead so the decoder layer guarantees shape. Update the function
signature to -> AlertGroup, parse members the same way (json.loads if str),
build a dict with id, name, members, created_at, updated_at and pass it to
AlertGroup.parse_obj (or AlertGroup(**data)) and return that instance; ensure
you import or define the AlertGroup model with matching fields so callers of
_decode_alert_group receive a validated model.

In `@app/database/queries/breeze_buddy/alert_groups.py`:
- Line 22: The function upsert_alert_group_query currently types members as a
bare list; change its signature to a concrete typed collection (e.g., members:
List[Dict[str, str]] or a typed alias like AlertMember = Dict[str, str]) and
update imports (from typing import List, Dict or the alias) so static checkers
can validate member shape; ensure any references inside upsert_alert_group_query
and its callers use the chosen concrete type.

In `@docs/plans/2026-04-10-voice-alert-notification.md`:
- Around line 686-691: The dedup snippet calls redis.set(...) without a
namespace which can cause cross-service key collisions; update the call to
include the namespace parameter (e.g., namespace=req.redis_namespace or a
service-specific constant) when setting the dedup key so that the Redis
operation is namespaced; locate the redis.set invocation that uses dedup_key
(and the surrounding dedup logic) and add the namespace argument accordingly.
- Around line 893-911: Replace direct os.environ reads in this config block with
the project's env helpers from app.core.config.static: import get_required_env
for mandatory vars and get_env (or equivalent) for optional ones, then replace
ENABLE_HEALTH_MONITOR, HEALTH_MONITOR_INTERVAL_SECONDS, ALERT_SYSTEM_JWT_TOKEN,
ALERT_RESELLER_ID, ALERT_MERCHANT_ID, ALERT_TEMPLATE, and ALERT_GROUP_NAME to
use those helpers; use get_required_env for truly mandatory values (e.g.,
ALERT_SYSTEM_JWT_TOKEN and any required IDs), use the optional helper with
default values for flags and intervals (convert the returned string to bool/int
for ENABLE_HEALTH_MONITOR and HEALTH_MONITOR_INTERVAL_SECONDS), and ensure no
direct os.environ usage remains in this module.
🪄 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: 1b1785bb-b074-4e6d-9e5e-d1057f04a863

📥 Commits

Reviewing files that changed from the base of the PR and between 08ddcb9 and 476ad91.

📒 Files selected for processing (9)
  • app/api/routers/breeze_buddy/__init__.py
  • app/api/routers/breeze_buddy/alerts/__init__.py
  • app/api/routers/breeze_buddy/alerts/handlers.py
  • app/database/accessor/__init__.py
  • app/database/accessor/breeze_buddy/alert_groups.py
  • app/database/migrations/023_create_alert_groups_table.sql
  • app/database/queries/breeze_buddy/alert_groups.py
  • app/schemas/breeze_buddy/auth.py
  • docs/plans/2026-04-10-voice-alert-notification.md

Comment on lines +32 to +67
alert_id: str = Field(
...,
description="Unique alert identifier for deduplication",
examples=["stt-degraded", "tts-elevenlabs-down"],
)

# Which group of people to call
alert_group_name: str = Field(
...,
description="Name of the alert group (must exist in alert_groups table)",
examples=["platform-oncall"],
)

# Human-readable message spoken via TTS
# The caller builds this string — the endpoint just passes it through
alert_message: str = Field(
...,
description="Alert message spoken via TTS on the call",
examples=["STT degraded: 47 errors in 5 minutes"],
)

# BB template config — fully reuses existing template machinery
reseller_id: str = Field(..., description="Reseller ID for the alert template")
merchant_id: Optional[str] = Field(None, description="Merchant ID (optional)")
template: str = Field(
...,
description="BB template name (must exist in templates table)",
examples=["alert-voice-notification"],
)

# How long (seconds) to suppress duplicate alerts for this alert_id
dedup_ttl_seconds: int = Field(
default=300,
ge=0,
description="Dedup window in seconds. 0 = no dedup.",
)
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

Reject blank identifiers to prevent invalid dedup/group/template operations.

Required string fields currently allow ""/whitespace-only values. That can produce invalid dedup keys (e.g., empty alert_id) and ambiguous downstream lookups.

Proposed validation hardening
     alert_id: str = Field(
         ...,
+        min_length=1,
         description="Unique alert identifier for deduplication",
         examples=["stt-degraded", "tts-elevenlabs-down"],
     )
@@
     alert_group_name: str = Field(
         ...,
+        min_length=1,
         description="Name of the alert group (must exist in alert_groups table)",
         examples=["platform-oncall"],
     )
@@
     alert_message: str = Field(
         ...,
+        min_length=1,
         description="Alert message spoken via TTS on the call",
         examples=["STT degraded: 47 errors in 5 minutes"],
     )
@@
-    reseller_id: str = Field(..., description="Reseller ID for the alert template")
-    merchant_id: Optional[str] = Field(None, description="Merchant ID (optional)")
+    reseller_id: str = Field(..., min_length=1, description="Reseller ID for the alert template")
+    merchant_id: Optional[str] = Field(None, min_length=1, description="Merchant ID (optional)")
@@
     template: str = Field(
         ...,
+        min_length=1,
         description="BB template name (must exist in templates table)",
         examples=["alert-voice-notification"],
     )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/api/routers/breeze_buddy/alerts/__init__.py` around lines 32 - 67, The
string fields alert_id, alert_group_name, template, reseller_id, merchant_id and
alert_message currently accept empty or whitespace-only values; add validation
to reject blank identifiers by either switching their types to constrained
strings (e.g., use pydantic.constr(strip_whitespace=True, min_length=1) for
alert_id, alert_group_name, template, reseller_id and alert_message and
Optional[constr(...)] for merchant_id) or add a `@validator` on those fields that
strips whitespace and raises ValueError if the result is empty, so
dedup/group/template lookups never receive empty keys.

Comment on lines +39 to +43
redis = await get_redis_service()
dedup_key = f"alert:dedup:{req.alert_id}"

# ── Step 1: Dedup ──────────────────────────────────────────────
if req.dedup_ttl_seconds > 0:
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

Avoid requiring Redis when dedup is disabled.

Line 39 initializes Redis even when dedup_ttl_seconds == 0, so Redis outages can block non-dedup alert firing.

💡 Suggested change
-    redis = await get_redis_service()
+    redis = None
     dedup_key = f"alert:dedup:{req.alert_id}"

     # ── Step 1: Dedup ──────────────────────────────────────────────
     if req.dedup_ttl_seconds > 0:
+        redis = await get_redis_service()
         acquired = await redis.set(
             key=dedup_key,
             value="1",
             nx=True,
             ex=req.dedup_ttl_seconds,
         )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/api/routers/breeze_buddy/alerts/handlers.py` around lines 39 - 43, The
code eagerly calls get_redis_service() even when dedup is disabled, which can
cause Redis outages to block alert handling; change the logic so
get_redis_service() is only called when req.dedup_ttl_seconds > 0 (i.e., move
the redis = await get_redis_service() and dedup_key creation into the dedup
branch), use get_redis_service() and dedup_key only inside the dedup block that
checks req.dedup_ttl_seconds, and ensure any later references to redis/dedup_key
are guarded by that same condition.

Comment on lines +40 to +48
dedup_key = f"alert:dedup:{req.alert_id}"

# ── Step 1: Dedup ──────────────────────────────────────────────
if req.dedup_ttl_seconds > 0:
acquired = await redis.set(
key=dedup_key,
value="1",
nx=True,
ex=req.dedup_ttl_seconds,
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

Scope and namespace the dedup key to prevent cross-tenant/service suppression.

Line 40 keys only by alert_id, so identical IDs across resellers can deduplicate each other. Also, this flow does not use a Redis namespace, which increases collision risk across services.

As per coding guidelines, "Always use namespace parameter in redis_get/redis_set calls to prevent key collisions across services".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/api/routers/breeze_buddy/alerts/handlers.py` around lines 40 - 48, The
dedup key is currently built only from req.alert_id and redis.set is called
without a namespace, allowing cross-tenant/service collisions; update the dedup
key construction and redis call to include tenant/service context (e.g.,
incorporate req.reseller_id or req.tenant_id into dedup_key alongside
req.alert_id) and pass the namespace argument to the Redis helper (use the
project's redis_set/redis_get wrapper or add namespace="breeze_buddy.alerts" to
redis.set) so keys are both namespaced and scoped per-tenant; adjust references
to dedup_key and the redis.set call accordingly.

Comment on lines +75 to +84
if not members:
logger.warning(
f"Alert group '{req.alert_group_name}' has no members — no calls fired"
)
return {
"status": "no_members",
"alert_id": req.alert_id,
"alert_group_name": req.alert_group_name,
"leads_queued": 0,
}
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

Release dedup lock when zero leads are queued.

When group has no members (Lines 75-84) or all member queue attempts fail (final return block), the dedup key remains set and suppresses retries even though nothing was fired.

💡 Suggested change
     if not members:
+        if req.dedup_ttl_seconds > 0:
+            await redis.delete(dedup_key)
         logger.warning(
             f"Alert group '{req.alert_group_name}' has no members — no calls fired"
         )
         return {
             "status": "no_members",
@@
-    return {
+    if req.dedup_ttl_seconds > 0 and len(queued_leads) == 0:
+        await redis.delete(dedup_key)
+
+    return {
         "status": "queued",
         "alert_id": req.alert_id,
         "alert_group_name": req.alert_group_name,
         "leads_queued": len(queued_leads),
         "leads": queued_leads,
         "failed": failed,
     }

Also applies to: 182-189

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/api/routers/breeze_buddy/alerts/handlers.py` around lines 75 - 84, The
early return when no members (checking members and returning status
"no_members") and the final return when leads_queued == 0 must release the dedup
lock so retries aren't suppressed; locate the dedup key variable (dedup_key or
similar) and the dedup store API used elsewhere (e.g.,
set_dedup_key/remove_dedup_key or release_dedup_lock) and call the appropriate
release/remove function just before each return (both in the members-empty
branch and the final block that returns leads_queued 0). Ensure you reference
the same dedup identifier used when the lock was set so the lock is cleared
reliably on these zero-leads exit paths.

Comment on lines +165 to +177
logger.info(
f"Alert lead queued: {lead_id} → {phone} " f"(alert={req.alert_id})"
)
else:
failed.append(
{
"phone": phone,
"reason": "create_lead_call_tracker returned None",
}
)
except Exception as e:
logger.error(
f"Failed to queue alert lead for {phone}: {e}",
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

Mask phone numbers in logs to reduce PII exposure.

Lines 166 and 177 log full phone numbers. This is a privacy/compliance risk in centralized logs.

💡 Suggested change
+def _mask_phone(phone: str) -> str:
+    return f"***{phone[-4:]}" if len(phone) >= 4 else "***"
@@
-                logger.info(
-                    f"Alert lead queued: {lead_id} → {phone} " f"(alert={req.alert_id})"
-                )
+                logger.info(
+                    f"Alert lead queued: {lead_id} → {_mask_phone(phone)} "
+                    f"(alert={req.alert_id})"
+                )
@@
-            logger.error(
-                f"Failed to queue alert lead for {phone}: {e}",
+            logger.error(
+                f"Failed to queue alert lead for {_mask_phone(phone)}: {e}",
                 exc_info=True,
             )
🧰 Tools
🪛 Ruff (0.15.10)

[warning] 175-175: Do not catch blind exception: Exception

(BLE001)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/api/routers/breeze_buddy/alerts/handlers.py` around lines 165 - 177, The
logs currently emit raw phone numbers in logger.info and logger.error; replace
those uses of the phone variable with a masked value (e.g., keep only last 4
digits and replace the rest with asterisks) by creating/using a small helper
like mask_phone_number(phone) and assign masked_phone = mask_phone_number(phone)
and then use masked_phone in the logger.info(...) and logger.error(...) calls
(and optionally in the failed.append dict) so PII is not written to logs.

updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX IF NOT EXISTS idx_alert_groups_name ON alert_groups (name);
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

Remove redundant index on name.

name TEXT NOT NULL UNIQUE already creates an index; idx_alert_groups_name duplicates it and adds unnecessary write overhead.

Proposed migration adjustment
-CREATE INDEX IF NOT EXISTS idx_alert_groups_name ON alert_groups (name);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/database/migrations/023_create_alert_groups_table.sql` at line 13, Remove
the redundant index creation for alert_groups.name: the column is declared as
UNIQUE (name TEXT NOT NULL UNIQUE) which already creates a unique index, so
delete the CREATE INDEX IF NOT EXISTS idx_alert_groups_name ON alert_groups
(name); line from the migration (referencing idx_alert_groups_name and table
alert_groups) to avoid duplicate indexing and extra write overhead.


## 1.1 System Architecture — Two Decoupled Layers

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

Add fenced-code languages to pass markdown lint.

These code fences are missing a language identifier (text, python, sql, etc.), which triggers MD040.

Also applies to: 103-103, 124-124

🧰 Tools
🪛 markdownlint-cli2 (0.22.0)

[warning] 21-21: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/plans/2026-04-10-voice-alert-notification.md` at line 21, Several fenced
code blocks in the markdown (currently just triple backticks ``` with no
language) are missing language identifiers and trigger MD040; update each ```
block in docs/plans/2026-04-10-voice-alert-notification.md to include an
appropriate language tag (for example `text`, `yaml`, `python`, `sql`, etc.)
matching the block content so the linter accepts them (this applies to the code
fences flagged by the review and any other triple-backtick blocks in the same
file).

Comment on lines +1404 to +1426
### Adding a Twilio Balance Poller

1. Create `app/services/health_monitor/balance_poller.py` — call Twilio API, check balance
2. In `app/services/health_monitor/task.py`, add a second `register_task()` call
3. The poller calls `_fire_voice_alert()` from `monitor.py` — reuses the same `/alerts/fire` flow
4. Zero changes to the alert endpoint, router, DB, or dedup logic

### Adding sequential call mode (escalation)

1. Alert endpoint pushes first member immediately, stores remaining members in Redis list `alert:escalation:{alert_id}`
2. Telephony webhook handler checks: if lead finished with NO_ANSWER/BUSY → pop next from Redis list → push next lead
3. Requires webhook handler modification — out of scope for v1

### Adding a pre-recorded fallback MP3

1. Upload a generic alert MP3 to GCS
2. Configure the `alert-voice-notification` template with a `fallback_audio_url`
3. When TTS synthesis fails in `prepare_and_store_initial_greeting()`, the pipeline plays the fallback
4. Requires minor pipeline change to support `fallback_audio_url` — out of scope for v1

### Admin API for alert groups

1. Add CRUD endpoints: `POST /alert-groups`, `GET /alert-groups`, `PUT /alert-groups/{name}`, `DELETE /alert-groups/{name}`
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

Fix heading level jump under Part 4.

After # Part 4, the next heading should be ## ... (not ### ...) to satisfy MD001.

🧰 Tools
🪛 markdownlint-cli2 (0.22.0)

[warning] 1404-1404: Heading levels should only increment by one level at a time
Expected: h2; Actual: h3

(MD001, heading-increment)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/plans/2026-04-10-voice-alert-notification.md` around lines 1404 - 1426,
The markdown under "Part 4" uses level-3 headings (###) but should use level-2
(##) to avoid an MD001 heading level jump; update the headings "Adding a Twilio
Balance Poller", "Adding sequential call mode (escalation)", "Adding a
pre-recorded fallback MP3", and "Admin API for alert groups" to be level-2
(prefix with '##') so they are directly under the "# Part 4" header and conform
to the lint rule.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Adds a new voice-alert firing surface area to Clairvoyance’s Breeze Buddy stack by introducing an /alerts/fire endpoint and an alert_groups DB table used to resolve on-call phone lists, plus a new JWT role for system-driven alerting.

Changes:

  • Adds alert_groups migration, query builder, and DB accessor for resolving alert group members.
  • Introduces POST /alerts/fire router + handler with Redis SETNX-based deduplication and lead creation.
  • Extends Breeze Buddy auth roles with ALERT_SYSTEM and registers the alerts router.

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 10 comments.

Show a summary per file
File Description
docs/plans/2026-04-10-voice-alert-notification.md Design/implementation plan for the voice alert notification system.
app/schemas/breeze_buddy/auth.py Adds ALERT_SYSTEM to UserRole.
app/database/migrations/023_create_alert_groups_table.sql Creates the alert_groups table.
app/database/queries/breeze_buddy/alert_groups.py Adds parameterized SQL builders for alert group get/upsert.
app/database/accessor/breeze_buddy/alert_groups.py Adds async accessors for alert group retrieval/upsert.
app/database/accessor/init.py Exports new alert group accessors.
app/api/routers/breeze_buddy/alerts/init.py Adds /alerts/fire endpoint + request schema + role gate.
app/api/routers/breeze_buddy/alerts/handlers.py Implements dedup, group resolution, template/config validation, and lead enqueueing.
app/api/routers/breeze_buddy/init.py Registers alerts router under Breeze Buddy API.

CREATE TABLE IF NOT EXISTS alert_groups (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL UNIQUE,
members JSONB NOT NULL DEFAULT '[]',
Comment on lines +3 to +4
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Comment on lines +16 to +27
def _decode_alert_group(row) -> Dict[str, Any]:
"""Decode an asyncpg Record into a dict."""
members = row["members"]
if isinstance(members, str):
members = json.loads(members)
return {
"id": str(row["id"]),
"name": row["name"],
"members": members,
"created_at": row["created_at"],
"updated_at": row["updated_at"],
}
RESELLER = "reseller"
MERCHANT = "merchant"
USER = "user" # Renamed from SHOP
ALERT_SYSTEM = "alert_system"
Comment on lines +94 to +100
if current_user.role not in (UserRole.ADMIN, UserRole.ALERT_SYSTEM):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only ADMIN or ALERT_SYSTEM roles can fire alerts",
)

return await fire_alert_handler(req, current_user)
Comment on lines +74 to +84
members: List[Dict[str, str]] = group.get("members", [])
if not members:
logger.warning(
f"Alert group '{req.alert_group_name}' has no members — no calls fired"
)
return {
"status": "no_members",
"alert_id": req.alert_id,
"alert_group_name": req.alert_group_name,
"leads_queued": 0,
}
Comment on lines +131 to +135
"customer_mobile_number": phone,
"customer_name": name,
"alert_message": req.alert_message,
"alert_id": req.alert_id,
**(req.extra_payload or {}),
Comment on lines +182 to +189
return {
"status": "queued",
"alert_id": req.alert_id,
"alert_group_name": req.alert_group_name,
"leads_queued": len(queued_leads),
"leads": queued_leads,
"failed": failed,
}
Comment on lines +28 to +29
async def fire_alert_handler(req: Any, current_user: UserInfo) -> Dict[str, Any]:
"""
Comment on lines +41 to +67

# ── Step 1: Dedup ──────────────────────────────────────────────
if req.dedup_ttl_seconds > 0:
acquired = await redis.set(
key=dedup_key,
value="1",
nx=True,
ex=req.dedup_ttl_seconds,
)
if not acquired:
logger.info(
f"Alert '{req.alert_id}' deduplicated (TTL key exists in Redis)"
)
return {
"status": "deduplicated",
"alert_id": req.alert_id,
"message": (
f"Alert suppressed — already fired within "
f"{req.dedup_ttl_seconds}s window"
),
}

# ── Step 2: Resolve alert group ────────────────────────────────
group = await get_alert_group_by_name(req.alert_group_name)
if not group:
# Release dedup key so next retry can attempt again
if req.dedup_ttl_seconds > 0:
"alert_group": req.alert_group_name,
},
request_id=request_id,
execution_mode=ExecutionMode.TELEPHONY,
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Can we defined a new executionMode for alerts such as TELEPHONY_ALERT, so that for tracking it will be easy.

Comment on lines +5 to +8
CREATE TABLE IF NOT EXISTS alert_groups (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL UNIQUE,
members JSONB NOT NULL DEFAULT '[]',
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

If you want to give this alerting system for external people, let's say for infra, then without proper reseller and merchantId how will we configure alerts ?

Standalone alerting service for firing voice calls to on-call groups.

- POST /alerts/fire endpoint with Redis SETNX deduplication
- alert_groups DB table (migration 023) + queries + accessors
- ALERT_SYSTEM role added to UserRole enum for JWT auth
- Fully parameterized via request body (no config dependencies)
- Reuses existing BB template/lead machinery end-to-end
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.

7 participants