Skip to content

feat(telephony): add WhatsApp calling integration with outbound Calling (Business-Initiated Calling & Permission Management) - #757

Open
amitbhakt wants to merge 9 commits into
dograh-hq:mainfrom
amitbhakt:feat/whatsapp-outbound-calling
Open

feat(telephony): add WhatsApp calling integration with outbound Calling (Business-Initiated Calling & Permission Management)#757
amitbhakt wants to merge 9 commits into
dograh-hq:mainfrom
amitbhakt:feat/whatsapp-outbound-calling

Conversation

@amitbhakt

@amitbhakt amitbhakt commented Sep 11, 2026

Copy link
Copy Markdown

Pull Request: WhatsApp Outbound Calling (Business-Initiated Calling & Permission Management)

Prerequisite Notice for Reviewers:
This pull request builds upon the WhatsApp Inbound Calling PR (feature/whatsapp-inbound-calling). Please review and merge the Inbound Calling PR first. This PR is strictly scoped to adding Outbound Business-Initiated Calling (BIC), recipient permission management, and campaign dispatch orchestration on top of that foundation.


1. Summary

While the prerequisite Inbound PR introduced the base WhatsApp telephony provider for user-initiated incoming calls, this pull request implements Business-Initiated Calling (BIC).

Because Meta platform regulations require explicit recipient consent prior to initiating outbound WhatsApp calls, this PR introduces:

  1. Destination Policy Restriction Engine: Enforces Meta country restrictions prior to initiating calls.
  2. Permission Gating and Lifecycle State Machine: Tracks temporary (24-hour) and permanent call permissions.
  3. Interactive Permission Request Dispatching: Enables businesses to send interactive WhatsApp messages requesting call permission.
  4. Campaign Lead Parking and Automated Reactivation: Parks leads awaiting permission and reactivates them via an automated ARQ periodic sweep task when consent is granted.
  5. Architectural Decoupling (service.py): Establishes a dedicated service layer for active WebRTC connections, client caching, and Redis pub/sub, removing all provider dependencies on HTTP route handlers.
  6. Frontend Outbound Controls: Adds real-time permission evaluation, custom permission message previews, in-dialog call management (live duration timer, hangup), and campaign permission settings.

2. Architecture and Technical Design

2.1 Provider Decoupling via Dedicated Service Layer (service.py)

In the initial implementation, WhatsAppProvider imported internal connection globals and helpers directly from routes.py. This PR decouples the architecture:

  • service.py: Encapsulates:
    • Active WebRTC connection registry (_active_connections).
    • Outbound answered events (_outbound_answered_events).
    • Cross-worker Redis pub/sub synchronization (whatsapp:call:terminate, whatsapp:call:events).
    • Reusable HTTP client sessions and cached WhatsAppClient instances.
    • Pipeline runner registration (set_pipeline_runner), enabling the provider to register active calls without directly importing route modules.
  • provider.py: Imports exclusively from service.py. It initiates outbound calls via WebRTC after validating country eligibility and checking local recipient permission records.

2.2 Destination Country Policy Restrictions (restrictions.py)

Meta strictly restricts Business-Initiated WhatsApp Calls in specific jurisdictions:

  • United States and Canada (+1)
  • Egypt (+20)
  • Vietnam (+84)
  • Nigeria (+234)

restrictions.py validates destination numbers before any API call is made. Phone numbers belonging to restricted jurisdictions fail fast with DestinationCountryRestrictedError. The frontend queries the backend /permissions/check endpoint directly to display policy notices, ensuring a single source of truth without duplicated client-side prefix matching.

2.3 Recipient Permission Lifecycle and Concurrency-Safe Upserts

Outbound calls require an active permission record (granted_temporary or granted_permanent):

  • Phone Number Normalization: Queries evaluate numbers across canonical E.164, raw digits, leading +, and local variants to prevent duplicate rows or missed permissions across formatting differences.
  • Interactive Messaging: Dispatches Meta interactive messages with call_permission_request payloads when permission is absent or expired.
  • Concurrency Protection: upsert_whatsapp_call_permission handles PostgreSQL unique constraint collisions (uq_whatsapp_perm_config_recipient) caused by simultaneous webhook deliveries. On conflict, the transaction rolls back and safely updates the existing record.

2.4 Campaign Dispatcher and Orchestrator Integration

  • Polymorphic Exception Handling: Defined TelephonyPermissionRequiredError in api/services/telephony/base.py. WhatsAppPermissionRequiredError inherits from this class. campaign_call_dispatcher.py handles permission-gated calls polymorphically without importing provider-specific classes.
  • Lead Parking: When a campaign has whatsapp_permission_action = "request_and_wait", unpermitted leads receive a permission request and their run is parked with scheduled_for = now + 24h in queued state.
  • Campaign Completion Safety: campaign_orchestrator.py checks total queued and processing runs before marking a campaign complete. Campaigns containing parked leads are prevented from completing prematurely while awaiting recipient responses.
  • Periodic Sweeper (sweep_parked_whatsapp_permissions): Registered an ARQ cron task running every 2 minutes in WorkerSettings.cron_jobs to poll for granted permissions and reactivate parked runs automatically.

3. Database Schema Changes

This PR includes two Alembic migrations:

  1. e1a2b3c4d5e6_add_whatsapp_call_permissions.py:
    • Creates whatsapp_call_permissions table:
      • Columns: id, organization_id, telephony_configuration_id, phone_number_id, recipient_phone_number, status, permission_type, meta_message_id, requested_at, granted_at, expires_at, updated_at.
      • Unique constraint: uq_whatsapp_perm_config_recipient on (telephony_configuration_id, recipient_phone_number).
      • Index: ix_whatsapp_perm_lookup on (phone_number_id, recipient_phone_number).
  2. b7d2f04c8a15_index_parked_queued_runs.py:
    • Adds composite index ix_queued_runs_retry_reason_state on (retry_reason, state) to optimize queries for parked leads during cron sweeps.

4. Frontend Enhancements

4.1 Phone Call Dialog (PhoneCallDialog.tsx)

  • Replaces the placeholder tooltip on the call button with active outbound WebRTC calling logic.
  • Evaluates recipient permission in real time via /permissions/check.
  • Displays contextual permission badges (granted, pending, not_requested, restricted).
  • Provides inline permission request dispatching with customized message text preview.
  • Adds active call state progression: calling indicator, live duration counter, and hangup control.

4.2 Campaign Settings (WhatsAppPermissionCard.tsx)

  • Provides UI configuration for campaign permission behavior:
    • request_and_wait: Sends permission request and parks the lead for up to 24 hours.
    • skip: Skips unpermitted leads immediately.

5. Pipecat Submodule Updates

Updates the pipecat submodule pointer to include:

  • WhatsAppClient.initiate_outbound_call: Creates local SDP offer and sends it to Meta Graph API via action "connect".
  • WhatsAppClient.check_call_permission: Queries Meta's call_permissions API.
  • WhatsAppClient.send_call_permission_request: Dispatches interactive permission messages.
  • Connection track pre-allocation and audio lifecycle synchronization in SmallWebRTCClient.

6. Testing and Verification Guide

6.1 Automated Test Execution

# Outbound provider initiation and permission gating tests
pytest api/tests/telephony/whatsapp/test_provider.py

# Campaign dispatcher parking, completion protection, and sweeper cron tests
pytest api/tests/telephony/whatsapp/test_campaign_permissions.py

# Country restriction validation tests
pytest api/tests/telephony/whatsapp/test_restrictions.py

# Format-tolerant recipient matching tests
pytest api/tests/telephony/whatsapp/test_recipient_matching.py

6.2 Key Test Scenarios Covered

  1. Jurisdiction Restrictions: Phone numbers with prefixes +1, +20, +84, and +234 raise DestinationCountryRestrictedError and are rejected before placing any API call.
  2. Permission Gating: Calling a recipient without an active permission record raises WhatsAppPermissionRequiredError (and TelephonyPermissionRequiredError).
  3. Database Concurrency: Simulated concurrent webhook writes to upsert_whatsapp_call_permission verify rollback on IntegrityError and successful retry update.
  4. Campaign Completion Protection: Campaigns with claimable count = 0 but parked runs count > 0 remain active.
  5. Periodic Sweeper: Verified sweep_parked_whatsapp_permissions reactivates parked leads when permission is granted.
  6. Architecture Layering: Verified WhatsAppProvider imports from service.py with zero imports from routes.py.

7. Reviewer Checklist

  • Prerequisite Verification: Confirm the Inbound Calling PR is reviewed or merged first.
  • Alembic Migrations: Review e1a2b3c4d5e6 and b7d2f04c8a15 under api/alembic/versions/.
  • Provider Layering: Verify provider.py does not reference route handlers and delegates state to service.py.
  • Error Hierarchy: Inspect TelephonyPermissionRequiredError in api/services/telephony/base.py and its handling in campaign_call_dispatcher.py.
  • Cron Registration: Verify sweep_parked_whatsapp_permissions is registered in api/tasks/arq.py under WorkerSettings.cron_jobs.
  • UI Integration: Verify PhoneCallDialog.tsx uses backend /permissions/check response without hardcoded client-side country lists.

Summary by cubic

Adds WhatsApp Business-Initiated Calling (outbound) with recipient permission management, campaign lead parking, and a new outbound call UI. Depends on the inbound calling PR (feature/whatsapp-inbound-calling); merge that first.

  • Enforces Meta country restrictions for outbound calls (+1, +20, +84, +234); campaign numbers are canonicalized to E.164 at ingest so formatted uploads dial correctly, and a bracketed national trunk prefix is stripped only when it directly follows the country code.
  • Tracks recipient call permissions in a new whatsapp_call_permissions table, sends interactive permission requests when missing, and matches permissions by exact canonical digit equality.
  • Parks campaign leads in request_and_wait mode and reactivates them on consent via a 2-minute ARQ sweep; the sync cooldown only arms after parked runs and config are validated.
  • Grants and denials are claimed once so duplicate webhook deliveries can't double-activate a parked run; denials recount processed_rows once per campaign instead of once per queued run.
  • Adds a PhoneCallDialog UI with live call timer, hangup, and custom permission message; campaign UI branches on a provider requires_call_permission capability flag and reads the phone-number count separately from the concurrency sentinel so the missing-numbers warning still renders.
  • Decouples the WhatsApp provider from route handlers via a service.py layer; the pipeline runner installs at FastAPI and ARQ startup instead of via import side effects.
  • Ties the teardown claim to the pipeline task's lifetime so a cancelled pipeline can't re-terminate a call, and from-number pool releases require the acquisition's ownership token so stale retries can't free a re-acquired caller ID.
  • Gates the voice pipeline until the call is answered so no greeting or recording starts while the handset is ringing, and refuses webhook verify-token matches shared across more than one configuration.

Migration

  • Runs three Alembic migrations: e1a2b3c4d5e6, b7d2f04c8a15, and c3f5a1b90d47.
  • Set WHATSAPP_WEBHOOK_VERIFY_TOKEN for webhook verification; updates the pipecat submodule (rebuild needed).

Written for commit 22632e8. Summary will update on new commits.

Review in cubic

RetriggerConfidence Score: 5/5

Safe to merge; no outstanding blocking issues remain.

Findings

  1. P1 Normalize Campaign Numbers First
  2. P1 Validate canonical duplicates

Summary

  • Adds persistent WhatsApp recipient permissions, webhook processing, destination restrictions, and permission-request messaging.
  • Parks campaign contacts until permission is granted and reactivates them through periodic worker synchronization.
  • Adds outbound WebRTC call controls and campaign permission settings.
  • Canonicalizes campaign destinations and detects canonical duplicates without removing a subscriber zero from formatted phone numbers.

Reviews (8) · Last reviewed commit: "fix(telephony): handle split country cod..."

…nd permission management

Integrates WhatsApp Business Calling API with WebRTC and Pipecat pipeline,
supporting both User-Initiated (inbound) and Business-Initiated (outbound)
calls with permission management and campaign dispatch integration.

## Backend
- Provider: WhatsApp Business API implementation using WebRTC media transport
  - Inbound and outbound voice calling support
  - Geographic restriction validation (Meta policy exclusions for +1, +20, +84, +234)
  - Interactive call permission request messaging with 24-hour expiration
  - Dedicated service layer (service.py) decoupling active WebRTC connections, client caching, and Redis pub/sub from HTTP routes
  - Scoped recipient matching across phone format variations and telephony configurations
- Campaign Orchestration & Dispatch:
  - Polymorphic TelephonyPermissionRequiredError handling in generic campaign dispatcher
  - Campaign orchestrator completion checks updated to retain parked runs
  - ARQ periodic cron task (sweep_parked_whatsapp_permissions) for automatic lead reactivation upon permission grant
- Database & Migrations:
  - whatsapp_call_permissions model with unique constraint and indexes
  - Alembic migrations for permission tracking and parked queued runs index
  - Concurrency race handling with rollback and update retry on permission upsert
- Webhooks & Security:
  - Meta webhook signature verification enforced across all endpoints including empty payloads
  - Webhook delivery and status processing for interactive permission responses

## Frontend
- PhoneCallDialog:
  - Real-time WhatsApp call permission checking via backend API
  - Integrated permission request flow with custom message preview
  - Live call status progression, duration timer, and in-dialog termination controls
- Campaigns:
  - WhatsAppPermissionCard component for managing campaign permission behavior (request_and_wait vs skip)
  - Campaign advanced settings and runs table integration

## Submodule & Tests
- Updated Pipecat submodule with outbound calling and permission request methods
- Comprehensive unit test suites covering restrictions, recipient matching, permissions gating, orchestrator lifecycle, concurrency races, and provider decoupling
@github-actions github-actions Bot added the feat New feature (changelog: Features) label Sep 11, 2026

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

6 issues found across 79 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="api/db/models.py">

<violation number="1" location="api/db/models.py:438">
P2: Every permission-message status webhook looks up `meta_message_id`, but this column has no index, so each callback scans the entire permissions table as it grows. Add a partial index on `meta_message_id` and include it in the migration.</violation>
</file>

<file name="api/db/workflow_run_client.py">

<violation number="1" location="api/db/workflow_run_client.py:120">
P2: This per-lead lookup has no index on `queued_run_id`, so campaign dispatches will repeatedly scan the full `workflow_runs` table at scale. Add a migration for an index on `queued_run_id` (ideally including `created_at` for this latest-row query).</violation>
</file>

<file name="docker-compose.override.yaml">

<violation number="1" location="docker-compose.override.yaml:11">
P2: This file is auto-loaded by every `docker compose` command in the repo, so it silently replaces the registry-backed stack with local builds and `pull_policy: never` for all deployments that clone the repo (start_docker.sh, remote_up.sh, update_remote.sh), not just opted-in local dev. Docstrings and the base compose drive deployment through registry pulls (`--pull always`); on a fresh host that has never built dograh-local images, `pull_policy: never` means nothing is pulled and the api/ui containers fail to start. The same content is already generated at runtime by setup_remote.sh only for build mode, so this should be opt-in rather than committed as a repo-root auto-loaded override.</violation>
</file>

<file name="api/db/telephony_configuration_client.py">

<violation number="1" location="api/db/telephony_configuration_client.py:30">
P2: When a permission row was stored with punctuation or spacing, a later canonical lookup misses it and inserts a second row for the same recipient. Compare canonicalized stored values or migrate/merge legacy rows before relying on this candidate list.</violation>
</file>

<file name="api/alembic/versions/b7d2f04c8a15_index_parked_queued_runs.py">

<violation number="1" location="api/alembic/versions/b7d2f04c8a15_index_parked_queued_runs.py:29">
P1: This index targets queued_runs.retry_reason, but that column is introduced by fefdd1835b7d (separate branch, down_revision a75ae71af479), which is not an ancestor of this migration. Applying this lineage on a clean database raises 'column queued_runs.retry_reason does not exist'. Set down_revision to a migration whose lineage includes the retry_reason column (merge the retry/inbound PR into this chain first), or this migration fails on fresh deploys.</violation>
</file>

<file name="api/services/telephony/providers/whatsapp/provider.py">

<violation number="1" location="api/services/telephony/providers/whatsapp/provider.py:300">
P1: When Meta successfully reports that permission is revoked or absent but the local status update fails, this fallback can dial using a stale local grant. Only use the local-permission fallback when `check_call_permission` itself is unavailable, not after a successful Meta response.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread api/services/telephony/providers/whatsapp/routes.py
Comment thread api/routes/telephony.py Outdated
Comment thread ui/src/app/workflow/[workflowId]/components/PhoneCallDialog.tsx Outdated
Comment thread ui/src/app/workflow/[workflowId]/components/PhoneCallDialog.tsx
"Please generate a fresh token in Meta Business Manager and update your Telephony Configuration."
),
)
has_permission = _local_permission_is_usable(perm, now)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1: When Meta successfully reports that permission is revoked or absent but the local status update fails, this fallback can dial using a stale local grant. Only use the local-permission fallback when check_call_permission itself is unavailable, not after a successful Meta response.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At api/services/telephony/providers/whatsapp/provider.py, line 300:

<comment>When Meta successfully reports that permission is revoked or absent but the local status update fails, this fallback can dial using a stale local grant. Only use the local-permission fallback when `check_call_permission` itself is unavailable, not after a successful Meta response.</comment>

<file context>
@@ -0,0 +1,1231 @@
+                            "Please generate a fresh token in Meta Business Manager and update your Telephony Configuration."
+                        ),
+                    )
+                has_permission = _local_permission_is_usable(perm, now)
+            else:
+                meta_checked = True
</file context>

Comment thread api/routes/campaign.py Outdated
Comment thread api/services/telephony/providers/whatsapp/provider.py Outdated
Comment thread api/db/campaign_client.py Outdated
Comment thread api/db/campaign_client.py
Comment thread api/db/telephony_configuration_client.py Outdated
Comment thread api/db/campaign_client.py Outdated
Comment thread api/services/campaign/campaign_call_dispatcher.py Outdated
Comment thread ui/src/app/campaigns/[campaignId]/edit/page.tsx Outdated
Comment thread api/routes/telephony.py Outdated
Comment thread api/services/telephony/providers/whatsapp/routes.py Outdated
Comment thread api/services/campaign/campaign_call_dispatcher.py Outdated

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

4 issues found across 39 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="api/services/pipecat/event_handlers.py">

<violation number="1" location="api/services/pipecat/event_handlers.py:87">
P2: Existing `asyncio.Event` callers now fail because `Event.wait()` returns `True`, not `ANSWERED`, so the handler skips recording and the greeting. Update those callers and tests to use `OutboundCallGate.resolve(ANSWERED)`, or explicitly preserve compatibility with `asyncio.Event`.</violation>
</file>

<file name="api/db/campaign_client.py">

<violation number="1" location="api/db/campaign_client.py:27">
P2: Tightening is_same_recipient_number to require full canonical-digit equality (plus the matching exact-digit SQL prefilter in get_queued_runs_awaiting_whatsapp_permission) means any parked lead stored without its country code will never be reactivated by a permission grant webhook or cron sweep, so the run strands until the 24h park expiry and fails even though the recipient consented. The new docstring assumes every stored lead starts with '+' (validated at ingest), but parked runs created from legacy or pre-validation data are not guaranteed to satisfy that. If any such rows exist, the grant path silently stops working for them. Confirm no legacy leads lack the '+' prefix, or run an ingest-normalization migration first.</violation>
</file>

<file name="api/db/models.py">

<violation number="1" location="api/db/models.py:468">
P2: `update_whatsapp_call_permission_status_by_message_id` resolves the row with `scalars().first()` and no ORDER BY, but the new `ix_whatsapp_perm_meta_message_id` is deliberately non-unique. If a wamid ever spans two rows (the exact case the models comment says the webhook must 'degrade to an extra row' for), the update lands on an arbitrary row, granting/denying the wrong recipient and moving the wrong campaign run. Order the lookup deterministically (e.g. by id) so a replayed wamid always updates the same row.</violation>

<violation number="2" location="api/db/models.py:470">
P3: Permission updates never clear `meta_message_id` after a request finishes, so this index accumulates historical rows instead of the claimed outstanding-request slice. Remove the partial predicate or clear IDs through a defined terminal-state lifecycle.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread api/services/pipecat/event_handlers.py
Comment thread api/services/call_concurrency/service.py Outdated
Comment thread api/services/telephony/providers/whatsapp/routes.py Outdated
Comment thread api/services/telephony/providers/whatsapp/restrictions.py Outdated
Comment thread ui/src/app/workflow/[workflowId]/components/PhoneCallDialog.tsx Outdated
Comment thread api/app.py Outdated
Comment thread api/db/models.py
Index(
"ix_whatsapp_perm_meta_message_id",
"meta_message_id",
postgresql_where=text("meta_message_id IS NOT NULL"),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P3: Permission updates never clear meta_message_id after a request finishes, so this index accumulates historical rows instead of the claimed outstanding-request slice. Remove the partial predicate or clear IDs through a defined terminal-state lifecycle.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At api/db/models.py, line 470:

<comment>Permission updates never clear `meta_message_id` after a request finishes, so this index accumulates historical rows instead of the claimed outstanding-request slice. Remove the partial predicate or clear IDs through a defined terminal-state lifecycle.</comment>

<file context>
@@ -458,6 +458,17 @@ class WhatsAppCallPermissionModel(Base):
+        Index(
+            "ix_whatsapp_perm_meta_message_id",
+            "meta_message_id",
+            postgresql_where=text("meta_message_id IS NOT NULL"),
+        ),
     )
</file context>

Comment thread api/db/telephony_phone_number_client.py
Comment thread ui/Dockerfile Outdated
Comment thread api/services/call_concurrency/service.py Outdated
…tcher mocks, cooldown timing, and E.164 validation

- Use atomic Redis counter in Lua script to prevent token collision in rate_limiter
- Restore acquire_from_number on CampaignCallDispatcher and configure token mocks in tests
- Arm WhatsApp permission sync cooldown only after validating parked runs and config
- Enforce strict E.164 validation and isolate route checks to WhatsApp provider
Comment thread api/app.py Outdated
Comment thread api/routes/campaign.py Outdated

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

3 issues found across 30 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="api/services/campaign/campaign_call_dispatcher.py">

<violation number="1" location="api/services/campaign/campaign_call_dispatcher.py:307">
P3: This PR removed the last production caller of `db_client.increment_campaign_processed_rows`, leaving the method (and its SQL in campaign_client.py:636-661) dead code. Delete it to avoid confusion with the new recompute-based `sync_campaign_processed_rows`.</violation>
</file>

<file name="api/db/telephony_phone_number_client.py">

<violation number="1" location="api/db/telephony_phone_number_client.py:414">
P2: When concurrent requests race to set different default caller IDs, this branch raises `TelephonyPhoneNumberConflictError`, but the endpoint leaves it unhandled and returns a generic 500. Catch this exception in the route and return the same 409 conflict response used for phone-number conflicts.</violation>
</file>

<file name="api/routes/campaign.py">

<violation number="1" location="api/routes/campaign.py:580">
P2: When a user clicks sync during the 30-second cooldown, this response sets `throttled` but the campaign page ignores it and falsely reports that no recipient granted permission. Update the client type and UI to distinguish a cooldown retry from a completed sync.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread api/services/telephony/providers/whatsapp/restrictions.py
Comment thread api/db/campaign_client.py
Comment thread api/routes/campaign.py Outdated
"reactivated_count": result.reactivated,
# Distinguishes "skipped, try again shortly" from "asked Meta, nobody
# has granted permission yet" - both reactivate zero runs.
"throttled": result.throttled,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: When a user clicks sync during the 30-second cooldown, this response sets throttled but the campaign page ignores it and falsely reports that no recipient granted permission. Update the client type and UI to distinguish a cooldown retry from a completed sync.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At api/routes/campaign.py, line 580:

<comment>When a user clicks sync during the 30-second cooldown, this response sets `throttled` but the campaign page ignores it and falsely reports that no recipient granted permission. Update the client type and UI to distinguish a cooldown retry from a completed sync.</comment>

<file context>
@@ -562,17 +562,22 @@ async def sync_campaign_whatsapp_permissions(
+        "reactivated_count": result.reactivated,
+        # Distinguishes "skipped, try again shortly" from "asked Meta, nobody
+        # has granted permission yet" - both reactivate zero runs.
+        "throttled": result.throttled,
     }
 
</file context>

Comment thread api/utils/telephony_address.py Outdated
# commits then race for uq_phone_numbers_default_caller. Same
# failure mode create_phone_number already handles.
await session.rollback()
raise TelephonyPhoneNumberConflictError(str(e)) from e

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: When concurrent requests race to set different default caller IDs, this branch raises TelephonyPhoneNumberConflictError, but the endpoint leaves it unhandled and returns a generic 500. Catch this exception in the route and return the same 409 conflict response used for phone-number conflicts.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At api/db/telephony_phone_number_client.py, line 414:

<comment>When concurrent requests race to set different default caller IDs, this branch raises `TelephonyPhoneNumberConflictError`, but the endpoint leaves it unhandled and returns a generic 500. Catch this exception in the route and return the same 409 conflict response used for phone-number conflicts.</comment>

<file context>
@@ -402,7 +402,16 @@ async def update_phone_number(
+                # commits then race for uq_phone_numbers_default_caller. Same
+                # failure mode create_phone_number already handles.
+                await session.rollback()
+                raise TelephonyPhoneNumberConflictError(str(e)) from e
             await session.refresh(row)
             return row
</file context>

return

try:
actual = await db_client.sync_campaign_processed_rows(campaign_id)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P3: This PR removed the last production caller of db_client.increment_campaign_processed_rows, leaving the method (and its SQL in campaign_client.py:636-661) dead code. Delete it to avoid confusion with the new recompute-based sync_campaign_processed_rows.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At api/services/campaign/campaign_call_dispatcher.py, line 307:

<comment>This PR removed the last production caller of `db_client.increment_campaign_processed_rows`, leaving the method (and its SQL in campaign_client.py:636-661) dead code. Delete it to avoid confusion with the new recompute-based `sync_campaign_processed_rows`.</comment>

<file context>
@@ -275,50 +285,34 @@ async def process_batch(self, campaign_id: int, batch_size: int = 10) -> int:
         try:
-            await db_client.increment_campaign_processed_rows(
-                campaign_id=campaign_id, delta=delta
+            actual = await db_client.sync_campaign_processed_rows(campaign_id)
+            logger.debug(
+                f"Campaign {campaign_id} processed_rows synced to {actual}"
</file context>

Comment thread ui/src/app/campaigns/[campaignId]/page.tsx
Comment thread ui/Dockerfile Outdated
Comment thread api/tests/test_telephony_address.py Outdated
Comment thread api/utils/telephony_address.py Outdated
…ion sync and layering

- Tie the pipeline teardown claim to the task's lifetime so an abandoned
  pipeline cannot re-terminate a call after the canceller stops waiting
- Extract campaign permission orchestration into permission_sync.py,
  keeping service.py to call transport and lifecycle
- Make sync_campaign_processed_rows the only writer of processed_rows,
  locking the campaign row before counting
- Honour the activation/denial claim result so duplicate grants no longer
  over-report or enqueue extra campaign batches
- Move generate_turn_credentials into api/services/turn.py and fix the
  TTL argument that passed TURN_SECRET as an int
- Centralise WhatsApp permission status classification in config.py
- Give the campaign sync endpoint a response model and use the generated
  SDK operation in the UI
- Refuse an ambiguous webhook verify-token match across configurations
- Repoint tests at the modules the code now lives in; regenerate client
Comment on lines +72 to +79
if not is_e164(phone_number):
raise HTTPException(
status_code=400,
detail=(
"Phone number must be in strict E.164 format, including the country "
f"code with a leading '+' (e.g. +14155552671). Got: {phone_number!r}"
),
)

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 Normalize Campaign Numbers First

Campaign ingestion accepts and retains formatted international numbers such as +44 7123 456789, but WhatsApp dispatch rejects the same stored value because it requires punctuation-free E.164. Those valid campaign contacts are marked failed before a call is placed. Normalize the value during ingestion or apply the same strict validation before storing it.

Artifacts

Evidence from the check

  • A narrow Python reproduction invokes the production campaign validator and WhatsApp destination validator with the formatted UK number, showing the exact exercised path.

Command output from the check

  • Captured execution output shows ingestion accepted the formatted number and dispatch returned HTTP 400, confirming the incompatible validation rules.

View artifacts

T-Rex Ran code and verified through T-Rex

Comment thread ui/src/app/campaigns/[campaignId]/page.tsx Outdated

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

1 issue found across 35 files (changes from recent commits).

Not reviewed (too large): ui/src/client/index.ts (~4 lines) - if these are generated or fixture files, add them to ignored paths to exclude them from future reviews.

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="api/services/telephony/providers/whatsapp/restrictions.py">

<violation number="1" location="api/services/telephony/providers/whatsapp/restrictions.py:72">
P1: Formatted international campaign numbers pass ingestion but are rejected here before outbound calling. Canonicalize the destination before this check, or enforce the same strict `is_e164` rule during campaign ingestion.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

"""
from api.utils.telephony_address import is_e164

if not is_e164(phone_number):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1: Formatted international campaign numbers pass ingestion but are rejected here before outbound calling. Canonicalize the destination before this check, or enforce the same strict is_e164 rule during campaign ingestion.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At api/services/telephony/providers/whatsapp/restrictions.py, line 72:

<comment>Formatted international campaign numbers pass ingestion but are rejected here before outbound calling. Canonicalize the destination before this check, or enforce the same strict `is_e164` rule during campaign ingestion.</comment>

<file context>
@@ -83,11 +62,22 @@ def is_restricted_country(phone_number: str) -> Tuple[bool, Optional[str]]:
     """
+    from api.utils.telephony_address import is_e164
+
+    if not is_e164(phone_number):
+        raise HTTPException(
+            status_code=400,
</file context>

Comment thread api/services/telephony/providers/whatsapp/provider.py Outdated
Comment thread api/tests/telephony/whatsapp/test_routes.py Outdated
Comment thread api/tests/test_from_number_pool_isolation.py
Comment thread api/routes/public_embed.py Outdated
Comment thread ui/src/app/campaigns/[campaignId]/page.tsx Outdated
Comment thread api/db/campaign_client.py Outdated
Comment thread api/services/telephony/providers/whatsapp/service.py
…rom provider capability

- Normalize lead phone numbers to strict E.164 at campaign ingest and
  canonicalize again on the WhatsApp dial path, so a formatted number
  accepted at upload is no longer failed before a call is placed
- Add ProviderSpec.requires_call_permission and surface it on the
  telephony configuration responses; the campaign UI branches on that
  capability instead of comparing provider names
- Classify Meta permission status once from the normalized value so a
  permanent grant is not stored as temporary without an expiry
- Recount processed_rows once per campaign after denials instead of once
  per queued run, which serialized exclusive locks on the webhook path
- Defer the whole teardown to handle_call_terminate when it claimed the
  pipeline, rather than repeating pops and slot releases in the finalizer
- Move generate_turn_credentials to api/services/turn.py and merge the
  split api.constants imports it left behind
- Refuse an ambiguous webhook verify-token match across configurations
- Cover the dial-path validation, the per-campaign recount and the import
  isolation rule with tests that fail when the behavior regresses

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

1 issue found across 35 files (changes from recent commits).

Not reviewed (too large): ui/src/client/index.ts (~4 lines) - if these are generated or fixture files, add them to ignored paths to exclude them from future reviews.

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="api/services/campaign/sources/csv.py">

<violation number="1" location="api/services/campaign/sources/csv.py:41">
P2: When a CSV contains the same number in different accepted formatting variants, campaign validation accepts both rows, then this assignment canonicalizes them to the same stored number and the dispatcher places duplicate calls. Normalize phone numbers before duplicate validation or reject duplicates using their canonical E.164 values.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread ui/src/app/campaigns/CampaignAdvancedSettings.tsx Outdated
# exactly as supplied, so validation still reports it the same way.
canonical_phone = canonicalize_e164(context_vars.get("phone_number"))
if canonical_phone:
context_vars["phone_number"] = canonical_phone

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: When a CSV contains the same number in different accepted formatting variants, campaign validation accepts both rows, then this assignment canonicalizes them to the same stored number and the dispatcher places duplicate calls. Normalize phone numbers before duplicate validation or reject duplicates using their canonical E.164 values.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At api/services/campaign/sources/csv.py, line 41:

<comment>When a CSV contains the same number in different accepted formatting variants, campaign validation accepts both rows, then this assignment canonicalizes them to the same stored number and the dispatcher places duplicate calls. Normalize phone numbers before duplicate validation or reject duplicates using their canonical E.164 values.</comment>

<file context>
@@ -30,6 +31,15 @@ def _build_context_variables(
+        # exactly as supplied, so validation still reports it the same way.
+        canonical_phone = canonicalize_e164(context_vars.get("phone_number"))
+        if canonical_phone:
+            context_vars["phone_number"] = canonical_phone
+
         override_type = (
</file context>

Comment thread api/services/telephony/providers/whatsapp/permission_sync.py Outdated
Comment thread api/utils/telephony_address.py Outdated
Comment thread api/tests/telephony/whatsapp/test_provider.py Outdated
Comment thread api/routes/organization.py Outdated
Comment on lines +39 to +41
canonical_phone = canonicalize_e164(context_vars.get("phone_number"))
if canonical_phone:
context_vars["phone_number"] = canonical_phone

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 Validate canonical duplicates

CSV validation compares the raw phone strings before this code canonicalizes them. A file containing +44 7123 456789 and +447123456789 is accepted, then creates two queued runs with the same canonical recipient, +447123456789. That recipient can receive duplicate permission requests or outbound calls. Canonicalize phone numbers before validating duplicates.

Knowledge Base Used:

Artifacts

Evidence from the check

  • Shows the authored Python harness that executes validation and sync for HEAD^ and the checked-out PR, ending with the takeaway that the actual bulk-insert payload is inspected.

Command output from the check

  • Runs the harness against HEAD^; validation passes and two queued rows retain distinct raw phone values, establishing the pre-change baseline.

Command output from the check

  • Runs the harness against the checked-out revision; validation passes, two rows are queued, and both persisted context phones are the identical canonical value, establishing the regression.

View artifacts

T-Rex Ran code and verified through T-Rex

Comment thread api/services/telephony/providers/whatsapp/service.py
Comment thread api/services/telephony/factory.py Outdated
…egistry-driven UI

- Canonicalize lead numbers to strict E.164 at campaign ingest and again on
  the WhatsApp dial path, dropping a bracketed national trunk prefix so
  '+44 (0) 20 7946 0958' does not become a valid-looking wrong number
- Detect duplicate leads by canonical number, so two spellings of one
  contact no longer produce two queued runs
- Shield the terminate owner's cleanup: cancelling it mid-disconnect no
  longer leaves a run incomplete with its concurrency slot held
- Recount processed_rows once per campaign, from a finally so partial
  denials still update progress
- Drive the campaign UI from ProviderSpec.requires_call_permission instead
  of provider names, read off the registry in the route rather than through
  the shared telephony factory
- Keep the no-phone-numbers warning visible for permission-based providers
- Replace the formatted-destination test that hit Meta's live API with a
  mocked one asserting the canonical number reaches every downstream step

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

2 issues found across 40 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="ui/src/app/campaigns/CampaignAdvancedSettings.tsx">

<violation number="1" location="ui/src/app/campaigns/CampaignAdvancedSettings.tsx:153">
P3: On the campaign edit page the newly ungated "No phone numbers configured" warning can never render for a consent-based (WhatsApp) provider: edit/page.tsx passes `fromNumbersCount={effectiveFromNumbers}` where `effectiveFromNumbers = requiresCallPermission ? 1 : ...`, so `fromNumbersCount === 0` is always false there. The comment claims the gate was removed so the warning also appears for consent-based providers, but only the new-campaign page (which passes the real `phone_number_count`) actually shows it. Pass the real number count to the warning (separate from the concurrency sentinel) so the edit page can surface it too.</violation>
</file>

<file name="api/db/campaign_client.py">

<violation number="1" location="api/db/campaign_client.py:1090">
P3: The new regexp_replace prefilter is not a strict superset of is_same_recipient_number, despite the comment claiming it matches 'exactly the reduction' the matcher performs. is_same_recipient_number also accepts via normalize_telephony_address(...).canonical == target_canonical, which strips a bracketed national trunk prefix: '+44 (0) 20 7946 0958' → '+442079460958'. SQL digit-stripping keeps that '0', so such a stored row fails the regexp_replace equality and is dropped before the Python matcher sees it. Ingest currently canonicalizes leads to E.164 before storage, which hides the gap, but the comment's own premise is that stored numbers can be formatted — for that case the filter still misses the trunk-prefix spelling. Tighten the SQL reduction to also drop a bracketed '(0)' (or at least correct the comment so it does not claim the prefilter is a superset of the matcher).</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread api/utils/telephony_address.py Outdated
consent-based provider does not have, but every provider
still needs an active number to dial from, so hiding this
hid the one warning that the campaign cannot run at all. */}
{fromNumbersCount === 0 && (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P3: On the campaign edit page the newly ungated "No phone numbers configured" warning can never render for a consent-based (WhatsApp) provider: edit/page.tsx passes fromNumbersCount={effectiveFromNumbers} where effectiveFromNumbers = requiresCallPermission ? 1 : ..., so fromNumbersCount === 0 is always false there. The comment claims the gate was removed so the warning also appears for consent-based providers, but only the new-campaign page (which passes the real phone_number_count) actually shows it. Pass the real number count to the warning (separate from the concurrency sentinel) so the edit page can surface it too.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At ui/src/app/campaigns/CampaignAdvancedSettings.tsx, line 153:

<comment>On the campaign edit page the newly ungated "No phone numbers configured" warning can never render for a consent-based (WhatsApp) provider: edit/page.tsx passes `fromNumbersCount={effectiveFromNumbers}` where `effectiveFromNumbers = requiresCallPermission ? 1 : ...`, so `fromNumbersCount === 0` is always false there. The comment claims the gate was removed so the warning also appears for consent-based providers, but only the new-campaign page (which passes the real `phone_number_count`) actually shows it. Pass the real number count to the warning (separate from the concurrency sentinel) so the edit page can surface it too.</comment>

<file context>
@@ -145,9 +145,14 @@ export default function CampaignAdvancedSettings({
+                    consent-based provider does not have, but every provider
+                    still needs an active number to dial from, so hiding this
+                    hid the one warning that the campaign cannot run at all. */}
+                {fromNumbersCount === 0 && (
                     <p className="text-sm text-amber-600 dark:text-amber-400">
-                        No phone numbers configured. Add CLIs in <Link href="/telephony-configurations" className="underline font-medium">Telephony Configuration</Link> before running the campaign.
</file context>

Comment thread api/db/campaign_client.py
candidate_filters = [
QueuedRunModel.context_variables["phone_number"].as_string() == raw_trimmed,
QueuedRunModel.context_variables["phone_number"].as_string()
== target_digits,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P3: The new regexp_replace prefilter is not a strict superset of is_same_recipient_number, despite the comment claiming it matches 'exactly the reduction' the matcher performs. is_same_recipient_number also accepts via normalize_telephony_address(...).canonical == target_canonical, which strips a bracketed national trunk prefix: '+44 (0) 20 7946 0958' → '+442079460958'. SQL digit-stripping keeps that '0', so such a stored row fails the regexp_replace equality and is dropped before the Python matcher sees it. Ingest currently canonicalizes leads to E.164 before storage, which hides the gap, but the comment's own premise is that stored numbers can be formatted — for that case the filter still misses the trunk-prefix spelling. Tighten the SQL reduction to also drop a bracketed '(0)' (or at least correct the comment so it does not claim the prefilter is a superset of the matcher).

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At api/db/campaign_client.py, line 1090:

<comment>The new regexp_replace prefilter is not a strict superset of is_same_recipient_number, despite the comment claiming it matches 'exactly the reduction' the matcher performs. is_same_recipient_number also accepts via normalize_telephony_address(...).canonical == target_canonical, which strips a bracketed national trunk prefix: '+44 (0) 20 7946 0958' → '+442079460958'. SQL digit-stripping keeps that '0', so such a stored row fails the regexp_replace equality and is dropped before the Python matcher sees it. Ingest currently canonicalizes leads to E.164 before storage, which hides the gap, but the comment's own premise is that stored numbers can be formatted — for that case the filter still misses the trunk-prefix spelling. Tighten the SQL reduction to also drop a bracketed '(0)' (or at least correct the comment so it does not claim the prefilter is a superset of the matcher).</comment>

<file context>
@@ -1086,10 +1086,14 @@ async def get_queued_runs_awaiting_whatsapp_permission(
-            QueuedRunModel.context_variables["phone_number"].as_string() == target_canonical,
-            QueuedRunModel.context_variables["phone_number"].as_string() == target_no_plus,
+            QueuedRunModel.context_variables["phone_number"].as_string()
+            == target_digits,
+            QueuedRunModel.context_variables["phone_number"].as_string()
+            == f"+{target_digits}",
</file context>

…one-number count

- Only drop a bracketed '(0)' directly after the country code: the previous
  pattern matched one anywhere in the number and deleted a subscriber digit,
  changing the call destination
- Pass the configured phone-number count to CampaignAdvancedSettings
  separately from the concurrency sentinel, so the 'no phone numbers'
  warning can render on the edit page for consent-based providers
- Regenerate docs/api-reference/openapi.json for the routes and schema
  fields added earlier in this branch

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

2 issues found across 5 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="ui/src/app/campaigns/new/page.tsx">

<violation number="1" location="ui/src/app/campaigns/new/page.tsx:533">
P3: configuredPhoneNumberCount is passed the exact same expression as fromNumbersCount (line 532 uses availableFromNumbersCount, which line 232 defines as selectedTelephonyConfig?.phone_number_count ?? fromNumbersCount). This page never substitutes a concurrency sentinel for fromNumbersCount the way the edit page does, so the new prop is a no-op and the component's (configuredPhoneNumberCount ?? fromNumbersCount) === 0 warning falls back to the identical value. Remove the redundant prop (the warning then correctly uses fromNumbersCount) or, if a real distinction is intended, pass the selected config's actual count while keeping the fallback distinct.</violation>
</file>

<file name="api/utils/telephony_address.py">

<violation number="1" location="api/utils/telephony_address.py:58">
P1: When formatting splits a three-digit country code, this pattern fails to recognize the following `(0)` trunk prefix and leaves `0` in the canonical number. Allow formatting between the country-code digits while still limiting the prefix to at most three digits.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread api/utils/telephony_address.py Outdated
# directly after it is the trunk prefix. One appearing later is part of the
# subscriber number's punctuation, and dropping its digit would change who
# gets dialled - so that case falls through to plain bracket stripping.
_TRUNK_PREFIX_RE = re.compile(r"^(\+\s*[0-9]{1,3}[\s\-.]*)\(\s*0\s*\)")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1: When formatting splits a three-digit country code, this pattern fails to recognize the following (0) trunk prefix and leaves 0 in the canonical number. Allow formatting between the country-code digits while still limiting the prefix to at most three digits.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At api/utils/telephony_address.py, line 58:

<comment>When formatting splits a three-digit country code, this pattern fails to recognize the following `(0)` trunk prefix and leaves `0` in the canonical number. Allow formatting between the country-code digits while still limiting the prefix to at most three digits.</comment>

<file context>
@@ -50,7 +50,12 @@ class NormalizedAddress:
+# directly after it is the trunk prefix. One appearing later is part of the
+# subscriber number's punctuation, and dropping its digit would change who
+# gets dialled - so that case falls through to plain bracket stripping.
+_TRUNK_PREFIX_RE = re.compile(r"^(\+\s*[0-9]{1,3}[\s\-.]*)\(\s*0\s*\)")
 
 
</file context>
Suggested change
_TRUNK_PREFIX_RE = re.compile(r"^(\+\s*[0-9]{1,3}[\s\-.]*)\(\s*0\s*\)")
_TRUNK_PREFIX_RE = re.compile(r"^(\+\s*[0-9](?:[\s\-.]*[0-9]){0,2}[\s\-.]*)\(\s*0\s*\)")

orgConcurrentLimit={orgConcurrentLimit}
fromNumbersCount={fromNumbersCount}
fromNumbersCount={availableFromNumbersCount}
configuredPhoneNumberCount={selectedTelephonyConfig?.phone_number_count ?? fromNumbersCount}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P3: configuredPhoneNumberCount is passed the exact same expression as fromNumbersCount (line 532 uses availableFromNumbersCount, which line 232 defines as selectedTelephonyConfig?.phone_number_count ?? fromNumbersCount). This page never substitutes a concurrency sentinel for fromNumbersCount the way the edit page does, so the new prop is a no-op and the component's (configuredPhoneNumberCount ?? fromNumbersCount) === 0 warning falls back to the identical value. Remove the redundant prop (the warning then correctly uses fromNumbersCount) or, if a real distinction is intended, pass the selected config's actual count while keeping the fallback distinct.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At ui/src/app/campaigns/new/page.tsx, line 533:

<comment>configuredPhoneNumberCount is passed the exact same expression as fromNumbersCount (line 532 uses availableFromNumbersCount, which line 232 defines as selectedTelephonyConfig?.phone_number_count ?? fromNumbersCount). This page never substitutes a concurrency sentinel for fromNumbersCount the way the edit page does, so the new prop is a no-op and the component's (configuredPhoneNumberCount ?? fromNumbersCount) === 0 warning falls back to the identical value. Remove the redundant prop (the warning then correctly uses fromNumbersCount) or, if a real distinction is intended, pass the selected config's actual count while keeping the fallback distinct.</comment>

<file context>
@@ -530,6 +530,7 @@ export default function NewCampaignPage() {
                                         effectiveLimit={effectiveLimit}
                                         orgConcurrentLimit={orgConcurrentLimit}
                                         fromNumbersCount={availableFromNumbersCount}
+                                        configuredPhoneNumberCount={selectedTelephonyConfig?.phone_number_count ?? fromNumbersCount}
                                         retryEnabled={retryEnabled}
                                         onRetryEnabledChange={setRetryEnabled}
</file context>

…d-calling

# Conflicts:
#	api/services/campaign/source_sync.py
#	api/services/pipecat/event_handlers.py
#	pipecat
#	ui/src/client/index.ts
#	ui/src/client/sdk.gen.ts
#	ui/src/client/types.gen.ts
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feat New feature (changelog: Features)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant