From fe4ea648e4911abe086b3269e0474b1e0ee42fd3 Mon Sep 17 00:00:00 2001 From: Abhishek Date: Tue, 17 Feb 2026 21:04:15 +0530 Subject: [PATCH] Feat/campaign enhancements (#163) * feat: add circuit breaker to safeguard * feat: Add Circuit breaker in campaigns to safeguard against telephony failures * feat: add schedules in campaigns --- api/constants.py | 9 + api/db/campaign_client.py | 3 + api/routes/campaign.py | 165 +++++- api/routes/telephony.py | 7 + .../campaign/campaign_event_protocol.py | 16 + .../campaign/campaign_event_publisher.py | 27 + .../campaign/campaign_orchestrator.py | 104 ++++ api/services/campaign/circuit_breaker.py | 301 +++++++++++ api/services/campaign/runner.py | 4 + api/tests/test_circuit_breaker.py | 504 ++++++++++++++++++ api/tests/test_user_turn_stop_scenarios.py | 51 +- .../campaigns/CampaignAdvancedSettings.tsx | 300 +++++++++++ .../app/campaigns/[campaignId]/edit/page.tsx | 364 +++++++++++++ ui/src/app/campaigns/[campaignId]/page.tsx | 92 +++- ui/src/app/campaigns/new/page.tsx | 154 ++---- ui/src/client/sdk.gen.ts | 17 +- ui/src/client/types.gen.ts | 68 +++ 17 files changed, 2037 insertions(+), 149 deletions(-) create mode 100644 api/services/campaign/circuit_breaker.py create mode 100644 api/tests/test_circuit_breaker.py create mode 100644 ui/src/app/campaigns/CampaignAdvancedSettings.tsx create mode 100644 ui/src/app/campaigns/[campaignId]/edit/page.tsx diff --git a/api/constants.py b/api/constants.py index 34fcba7..6f55e8d 100644 --- a/api/constants.py +++ b/api/constants.py @@ -104,6 +104,15 @@ DEFAULT_CAMPAIGN_RETRY_CONFIG = { } +# Circuit breaker defaults for campaign call failure detection +DEFAULT_CIRCUIT_BREAKER_CONFIG = { + "enabled": True, + "failure_threshold": 0.5, # 50% failure rate trips the breaker + "window_seconds": 120, # 2-minute sliding window + "min_calls_in_window": 5, # Don't trip until at least 5 outcomes +} + + TURN_SECRET = os.getenv("TURN_SECRET") TURN_HOST = os.getenv("TURN_HOST", "localhost") TURN_PORT = int(os.getenv("TURN_PORT", "3478")) diff --git a/api/db/campaign_client.py b/api/db/campaign_client.py index 2e0a53b..6a873cd 100644 --- a/api/db/campaign_client.py +++ b/api/db/campaign_client.py @@ -21,6 +21,7 @@ class CampaignClient(BaseDBClient): organization_id: int, retry_config: Optional[dict] = None, max_concurrency: Optional[int] = None, + schedule_config: Optional[dict] = None, ) -> CampaignModel: """Create a new campaign""" async with self.async_session() as session: @@ -28,6 +29,8 @@ class CampaignClient(BaseDBClient): orchestrator_metadata = {} if max_concurrency is not None: orchestrator_metadata["max_concurrency"] = max_concurrency + if schedule_config is not None: + orchestrator_metadata["schedule_config"] = schedule_config campaign = CampaignModel( name=name, diff --git a/api/routes/campaign.py b/api/routes/campaign.py index 44a3ef4..5c978b5 100644 --- a/api/routes/campaign.py +++ b/api/routes/campaign.py @@ -1,9 +1,10 @@ import json from datetime import datetime from typing import List, Optional +from zoneinfo import ZoneInfo from fastapi import APIRouter, Depends, HTTPException, Query -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_validator, model_validator from api.constants import DEFAULT_CAMPAIGN_RETRY_CONFIG, DEFAULT_ORG_CONCURRENCY_LIMIT from api.db import db_client @@ -46,6 +47,28 @@ async def _get_from_numbers_count(organization_id: int) -> int: return 0 +async def _validate_max_concurrency(max_concurrency: int, organization_id: int) -> None: + """Validate max_concurrency against org limit and configured phone numbers. + + Raises HTTPException(400) if the value exceeds the effective limit. + """ + org_limit = await _get_org_concurrent_limit(organization_id) + from_numbers_count = await _get_from_numbers_count(organization_id) + effective_limit = ( + min(org_limit, from_numbers_count) if from_numbers_count > 0 else org_limit + ) + if max_concurrency > effective_limit: + if from_numbers_count > 0 and from_numbers_count < org_limit: + raise HTTPException( + status_code=400, + detail=f"max_concurrency ({max_concurrency}) cannot exceed {effective_limit}. You have {from_numbers_count} phone number(s) configured. Add more CLIs in telephony configuration to increase concurrency.", + ) + raise HTTPException( + status_code=400, + detail=f"max_concurrency ({max_concurrency}) cannot exceed organization limit ({effective_limit})", + ) + + class RetryConfigRequest(BaseModel): enabled: bool = True max_retries: int = Field(default=2, ge=0, le=10) @@ -64,6 +87,45 @@ class RetryConfigResponse(BaseModel): retry_on_voicemail: bool +class TimeSlotRequest(BaseModel): + day_of_week: int = Field(..., ge=0, le=6) + start_time: str = Field(..., pattern=r"^\d{2}:\d{2}$") + end_time: str = Field(..., pattern=r"^\d{2}:\d{2}$") + + @model_validator(mode="after") + def validate_times(self): + if self.start_time >= self.end_time: + raise ValueError("start_time must be before end_time") + return self + + +class ScheduleConfigRequest(BaseModel): + enabled: bool = True + timezone: str = "UTC" + slots: List[TimeSlotRequest] = Field(..., min_length=1, max_length=50) + + @field_validator("timezone") + @classmethod + def validate_timezone(cls, v: str) -> str: + try: + ZoneInfo(v) + except (KeyError, Exception): + raise ValueError(f"Invalid timezone: {v}") + return v + + +class TimeSlotResponse(BaseModel): + day_of_week: int + start_time: str + end_time: str + + +class ScheduleConfigResponse(BaseModel): + enabled: bool + timezone: str + slots: List[TimeSlotResponse] + + class CreateCampaignRequest(BaseModel): name: str = Field(..., min_length=1, max_length=255) workflow_id: int @@ -71,6 +133,14 @@ class CreateCampaignRequest(BaseModel): source_id: str # Google Sheet URL or CSV file key retry_config: Optional[RetryConfigRequest] = None max_concurrency: Optional[int] = Field(default=None, ge=1, le=100) + schedule_config: Optional[ScheduleConfigRequest] = None + + +class UpdateCampaignRequest(BaseModel): + name: Optional[str] = Field(None, min_length=1, max_length=255) + retry_config: Optional[RetryConfigRequest] = None + max_concurrency: Optional[int] = Field(default=None, ge=1, le=100) + schedule_config: Optional[ScheduleConfigRequest] = None class CampaignResponse(BaseModel): @@ -89,6 +159,7 @@ class CampaignResponse(BaseModel): completed_at: Optional[datetime] retry_config: RetryConfigResponse max_concurrency: Optional[int] = None + schedule_config: Optional[ScheduleConfigResponse] = None class CampaignsResponse(BaseModel): @@ -138,10 +209,18 @@ def _build_campaign_response(campaign, workflow_name: str) -> CampaignResponse: else DEFAULT_CAMPAIGN_RETRY_CONFIG ) - # Get max_concurrency from orchestrator_metadata + # Get max_concurrency and schedule_config from orchestrator_metadata max_concurrency = None + schedule_config = None if campaign.orchestrator_metadata: max_concurrency = campaign.orchestrator_metadata.get("max_concurrency") + sc = campaign.orchestrator_metadata.get("schedule_config") + if sc: + schedule_config = ScheduleConfigResponse( + enabled=sc.get("enabled", False), + timezone=sc.get("timezone", "UTC"), + slots=[TimeSlotResponse(**slot) for slot in sc.get("slots", [])], + ) return CampaignResponse( id=campaign.id, @@ -159,6 +238,7 @@ def _build_campaign_response(campaign, workflow_name: str) -> CampaignResponse: completed_at=campaign.completed_at, retry_config=RetryConfigResponse(**retry_config), max_concurrency=max_concurrency, + schedule_config=schedule_config, ) @@ -181,31 +261,21 @@ async def create_campaign( if not validation_result.is_valid: raise HTTPException(status_code=400, detail=validation_result.error.message) - # Validate max_concurrency against effective limit (min of org limit and from_numbers count) if request.max_concurrency is not None: - org_limit = await _get_org_concurrent_limit(user.selected_organization_id) - from_numbers_count = await _get_from_numbers_count( - user.selected_organization_id + await _validate_max_concurrency( + request.max_concurrency, user.selected_organization_id ) - effective_limit = ( - min(org_limit, from_numbers_count) if from_numbers_count > 0 else org_limit - ) - if request.max_concurrency > effective_limit: - if from_numbers_count > 0 and from_numbers_count < org_limit: - raise HTTPException( - status_code=400, - detail=f"max_concurrency ({request.max_concurrency}) cannot exceed {effective_limit}. You have {from_numbers_count} phone number(s) configured. Add more CLIs in telephony configuration to increase concurrency.", - ) - raise HTTPException( - status_code=400, - detail=f"max_concurrency ({request.max_concurrency}) cannot exceed organization limit ({effective_limit})", - ) # Build retry_config dict if provided retry_config = None if request.retry_config: retry_config = request.retry_config.model_dump() + # Build schedule_config dict if provided + schedule_config = None + if request.schedule_config: + schedule_config = request.schedule_config.model_dump() + campaign = await db_client.create_campaign( name=request.name, workflow_id=request.workflow_id, @@ -215,6 +285,7 @@ async def create_campaign( organization_id=user.selected_organization_id, retry_config=retry_config, max_concurrency=request.max_concurrency, + schedule_config=schedule_config, ) return _build_campaign_response(campaign, workflow_name) @@ -322,6 +393,62 @@ async def pause_campaign( return _build_campaign_response(campaign, workflow_name or "Unknown") +@router.patch("/{campaign_id}") +async def update_campaign( + campaign_id: int, + request: UpdateCampaignRequest, + user: UserModel = Depends(get_user), +) -> CampaignResponse: + """Update campaign settings (name, retry config, max concurrency, schedule)""" + campaign = await db_client.get_campaign(campaign_id, user.selected_organization_id) + if not campaign: + raise HTTPException(status_code=404, detail="Campaign not found") + + if campaign.state in ["completed", "failed"]: + raise HTTPException( + status_code=400, + detail=f"Cannot update a {campaign.state} campaign", + ) + + if request.max_concurrency is not None: + await _validate_max_concurrency( + request.max_concurrency, user.selected_organization_id + ) + + # Build update kwargs + update_kwargs = {} + + if request.name is not None: + update_kwargs["name"] = request.name + + if request.retry_config is not None: + update_kwargs["retry_config"] = request.retry_config.model_dump() + + # Merge max_concurrency and schedule_config into orchestrator_metadata + metadata = campaign.orchestrator_metadata or {} + metadata_changed = False + + if request.max_concurrency is not None: + metadata["max_concurrency"] = request.max_concurrency + metadata_changed = True + + if request.schedule_config is not None: + metadata["schedule_config"] = request.schedule_config.model_dump() + metadata_changed = True + + if metadata_changed: + update_kwargs["orchestrator_metadata"] = metadata + + if update_kwargs: + await db_client.update_campaign(campaign_id=campaign_id, **update_kwargs) + + # Re-fetch to return updated data + campaign = await db_client.get_campaign(campaign_id, user.selected_organization_id) + workflow_name = await db_client.get_workflow_name(campaign.workflow_id, user.id) + + return _build_campaign_response(campaign, workflow_name or "Unknown") + + @router.get("/{campaign_id}/runs") async def get_campaign_runs( campaign_id: int, diff --git a/api/routes/telephony.py b/api/routes/telephony.py index 905b68c..4020839 100644 --- a/api/routes/telephony.py +++ b/api/routes/telephony.py @@ -32,6 +32,7 @@ from api.errors.telephony_errors import TelephonyError from api.services.auth.depends import get_user from api.services.campaign.campaign_call_dispatcher import campaign_call_dispatcher from api.services.campaign.campaign_event_publisher import get_campaign_event_publisher +from api.services.campaign.circuit_breaker import circuit_breaker from api.services.quota_service import check_dograh_quota, check_dograh_quota_by_user_id from api.services.telephony.call_transfer_manager import get_call_transfer_manager from api.services.telephony.factory import ( @@ -760,6 +761,9 @@ async def _process_status_update(workflow_run_id: int, status: StatusCallbackReq # Release concurrent slot if this was a campaign call if workflow_run.campaign_id: await campaign_call_dispatcher.release_call_slot(workflow_run_id) + await circuit_breaker.record_and_evaluate( + workflow_run.campaign_id, is_failure=False + ) # Mark workflow run as completed await db_client.update_workflow_run( @@ -776,6 +780,9 @@ async def _process_status_update(workflow_run_id: int, status: StatusCallbackReq # Release concurrent slot for terminal statuses if this was a campaign call if workflow_run.campaign_id: await campaign_call_dispatcher.release_call_slot(workflow_run_id) + await circuit_breaker.record_and_evaluate( + workflow_run.campaign_id, is_failure=True + ) # Check if retry is needed for campaign calls (busy/no-answer) if status.status in ["busy", "no-answer"] and workflow_run.campaign_id: diff --git a/api/services/campaign/campaign_event_protocol.py b/api/services/campaign/campaign_event_protocol.py index 32ad1f9..1e0a416 100644 --- a/api/services/campaign/campaign_event_protocol.py +++ b/api/services/campaign/campaign_event_protocol.py @@ -33,6 +33,9 @@ class CampaignEventType(str, Enum): RETRY_SCHEDULED = "retry_scheduled" RETRY_FAILED = "retry_failed" + # Circuit breaker events + CIRCUIT_BREAKER_TRIPPED = "circuit_breaker_tripped" + class RetryReason(str, Enum): """Reasons for retry.""" @@ -218,6 +221,18 @@ class RetryFailedEvent(BaseCampaignEvent): last_reason: str = "" # RetryReason value +@dataclass +class CircuitBreakerTrippedEvent(BaseCampaignEvent): + """Event sent when the circuit breaker trips and pauses a campaign.""" + + type: str = CampaignEventType.CIRCUIT_BREAKER_TRIPPED + failure_rate: float = 0.0 + failure_count: int = 0 + success_count: int = 0 + threshold: float = 0.0 + window_seconds: int = 0 + + def parse_campaign_event(data: str) -> Any: """Parse a campaign event message.""" try: @@ -239,6 +254,7 @@ def parse_campaign_event(data: str) -> Any: CampaignEventType.RETRY_NEEDED: RetryNeededEvent, CampaignEventType.RETRY_SCHEDULED: RetryScheduledEvent, CampaignEventType.RETRY_FAILED: RetryFailedEvent, + CampaignEventType.CIRCUIT_BREAKER_TRIPPED: CircuitBreakerTrippedEvent, } event_class = event_class_map.get(event_type) diff --git a/api/services/campaign/campaign_event_publisher.py b/api/services/campaign/campaign_event_publisher.py index 4f6438d..3903e95 100644 --- a/api/services/campaign/campaign_event_publisher.py +++ b/api/services/campaign/campaign_event_publisher.py @@ -14,6 +14,7 @@ from api.services.campaign.campaign_event_protocol import ( BatchCompletedEvent, BatchFailedEvent, CampaignCompletedEvent, + CircuitBreakerTrippedEvent, RetryNeededEvent, SyncCompletedEvent, ) @@ -123,6 +124,32 @@ class CampaignEventPublisher: await self.redis.publish(RedisChannel.CAMPAIGN_EVENTS.value, event.to_json()) + async def publish_circuit_breaker_tripped( + self, + campaign_id: int, + failure_rate: float, + failure_count: int, + success_count: int, + threshold: float, + window_seconds: int, + ): + """Publish circuit breaker tripped event.""" + event = CircuitBreakerTrippedEvent( + campaign_id=campaign_id, + failure_rate=failure_rate, + failure_count=failure_count, + success_count=success_count, + threshold=threshold, + window_seconds=window_seconds, + ) + + await self.redis.publish(RedisChannel.CAMPAIGN_EVENTS.value, event.to_json()) + + logger.warning( + f"Published circuit breaker tripped event for campaign {campaign_id}: " + f"failure_rate={failure_rate:.2%} ({failure_count} failures)" + ) + # Global publisher instance with lazy Redis connection async def get_campaign_event_publisher() -> CampaignEventPublisher: diff --git a/api/services/campaign/campaign_orchestrator.py b/api/services/campaign/campaign_orchestrator.py index d701e51..06148c2 100644 --- a/api/services/campaign/campaign_orchestrator.py +++ b/api/services/campaign/campaign_orchestrator.py @@ -14,6 +14,7 @@ import asyncio import signal from datetime import UTC, datetime, timedelta from typing import Dict +from zoneinfo import ZoneInfo import redis.asyncio as aioredis from loguru import logger @@ -25,11 +26,13 @@ from api.enums import RedisChannel from api.services.campaign.campaign_event_protocol import ( BatchCompletedEvent, BatchFailedEvent, + CircuitBreakerTrippedEvent, RetryNeededEvent, SyncCompletedEvent, parse_campaign_event, ) from api.services.campaign.campaign_event_publisher import CampaignEventPublisher +from api.services.campaign.circuit_breaker import circuit_breaker from api.tasks.arq import enqueue_job from api.tasks.function_names import FunctionNames @@ -165,6 +168,14 @@ class CampaignOrchestrator: await self._schedule_next_batch(campaign_id) self._last_activity[campaign_id] = datetime.now(UTC) + elif isinstance(event, CircuitBreakerTrippedEvent): + # Circuit breaker tripped - clear state for this campaign + logger.warning( + f"campaign_id: {campaign_id} - Circuit breaker tripped event received: " + f"failure_rate={event.failure_rate:.2%}" + ) + self._clear_campaign_state(campaign_id) + async def _handle_retry_event(self, event: RetryNeededEvent): """Process retry event and schedule if eligible (from campaign_retry_manager).""" @@ -274,6 +285,53 @@ class CampaignOrchestrator: f"last reason: {reason}" ) + def _is_within_schedule(self, campaign: CampaignModel) -> bool: + """Check if the current time falls within the campaign's schedule windows. + + Returns True (allow scheduling) if: + - No schedule_config in metadata + - Schedule is disabled + - No slots configured + - Invalid timezone (fail open) + - Current time matches a slot + """ + if not campaign.orchestrator_metadata: + return True + + schedule_config = campaign.orchestrator_metadata.get("schedule_config") + if not schedule_config: + return True + + if not schedule_config.get("enabled", False): + return True + + slots = schedule_config.get("slots") + if not slots: + return True + + timezone_str = schedule_config.get("timezone", "UTC") + try: + tz = ZoneInfo(timezone_str) + except (KeyError, Exception): + logger.warning( + f"campaign_id: {campaign.id} - Invalid timezone '{timezone_str}' in schedule_config, " + f"failing open (allowing scheduling)" + ) + return True + + now = datetime.now(tz) + current_day = now.weekday() # 0=Monday through 6=Sunday + current_time = now.strftime("%H:%M") + + for slot in slots: + if slot.get("day_of_week") == current_day: + start = slot.get("start_time", "") + end = slot.get("end_time", "") + if start <= current_time < end: + return True + + return False + async def _schedule_next_batch(self, campaign_id: int): """Schedule next batch immediately if work available.""" @@ -302,6 +360,40 @@ class CampaignOrchestrator: ) return + # Check schedule window before scheduling + if not self._is_within_schedule(campaign): + logger.info( + f"campaign_id: {campaign_id} - Outside scheduled time window, skipping batch" + ) + return + + # Safety net: check circuit breaker before scheduling + cb_config = None + if campaign.orchestrator_metadata: + cb_config = campaign.orchestrator_metadata.get("circuit_breaker") + + is_open, stats = await circuit_breaker.is_circuit_open( + campaign_id=campaign_id, + config=cb_config, + ) + + if is_open and stats: + logger.warning( + f"campaign_id: {campaign_id} - Circuit breaker is open, " + f"pausing campaign. Stats: {stats}" + ) + await db_client.update_campaign(campaign_id=campaign_id, state="paused") + await self.publisher.publish_circuit_breaker_tripped( + campaign_id=campaign_id, + failure_rate=stats["failure_rate"], + failure_count=stats["failure_count"], + success_count=stats["success_count"], + threshold=stats["threshold"], + window_seconds=stats["window_seconds"], + ) + self._clear_campaign_state(campaign_id) + return + # Check for available work (queued runs + due retries) has_work = await self._has_pending_work(campaign_id) @@ -399,6 +491,12 @@ class CampaignOrchestrator: if campaign_id not in self._batch_in_progress: has_work = await self._has_pending_work(campaign_id) if has_work: + if not self._is_within_schedule(campaign): + logger.info( + f"campaign_id: {campaign_id} - Found orphaned work but outside " + f"schedule window, skipping" + ) + continue logger.info( f"campaign_id: {campaign_id} - Found orphaned work (likely new retries), " f"scheduling batch to process" @@ -428,6 +526,12 @@ class CampaignOrchestrator: # Check for any pending work has_work = await self._has_pending_work(campaign_id) if has_work: + # If outside schedule window, don't mark complete — work remains for next window + if not self._is_within_schedule(campaign): + logger.debug( + f"campaign_id: {campaign_id} - Outside schedule window with pending work, " + f"not marking complete" + ) return False # Check in-memory last activity diff --git a/api/services/campaign/circuit_breaker.py b/api/services/campaign/circuit_breaker.py new file mode 100644 index 0000000..7a8f13a --- /dev/null +++ b/api/services/campaign/circuit_breaker.py @@ -0,0 +1,301 @@ +"""Campaign circuit breaker for automatic pause on high failure rates. + +Uses two Redis sorted sets (ZSETs) per campaign — one for failures, one for +successes — as sliding windows. ZCARD gives O(1) counts without iterating +members, keeping the Lua scripts simple. +""" + +import time +from typing import Optional, Tuple + +import redis.asyncio as aioredis +from loguru import logger + +from api.constants import DEFAULT_CIRCUIT_BREAKER_CONFIG, REDIS_URL +from api.db import db_client +from api.services.campaign.campaign_event_publisher import get_campaign_event_publisher + + +class CircuitBreaker: + """Sliding window circuit breaker for campaign call failures.""" + + def __init__(self): + self.redis_client: Optional[aioredis.Redis] = None + + async def _get_redis(self) -> aioredis.Redis: + """Get or create Redis connection.""" + if self.redis_client is None: + self.redis_client = await aioredis.from_url( + REDIS_URL, decode_responses=True + ) + return self.redis_client + + @staticmethod + def _keys(campaign_id: int) -> Tuple[str, str]: + """Return (failures_key, successes_key) for a campaign.""" + return f"cb_failures:{campaign_id}", f"cb_successes:{campaign_id}" + + async def record_call_outcome( + self, + campaign_id: int, + is_failure: bool, + config: Optional[dict] = None, + ) -> Tuple[bool, Optional[dict]]: + """Record a call outcome and check if the circuit breaker should trip. + + Args: + campaign_id: The campaign ID. + is_failure: True if the call failed, False if succeeded. + config: Optional per-campaign circuit breaker config override. + Falls back to DEFAULT_CIRCUIT_BREAKER_CONFIG. + + Returns: + Tuple of (tripped: bool, stats: dict or None). + If tripped is True, stats contains failure_rate, failure_count, + success_count, threshold, window_seconds. + """ + cb_config = {**DEFAULT_CIRCUIT_BREAKER_CONFIG, **(config or {})} + + if not cb_config.get("enabled", True): + return False, None + + redis_client = await self._get_redis() + + window_seconds = cb_config["window_seconds"] + threshold = cb_config["failure_threshold"] + min_calls = cb_config["min_calls_in_window"] + + now = time.time() + window_start = now - window_seconds + + fail_key, succ_key = self._keys(campaign_id) + + lua_script = """ + local fail_key = KEYS[1] + local succ_key = KEYS[2] + local now = tonumber(ARGV[1]) + local window_start = tonumber(ARGV[2]) + local is_failure = tonumber(ARGV[3]) + local threshold = tonumber(ARGV[4]) + local min_calls = tonumber(ARGV[5]) + local ttl = tonumber(ARGV[6]) + + -- Trim both sets to the sliding window + redis.call('ZREMRANGEBYSCORE', fail_key, 0, window_start) + redis.call('ZREMRANGEBYSCORE', succ_key, 0, window_start) + + -- Add the new outcome to the appropriate set + if is_failure == 1 then + redis.call('ZADD', fail_key, now, now) + else + redis.call('ZADD', succ_key, now, now) + end + + -- Refresh TTL on both keys + redis.call('EXPIRE', fail_key, ttl) + redis.call('EXPIRE', succ_key, ttl) + + -- Count via ZCARD (O(1)) + local failures = redis.call('ZCARD', fail_key) + local successes = redis.call('ZCARD', succ_key) + local total = failures + successes + + -- Check trip condition + if total >= min_calls and (failures / total) >= threshold then + return {1, failures, successes, total} + end + + return {0, failures, successes, total} + """ + + try: + result = await redis_client.eval( + lua_script, + 2, + fail_key, + succ_key, + now, + window_start, + 1 if is_failure else 0, + threshold, + min_calls, + window_seconds + 60, # TTL with buffer + ) + + tripped = bool(result[0]) + failure_count = int(result[1]) + success_count = int(result[2]) + total = int(result[3]) + failure_rate = failure_count / total if total > 0 else 0.0 + + if tripped: + logger.warning( + f"Circuit breaker TRIPPED for campaign {campaign_id}: " + f"failure_rate={failure_rate:.2%} ({failure_count}/{total}) " + f"threshold={threshold:.2%} window={window_seconds}s" + ) + + stats = { + "failure_rate": failure_rate, + "failure_count": failure_count, + "success_count": success_count, + "threshold": threshold, + "window_seconds": window_seconds, + } + return tripped, stats + + except Exception as e: + logger.error(f"Circuit breaker error for campaign {campaign_id}: {e}") + # Fail open - do NOT trip on errors + return False, None + + async def is_circuit_open( + self, + campaign_id: int, + config: Optional[dict] = None, + ) -> Tuple[bool, Optional[dict]]: + """Check if the circuit breaker is in open (tripped) state without recording. + + Used as a safety net check before scheduling batches. + """ + cb_config = {**DEFAULT_CIRCUIT_BREAKER_CONFIG, **(config or {})} + + if not cb_config.get("enabled", True): + return False, None + + redis_client = await self._get_redis() + + window_seconds = cb_config["window_seconds"] + threshold = cb_config["failure_threshold"] + min_calls = cb_config["min_calls_in_window"] + + now = time.time() + window_start = now - window_seconds + + fail_key, succ_key = self._keys(campaign_id) + + lua_script = """ + local fail_key = KEYS[1] + local succ_key = KEYS[2] + local window_start = tonumber(ARGV[1]) + local threshold = tonumber(ARGV[2]) + local min_calls = tonumber(ARGV[3]) + + -- Trim both sets + redis.call('ZREMRANGEBYSCORE', fail_key, 0, window_start) + redis.call('ZREMRANGEBYSCORE', succ_key, 0, window_start) + + -- Count via ZCARD + local failures = redis.call('ZCARD', fail_key) + local successes = redis.call('ZCARD', succ_key) + local total = failures + successes + + if total >= min_calls and (failures / total) >= threshold then + return {1, failures, successes, total} + end + + return {0, failures, successes, total} + """ + + try: + result = await redis_client.eval( + lua_script, + 2, + fail_key, + succ_key, + window_start, + threshold, + min_calls, + ) + + is_open = bool(result[0]) + failure_count = int(result[1]) + success_count = int(result[2]) + total = int(result[3]) + failure_rate = failure_count / total if total > 0 else 0.0 + + stats = { + "failure_rate": failure_rate, + "failure_count": failure_count, + "success_count": success_count, + "threshold": threshold, + "window_seconds": window_seconds, + } + return is_open, stats + + except Exception as e: + logger.error(f"Circuit breaker check error for campaign {campaign_id}: {e}") + return False, None + + async def record_and_evaluate(self, campaign_id: int, is_failure: bool) -> None: + """Record a call outcome, and if the breaker trips, pause the campaign. + + This is the main entry point called from telephony status callbacks. + It handles fetching campaign config, recording the outcome, and + pausing + publishing an event if the breaker trips. + + Exceptions are caught internally so this never disrupts the caller. + """ + try: + campaign = await db_client.get_campaign_by_id(campaign_id) + if not campaign or campaign.state != "running": + return + + cb_config = {} + if campaign.orchestrator_metadata: + cb_config = campaign.orchestrator_metadata.get("circuit_breaker", {}) + + tripped, stats = await self.record_call_outcome( + campaign_id=campaign_id, + is_failure=is_failure, + config=cb_config, + ) + + if tripped and stats: + logger.warning( + f"Circuit breaker tripped for campaign {campaign_id}, " + f"pausing campaign. Stats: {stats}" + ) + + await db_client.update_campaign(campaign_id=campaign_id, state="paused") + + publisher = await get_campaign_event_publisher() + await publisher.publish_circuit_breaker_tripped( + campaign_id=campaign_id, + failure_rate=stats["failure_rate"], + failure_count=stats["failure_count"], + success_count=stats["success_count"], + threshold=stats["threshold"], + window_seconds=stats["window_seconds"], + ) + + except Exception as e: + logger.error(f"Error in circuit breaker for campaign {campaign_id}: {e}") + + async def reset(self, campaign_id: int) -> bool: + """Reset the circuit breaker state for a campaign. + + Called when a campaign is resumed to give it a clean slate. + """ + redis_client = await self._get_redis() + fail_key, succ_key = self._keys(campaign_id) + + try: + await redis_client.delete(fail_key, succ_key) + logger.info(f"Circuit breaker reset for campaign {campaign_id}") + return True + except Exception as e: + logger.error( + f"Error resetting circuit breaker for campaign {campaign_id}: {e}" + ) + return False + + async def close(self): + """Close Redis connection.""" + if self.redis_client: + await self.redis_client.close() + self.redis_client = None + + +# Global circuit breaker instance +circuit_breaker = CircuitBreaker() diff --git a/api/services/campaign/runner.py b/api/services/campaign/runner.py index f1b397c..7955599 100644 --- a/api/services/campaign/runner.py +++ b/api/services/campaign/runner.py @@ -4,6 +4,7 @@ from typing import Any, Dict from loguru import logger from api.db import db_client +from api.services.campaign.circuit_breaker import circuit_breaker from api.tasks.arq import enqueue_job from api.tasks.function_names import FunctionNames @@ -67,6 +68,9 @@ class CampaignRunnerService: # stale campaign checker would do that if there are pending work. await db_client.update_campaign(campaign_id=campaign_id, state="running") + # Reset circuit breaker so the resumed campaign starts with a clean slate + await circuit_breaker.reset(campaign_id) + logger.info(f"Campaign {campaign_id} resumed") async def get_campaign_status(self, campaign_id: int) -> Dict[str, Any]: diff --git a/api/tests/test_circuit_breaker.py b/api/tests/test_circuit_breaker.py new file mode 100644 index 0000000..a88a5ea --- /dev/null +++ b/api/tests/test_circuit_breaker.py @@ -0,0 +1,504 @@ +""" +Tests for Campaign Circuit Breaker. + +These tests verify: +1. Circuit breaker records call outcomes (success/failure) +2. Circuit breaker trips when failure rate exceeds threshold +3. Circuit breaker does NOT trip when below threshold or min_calls +4. Circuit breaker reset clears state +5. Integration: _process_status_update pauses campaign on circuit breaker trip +""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +# ============================================================================= +# Unit tests for CircuitBreaker class +# ============================================================================= + + +class TestCircuitBreakerRecordOutcome: + """Tests for recording call outcomes and trip detection.""" + + @pytest.mark.asyncio + async def test_no_trip_below_min_calls(self): + """Circuit breaker should NOT trip when total calls < min_calls_in_window.""" + from api.services.campaign.circuit_breaker import CircuitBreaker + + cb = CircuitBreaker() + + # Mock Redis to simulate a window with 3 failures out of 3 total + # (100% failure rate, but below min_calls=5) + mock_redis = AsyncMock() + mock_redis.eval = AsyncMock( + return_value=[0, 3, 0, 3] # [not_tripped, failures, successes, total] + ) + cb.redis_client = mock_redis + + tripped, stats = await cb.record_call_outcome(campaign_id=1, is_failure=True) + + assert tripped is False + assert stats is not None + assert stats["failure_count"] == 3 + assert stats["success_count"] == 0 + + @pytest.mark.asyncio + async def test_trip_when_threshold_exceeded(self): + """Circuit breaker should trip when failure rate >= threshold and total >= min_calls.""" + from api.services.campaign.circuit_breaker import CircuitBreaker + + cb = CircuitBreaker() + + # Mock Redis to simulate: 4 failures out of 6 total = 66% > 50% threshold + mock_redis = AsyncMock() + mock_redis.eval = AsyncMock( + return_value=[1, 4, 2, 6] # [tripped, failures, successes, total] + ) + cb.redis_client = mock_redis + + tripped, stats = await cb.record_call_outcome(campaign_id=1, is_failure=True) + + assert tripped is True + assert stats is not None + assert stats["failure_rate"] == pytest.approx(4 / 6) + assert stats["failure_count"] == 4 + assert stats["success_count"] == 2 + + @pytest.mark.asyncio + async def test_no_trip_below_threshold(self): + """Circuit breaker should NOT trip when failure rate < threshold.""" + from api.services.campaign.circuit_breaker import CircuitBreaker + + cb = CircuitBreaker() + + # Mock Redis: 2 failures out of 8 total = 25% < 50% threshold + mock_redis = AsyncMock() + mock_redis.eval = AsyncMock( + return_value=[0, 2, 6, 8] # [not_tripped, failures, successes, total] + ) + cb.redis_client = mock_redis + + tripped, stats = await cb.record_call_outcome(campaign_id=1, is_failure=False) + + assert tripped is False + assert stats["failure_rate"] == pytest.approx(2 / 8) + + @pytest.mark.asyncio + async def test_disabled_circuit_breaker(self): + """Circuit breaker should not record or trip when disabled.""" + from api.services.campaign.circuit_breaker import CircuitBreaker + + cb = CircuitBreaker() + mock_redis = AsyncMock() + cb.redis_client = mock_redis + + tripped, stats = await cb.record_call_outcome( + campaign_id=1, + is_failure=True, + config={"enabled": False}, + ) + + assert tripped is False + assert stats is None + # Redis should not have been called + mock_redis.eval.assert_not_called() + + @pytest.mark.asyncio + async def test_custom_config_override(self): + """Per-campaign config should override defaults.""" + from api.services.campaign.circuit_breaker import CircuitBreaker + + cb = CircuitBreaker() + + # With custom threshold of 0.8, 4/6 = 66% should NOT trip + mock_redis = AsyncMock() + mock_redis.eval = AsyncMock( + return_value=[0, 4, 2, 6] # Lua script respects the threshold we pass + ) + cb.redis_client = mock_redis + + tripped, stats = await cb.record_call_outcome( + campaign_id=1, + is_failure=True, + config={"failure_threshold": 0.8, "min_calls_in_window": 3}, + ) + + assert tripped is False + + @pytest.mark.asyncio + async def test_redis_error_fails_open(self): + """On Redis error, circuit breaker should fail open (not trip).""" + from api.services.campaign.circuit_breaker import CircuitBreaker + + cb = CircuitBreaker() + + mock_redis = AsyncMock() + mock_redis.eval = AsyncMock(side_effect=Exception("Redis connection lost")) + cb.redis_client = mock_redis + + tripped, stats = await cb.record_call_outcome(campaign_id=1, is_failure=True) + + assert tripped is False + assert stats is None + + +class TestCircuitBreakerIsOpen: + """Tests for read-only circuit state check.""" + + @pytest.mark.asyncio + async def test_is_open_when_threshold_exceeded(self): + """is_circuit_open should return True when failure rate exceeds threshold.""" + from api.services.campaign.circuit_breaker import CircuitBreaker + + cb = CircuitBreaker() + + mock_redis = AsyncMock() + mock_redis.eval = AsyncMock( + return_value=[1, 5, 2, 7] # [is_open, failures, successes, total] + ) + cb.redis_client = mock_redis + + is_open, stats = await cb.is_circuit_open(campaign_id=1) + + assert is_open is True + assert stats["failure_count"] == 5 + + @pytest.mark.asyncio + async def test_is_not_open_when_healthy(self): + """is_circuit_open should return False when failure rate is low.""" + from api.services.campaign.circuit_breaker import CircuitBreaker + + cb = CircuitBreaker() + + mock_redis = AsyncMock() + mock_redis.eval = AsyncMock(return_value=[0, 1, 9, 10]) + cb.redis_client = mock_redis + + is_open, stats = await cb.is_circuit_open(campaign_id=1) + + assert is_open is False + assert stats["failure_rate"] == pytest.approx(0.1) + + +class TestCircuitBreakerReset: + """Tests for circuit breaker reset.""" + + @pytest.mark.asyncio + async def test_reset_deletes_redis_keys(self): + """Reset should delete both failure and success keys for the campaign.""" + from api.services.campaign.circuit_breaker import CircuitBreaker + + cb = CircuitBreaker() + + mock_redis = AsyncMock() + mock_redis.delete = AsyncMock(return_value=2) + cb.redis_client = mock_redis + + result = await cb.reset(campaign_id=42) + + assert result is True + mock_redis.delete.assert_called_once_with("cb_failures:42", "cb_successes:42") + + @pytest.mark.asyncio + async def test_reset_on_redis_error(self): + """Reset should return False on Redis error.""" + from api.services.campaign.circuit_breaker import CircuitBreaker + + cb = CircuitBreaker() + + mock_redis = AsyncMock() + mock_redis.delete = AsyncMock(side_effect=Exception("Redis down")) + cb.redis_client = mock_redis + + result = await cb.reset(campaign_id=42) + + assert result is False + + +# ============================================================================= +# Tests for record_and_evaluate (the high-level method on CircuitBreaker) +# ============================================================================= + + +class TestRecordAndEvaluate: + """Test circuit_breaker.record_and_evaluate which handles the full + flow: record outcome, check trip, pause campaign, publish event.""" + + @pytest.mark.asyncio + async def test_trips_and_pauses_campaign(self): + """When record_call_outcome returns tripped, campaign should be paused.""" + from api.services.campaign.circuit_breaker import CircuitBreaker + + cb = CircuitBreaker() + + mock_campaign = MagicMock() + mock_campaign.id = 42 + mock_campaign.state = "running" + mock_campaign.orchestrator_metadata = {} + + stats = { + "failure_rate": 0.6, + "failure_count": 6, + "success_count": 4, + "threshold": 0.5, + "window_seconds": 120, + } + + with ( + patch("api.services.campaign.circuit_breaker.db_client") as mock_db, + patch( + "api.services.campaign.circuit_breaker.get_campaign_event_publisher" + ) as mock_get_publisher, + ): + mock_db.get_campaign_by_id = AsyncMock(return_value=mock_campaign) + mock_db.update_campaign = AsyncMock() + + mock_publisher = AsyncMock() + mock_get_publisher.return_value = mock_publisher + + # Mock the internal record_call_outcome to return tripped + cb.record_call_outcome = AsyncMock(return_value=(True, stats)) + + await cb.record_and_evaluate(campaign_id=42, is_failure=True) + + # Verify campaign was paused + mock_db.update_campaign.assert_called_once_with( + campaign_id=42, state="paused" + ) + + # Verify event was published + mock_publisher.publish_circuit_breaker_tripped.assert_called_once() + + @pytest.mark.asyncio + async def test_no_pause_when_not_tripped(self): + """When record_call_outcome does NOT trip, campaign should not be paused.""" + from api.services.campaign.circuit_breaker import CircuitBreaker + + cb = CircuitBreaker() + + mock_campaign = MagicMock() + mock_campaign.id = 42 + mock_campaign.state = "running" + mock_campaign.orchestrator_metadata = {} + + with patch("api.services.campaign.circuit_breaker.db_client") as mock_db: + mock_db.get_campaign_by_id = AsyncMock(return_value=mock_campaign) + + cb.record_call_outcome = AsyncMock(return_value=(False, None)) + + await cb.record_and_evaluate(campaign_id=42, is_failure=False) + + mock_db.update_campaign.assert_not_called() + + @pytest.mark.asyncio + async def test_skips_when_campaign_not_running(self): + """Should skip when campaign is not in running state.""" + from api.services.campaign.circuit_breaker import CircuitBreaker + + cb = CircuitBreaker() + + mock_campaign = MagicMock() + mock_campaign.id = 42 + mock_campaign.state = "paused" + + with patch("api.services.campaign.circuit_breaker.db_client") as mock_db: + mock_db.get_campaign_by_id = AsyncMock(return_value=mock_campaign) + + cb.record_call_outcome = AsyncMock() + + await cb.record_and_evaluate(campaign_id=42, is_failure=True) + + # Should not even attempt to record + cb.record_call_outcome.assert_not_called() + + @pytest.mark.asyncio + async def test_reads_config_from_orchestrator_metadata(self): + """Should pass circuit_breaker config from orchestrator_metadata.""" + from api.services.campaign.circuit_breaker import CircuitBreaker + + cb = CircuitBreaker() + + custom_config = {"failure_threshold": 0.3, "min_calls_in_window": 10} + mock_campaign = MagicMock() + mock_campaign.id = 42 + mock_campaign.state = "running" + mock_campaign.orchestrator_metadata = {"circuit_breaker": custom_config} + + with patch("api.services.campaign.circuit_breaker.db_client") as mock_db: + mock_db.get_campaign_by_id = AsyncMock(return_value=mock_campaign) + + cb.record_call_outcome = AsyncMock(return_value=(False, None)) + + await cb.record_and_evaluate(campaign_id=42, is_failure=True) + + cb.record_call_outcome.assert_called_once_with( + campaign_id=42, + is_failure=True, + config=custom_config, + ) + + @pytest.mark.asyncio + async def test_error_is_swallowed(self): + """Errors inside record_and_evaluate should be caught, not raised.""" + from api.services.campaign.circuit_breaker import CircuitBreaker + + cb = CircuitBreaker() + + with patch("api.services.campaign.circuit_breaker.db_client") as mock_db: + mock_db.get_campaign_by_id = AsyncMock(side_effect=Exception("DB exploded")) + + # Should NOT raise + await cb.record_and_evaluate(campaign_id=42, is_failure=True) + + +# ============================================================================= +# Integration tests: _process_status_update calls circuit_breaker +# ============================================================================= + + +class TestProcessStatusUpdateCircuitBreaker: + """Test that _process_status_update calls circuit_breaker.record_and_evaluate + for campaign calls.""" + + @pytest.mark.asyncio + async def test_failure_status_calls_record_and_evaluate(self): + """When a campaign call fails, record_and_evaluate should be called + with is_failure=True.""" + + from api.routes.telephony import StatusCallbackRequest, _process_status_update + + mock_workflow_run = MagicMock() + mock_workflow_run.id = 100 + mock_workflow_run.campaign_id = 42 + mock_workflow_run.queued_run_id = 10 + mock_workflow_run.state = "running" + mock_workflow_run.logs = {"telephony_status_callbacks": []} + mock_workflow_run.gathered_context = {} + + status = StatusCallbackRequest( + call_id="call-123", + status="failed", + ) + + with ( + patch("api.routes.telephony.db_client") as mock_db, + patch("api.routes.telephony.campaign_call_dispatcher") as mock_dispatcher, + patch("api.routes.telephony.circuit_breaker") as mock_cb, + patch( + "api.routes.telephony.get_campaign_event_publisher" + ) as mock_get_publisher, + ): + mock_db.get_workflow_run_by_id = AsyncMock(return_value=mock_workflow_run) + mock_db.update_workflow_run = AsyncMock() + + mock_dispatcher.release_call_slot = AsyncMock(return_value=True) + mock_cb.record_and_evaluate = AsyncMock() + + mock_publisher = AsyncMock() + mock_get_publisher.return_value = mock_publisher + + await _process_status_update(100, status) + + mock_cb.record_and_evaluate.assert_called_once_with(42, is_failure=True) + + @pytest.mark.asyncio + async def test_success_status_calls_record_and_evaluate(self): + """When a campaign call succeeds, record_and_evaluate should be called + with is_failure=False.""" + + from api.routes.telephony import StatusCallbackRequest, _process_status_update + + mock_workflow_run = MagicMock() + mock_workflow_run.id = 100 + mock_workflow_run.campaign_id = 42 + mock_workflow_run.state = "running" + mock_workflow_run.logs = {"telephony_status_callbacks": []} + mock_workflow_run.gathered_context = {} + + status = StatusCallbackRequest( + call_id="call-456", + status="completed", + ) + + with ( + patch("api.routes.telephony.db_client") as mock_db, + patch("api.routes.telephony.campaign_call_dispatcher") as mock_dispatcher, + patch("api.routes.telephony.circuit_breaker") as mock_cb, + ): + mock_db.get_workflow_run_by_id = AsyncMock(return_value=mock_workflow_run) + mock_db.update_workflow_run = AsyncMock() + + mock_dispatcher.release_call_slot = AsyncMock(return_value=True) + mock_cb.record_and_evaluate = AsyncMock() + + await _process_status_update(100, status) + + mock_cb.record_and_evaluate.assert_called_once_with(42, is_failure=False) + + @pytest.mark.asyncio + async def test_non_campaign_call_skips_circuit_breaker(self): + """Calls without campaign_id should not interact with circuit breaker.""" + + from api.routes.telephony import StatusCallbackRequest, _process_status_update + + mock_workflow_run = MagicMock() + mock_workflow_run.id = 100 + mock_workflow_run.campaign_id = None # Not a campaign call + mock_workflow_run.state = "running" + mock_workflow_run.logs = {"telephony_status_callbacks": []} + mock_workflow_run.gathered_context = {} + + status = StatusCallbackRequest( + call_id="call-789", + status="failed", + ) + + with ( + patch("api.routes.telephony.db_client") as mock_db, + patch("api.routes.telephony.circuit_breaker") as mock_cb, + ): + mock_db.get_workflow_run_by_id = AsyncMock(return_value=mock_workflow_run) + mock_db.update_workflow_run = AsyncMock() + + await _process_status_update(100, status) + + # Circuit breaker should NOT be called for non-campaign calls + mock_cb.record_and_evaluate.assert_not_called() + + +# ============================================================================= +# Integration test: resume_campaign resets circuit breaker +# ============================================================================= + + +class TestResumeCampaignResetsCircuitBreaker: + """Test that resuming a campaign resets the circuit breaker.""" + + @pytest.mark.asyncio + async def test_resume_resets_circuit_breaker(self): + """Resuming a paused campaign should reset the circuit breaker state.""" + from api.services.campaign.runner import CampaignRunnerService + + mock_campaign = MagicMock() + mock_campaign.id = 42 + mock_campaign.state = "paused" + + with ( + patch("api.services.campaign.runner.db_client") as mock_db, + patch("api.services.campaign.runner.circuit_breaker") as mock_cb, + ): + mock_db.get_campaign_by_id = AsyncMock(return_value=mock_campaign) + mock_db.update_campaign = AsyncMock() + mock_cb.reset = AsyncMock(return_value=True) + + runner = CampaignRunnerService() + await runner.resume_campaign(42) + + # Verify circuit breaker was reset + mock_cb.reset.assert_called_once_with(42) + + # Verify campaign state was updated + mock_db.update_campaign.assert_called_once_with( + campaign_id=42, state="running" + ) diff --git a/api/tests/test_user_turn_stop_scenarios.py b/api/tests/test_user_turn_stop_scenarios.py index 4386eef..8cafcdf 100644 --- a/api/tests/test_user_turn_stop_scenarios.py +++ b/api/tests/test_user_turn_stop_scenarios.py @@ -48,7 +48,6 @@ from pipecat.turns.user_stop import ExternalUserTurnStopStrategy from pipecat.turns.user_turn_strategies import UserTurnStrategies from pipecat.utils.time import time_now_iso8601 - # Short timeout for faster tests STOP_STRATEGY_TIMEOUT = 0.15 # Delay to allow async processing @@ -115,7 +114,15 @@ def _build_components(llm_steps=None): turn_controller = user_agg._user_turn_controller - return injector, user_agg, stop_strategy, turn_controller, mock_llm, context, pipeline + return ( + injector, + user_agg, + stop_strategy, + turn_controller, + mock_llm, + context, + pipeline, + ) async def _run_scenario(pipeline, inject_fn): @@ -193,7 +200,9 @@ class TestUserTurnStopScenarios: await asyncio.sleep(0) await injector.inject(UserStartedSpeakingFrame()) await asyncio.sleep(0) - await injector.inject(TranscriptionFrame("hello", "user-1", time_now_iso8601())) + await injector.inject( + TranscriptionFrame("hello", "user-1", time_now_iso8601()) + ) await asyncio.sleep(0) await injector.inject(UserStoppedSpeakingFrame()) await asyncio.sleep(0) @@ -217,7 +226,9 @@ class TestUserTurnStopScenarios: assert stop_strategy._text == "", ( f"Expected empty _text after clean turn, got '{stop_strategy._text}'" ) - assert not turn_ctrl._user_turn, "Expected _user_turn to be False after turn" + assert not turn_ctrl._user_turn, ( + "Expected _user_turn to be False after turn" + ) assert mock_llm.get_current_step() == 1, ( f"Expected 1 LLM call (turn 2 only), got {mock_llm.get_current_step()}" ) @@ -257,7 +268,9 @@ class TestUserTurnStopScenarios: await asyncio.sleep(0) await injector.inject(UserStartedSpeakingFrame()) await asyncio.sleep(0) - await injector.inject(TranscriptionFrame("hello", "user-1", time_now_iso8601())) + await injector.inject( + TranscriptionFrame("hello", "user-1", time_now_iso8601()) + ) await asyncio.sleep(ASYNC_DELAY) # Bot stops -> unmuted @@ -329,7 +342,9 @@ class TestUserTurnStopScenarios: await asyncio.sleep(ASYNC_DELAY) # TranscriptionFrame arrives AFTER unmute -> reaches stop strategy - await injector.inject(TranscriptionFrame("hello", "user-1", time_now_iso8601())) + await injector.inject( + TranscriptionFrame("hello", "user-1", time_now_iso8601()) + ) await asyncio.sleep(ASYNC_DELAY) # Install spy on trigger_user_turn_stopped to track every call @@ -408,7 +423,9 @@ class TestUserTurnStopScenarios: # _aggregation is a separate concern from the stop strategy's _text. messages = context.messages user_messages = [m for m in messages if m.get("role") == "user"] - assert len(user_messages) == 1, f"Expected 1 user message, got {len(user_messages)}" + assert len(user_messages) == 1, ( + f"Expected 1 user message, got {len(user_messages)}" + ) user_text = user_messages[0]["content"] assert "hello" in user_text, ( f"Expected 'hello' (from aggregator) in user message, got: '{user_text}'" @@ -519,7 +536,9 @@ class TestUserTurnStopScenarios: await injector.inject(VADUserStoppedSpeakingFrame()) await asyncio.sleep(0) # Late transcription - but still during bot speaking - await injector.inject(TranscriptionFrame("late hello", "user-1", time_now_iso8601())) + await injector.inject( + TranscriptionFrame("late hello", "user-1", time_now_iso8601()) + ) await asyncio.sleep(ASYNC_DELAY) await injector.inject(BotStoppedSpeakingFrame()) @@ -651,7 +670,9 @@ class TestUserTurnStopScenarios: # The LLM received both "late hello" (dangling in aggregator from turn 1) # and "real speech" (from turn 2). user_messages = [m for m in context.messages if m.get("role") == "user"] - assert len(user_messages) == 1, f"Expected 1 user message, got {len(user_messages)}" + assert len(user_messages) == 1, ( + f"Expected 1 user message, got {len(user_messages)}" + ) user_text = user_messages[0]["content"] assert "late hello" in user_text, ( f"Expected 'late hello' (from aggregator) in user message, got: '{user_text}'" @@ -867,7 +888,9 @@ class TestUserTurnStopScenarios: await asyncio.sleep(ASYNC_DELAY) # Late transcription after unmute - await injector.inject(TranscriptionFrame("first", "user-1", time_now_iso8601())) + await injector.inject( + TranscriptionFrame("first", "user-1", time_now_iso8601()) + ) await asyncio.sleep(0) await injector.inject(UserStoppedSpeakingFrame()) await asyncio.sleep(TIMEOUT_WAIT) @@ -890,7 +913,9 @@ class TestUserTurnStopScenarios: await injector.inject(BotStoppedSpeakingFrame()) await asyncio.sleep(ASYNC_DELAY) - await injector.inject(TranscriptionFrame("second", "user-1", time_now_iso8601())) + await injector.inject( + TranscriptionFrame("second", "user-1", time_now_iso8601()) + ) await asyncio.sleep(0) await injector.inject(UserStoppedSpeakingFrame()) await asyncio.sleep(TIMEOUT_WAIT) @@ -926,7 +951,9 @@ class TestUserTurnStopScenarios: user_text = user_messages[0]["content"] assert "first" in user_text, f"Expected 'first' in '{user_text}'" assert "second" in user_text, f"Expected 'second' in '{user_text}'" - assert "actual speech" in user_text, f"Expected 'actual speech' in '{user_text}'" + assert "actual speech" in user_text, ( + f"Expected 'actual speech' in '{user_text}'" + ) await injector.inject(EndTaskFrame(), direction=FrameDirection.UPSTREAM) diff --git a/ui/src/app/campaigns/CampaignAdvancedSettings.tsx b/ui/src/app/campaigns/CampaignAdvancedSettings.tsx new file mode 100644 index 0000000..63a76ef --- /dev/null +++ b/ui/src/app/campaigns/CampaignAdvancedSettings.tsx @@ -0,0 +1,300 @@ +"use client"; + +import { Plus, X } from 'lucide-react'; +import { useId } from 'react'; +import TimezoneSelect, { type ITimezoneOption } from 'react-timezone-select'; + +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Separator } from '@/components/ui/separator'; +import { Switch } from '@/components/ui/switch'; + +export type TimeSlot = { day_of_week: number; start_time: string; end_time: string }; + +export interface CampaignAdvancedSettingsProps { + // Concurrency + maxConcurrency: string; + onMaxConcurrencyChange: (value: string) => void; + effectiveLimit: number; + orgConcurrentLimit: number; + fromNumbersCount: number; + // Retry config + retryEnabled: boolean; + onRetryEnabledChange: (value: boolean) => void; + maxRetries: string; + onMaxRetriesChange: (value: string) => void; + retryDelaySeconds: string; + onRetryDelaySecondsChange: (value: string) => void; + retryOnBusy: boolean; + onRetryOnBusyChange: (value: boolean) => void; + retryOnNoAnswer: boolean; + onRetryOnNoAnswerChange: (value: boolean) => void; + retryOnVoicemail: boolean; + onRetryOnVoicemailChange: (value: boolean) => void; + // Schedule config + scheduleEnabled: boolean; + onScheduleEnabledChange: (value: boolean) => void; + scheduleTimezone: ITimezoneOption | string; + onScheduleTimezoneChange: (value: ITimezoneOption | string) => void; + timeSlots: TimeSlot[]; + onTimeSlotsChange: (value: TimeSlot[]) => void; +} + +/** Extract the string timezone value from ITimezoneOption | string */ +export function getTimezoneValue(tz: ITimezoneOption | string): string { + return typeof tz === 'string' ? tz : tz.value; +} + +const timezoneSelectStyles = { + control: (base: Record, state: { isFocused: boolean }) => ({ + ...base, + minHeight: '36px', + fontSize: '14px', + backgroundColor: 'var(--background)', + borderColor: state.isFocused ? 'var(--ring)' : 'var(--border)', + boxShadow: state.isFocused ? '0 0 0 2px color-mix(in srgb, var(--ring) 20%, transparent)' : 'none', + '&:hover': { borderColor: 'var(--border)' }, + }), + menu: (base: Record) => ({ + ...base, + zIndex: 9999, + backgroundColor: 'var(--popover)', + border: '1px solid var(--border)', + boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)', + }), + menuList: (base: Record) => ({ + ...base, + backgroundColor: 'var(--popover)', + padding: 0, + }), + option: (base: Record, state: { isSelected: boolean; isFocused: boolean }) => ({ + ...base, + backgroundColor: state.isSelected ? 'var(--accent)' : state.isFocused ? 'var(--accent)' : 'var(--popover)', + color: 'var(--foreground)', + cursor: 'pointer', + '&:active': { backgroundColor: 'var(--accent)' }, + }), + singleValue: (base: Record) => ({ ...base, color: 'var(--foreground)' }), + input: (base: Record) => ({ ...base, color: 'var(--foreground)' }), + placeholder: (base: Record) => ({ ...base, color: 'var(--muted-foreground)' }), + indicatorSeparator: (base: Record) => ({ ...base, backgroundColor: 'var(--border)' }), + dropdownIndicator: (base: Record) => ({ + ...base, + color: 'var(--muted-foreground)', + '&:hover': { color: 'var(--foreground)' }, + }), +}; + +export default function CampaignAdvancedSettings({ + maxConcurrency, onMaxConcurrencyChange, effectiveLimit, orgConcurrentLimit, fromNumbersCount, + retryEnabled, onRetryEnabledChange, maxRetries, onMaxRetriesChange, + retryDelaySeconds, onRetryDelaySecondsChange, + retryOnBusy, onRetryOnBusyChange, retryOnNoAnswer, onRetryOnNoAnswerChange, + retryOnVoicemail, onRetryOnVoicemailChange, + scheduleEnabled, onScheduleEnabledChange, scheduleTimezone, onScheduleTimezoneChange, + timeSlots, onTimeSlotsChange, +}: CampaignAdvancedSettingsProps) { + const timezoneSelectId = useId(); + + return ( +
+ {/* Max Concurrent Calls */} +
+ + onMaxConcurrencyChange(e.target.value)} + min={1} + max={effectiveLimit} + /> +

+ Maximum number of simultaneous calls. Leave empty to use {effectiveLimit}. + {fromNumbersCount > 0 && ` You have ${fromNumbersCount} CLI${fromNumbersCount !== 1 ? 's' : ''} and an org limit of ${orgConcurrentLimit}.`} +

+ {fromNumbersCount > 0 && fromNumbersCount < orgConcurrentLimit && ( +

+ Concurrency is limited to {fromNumbersCount} by your configured phone numbers. To use the full org limit of {orgConcurrentLimit}, add more CLIs in Telephony Configuration. +

+ )} + {fromNumbersCount === 0 && ( +

+ No phone numbers configured. Add CLIs in Telephony Configuration before running the campaign. +

+ )} +
+ + {/* Retry Configuration */} +
+
+
+ +

+ Automatically retry failed calls +

+
+ +
+ + {retryEnabled && ( +
+
+
+ + onMaxRetriesChange(e.target.value)} + min={0} + max={10} + /> +
+
+ + onRetryDelaySecondsChange(e.target.value)} + min={30} + max={3600} + /> +
+
+ +
+ +
+
+ Busy Signal + +
+
+ No Answer + +
+
+ Voicemail + +
+
+
+
+ )} +
+ + + + {/* Call Schedule */} +
+
+
+ +

+ Restrict when calls are made +

+
+ +
+ + {scheduleEnabled && ( +
+
+ + +
+ +
+ + {timeSlots.map((slot, index) => ( +
+ + { + const updated = [...timeSlots]; + updated[index] = { ...updated[index], start_time: e.target.value }; + onTimeSlotsChange(updated); + }} + className="w-[130px]" + /> + to + { + const updated = [...timeSlots]; + updated[index] = { ...updated[index], end_time: e.target.value }; + onTimeSlotsChange(updated); + }} + className="w-[130px]" + /> + {timeSlots.length > 1 && ( + + )} +
+ ))} + +
+
+ )} +
+
+ ); +} diff --git a/ui/src/app/campaigns/[campaignId]/edit/page.tsx b/ui/src/app/campaigns/[campaignId]/edit/page.tsx new file mode 100644 index 0000000..02eb5d5 --- /dev/null +++ b/ui/src/app/campaigns/[campaignId]/edit/page.tsx @@ -0,0 +1,364 @@ +"use client"; + +import { ArrowLeft } from 'lucide-react'; +import { useParams, useRouter } from 'next/navigation'; +import { useCallback, useEffect, useState } from 'react'; +import type { ITimezoneOption } from 'react-timezone-select'; +import { toast } from 'sonner'; + +import { + getCampaignApiV1CampaignCampaignIdGet, + getCampaignLimitsApiV1OrganizationsCampaignLimitsGet, + updateCampaignApiV1CampaignCampaignIdPatch, +} from '@/client/sdk.gen'; +import type { CampaignResponse } from '@/client/types.gen'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Separator } from '@/components/ui/separator'; +import { useAuth } from '@/lib/auth'; + +import CampaignAdvancedSettings, { getTimezoneValue, type TimeSlot } from '../../CampaignAdvancedSettings'; + +export default function EditCampaignPage() { + const { user, getAccessToken, redirectToLogin, loading } = useAuth(); + const router = useRouter(); + const params = useParams(); + const campaignId = parseInt(params.campaignId as string); + + // Loading state + const [isLoading, setIsLoading] = useState(true); + const [campaign, setCampaign] = useState(null); + + // Form state + const [campaignName, setCampaignName] = useState(''); + const [maxConcurrency, setMaxConcurrency] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + const [submitError, setSubmitError] = useState(null); + + // Limits state + const [orgConcurrentLimit, setOrgConcurrentLimit] = useState(2); + const [fromNumbersCount, setFromNumbersCount] = useState(0); + + // Retry config state + const [retryEnabled, setRetryEnabled] = useState(true); + const [maxRetries, setMaxRetries] = useState('2'); + const [retryDelaySeconds, setRetryDelaySeconds] = useState('120'); + const [retryOnBusy, setRetryOnBusy] = useState(true); + const [retryOnNoAnswer, setRetryOnNoAnswer] = useState(true); + const [retryOnVoicemail, setRetryOnVoicemail] = useState(true); + + // Schedule config state + const [scheduleEnabled, setScheduleEnabled] = useState(false); + const [scheduleTimezone, setScheduleTimezone] = useState('UTC'); + const [timeSlots, setTimeSlots] = useState([ + { day_of_week: 0, start_time: '09:00', end_time: '17:00' }, + ]); + + // Redirect if not authenticated + useEffect(() => { + if (!loading && !user) { + redirectToLogin(); + } + }, [loading, user, redirectToLogin]); + + // Fetch campaign and populate form + const fetchCampaign = useCallback(async () => { + if (!user) return; + try { + const accessToken = await getAccessToken(); + const response = await getCampaignApiV1CampaignCampaignIdGet({ + path: { campaign_id: campaignId }, + headers: { 'Authorization': `Bearer ${accessToken}` }, + }); + + if (response.data) { + const c = response.data; + + // Redirect if campaign is completed or failed + if (['completed', 'failed'].includes(c.state)) { + router.replace(`/campaigns/${campaignId}`); + return; + } + + setCampaign(c); + + // Populate form state + setCampaignName(c.name); + setMaxConcurrency(c.max_concurrency ? String(c.max_concurrency) : ''); + + // Retry config + setRetryEnabled(c.retry_config.enabled); + setMaxRetries(String(c.retry_config.max_retries)); + setRetryDelaySeconds(String(c.retry_config.retry_delay_seconds)); + setRetryOnBusy(c.retry_config.retry_on_busy); + setRetryOnNoAnswer(c.retry_config.retry_on_no_answer); + setRetryOnVoicemail(c.retry_config.retry_on_voicemail); + + // Schedule config + if (c.schedule_config) { + setScheduleEnabled(c.schedule_config.enabled); + setScheduleTimezone(c.schedule_config.timezone); + if (c.schedule_config.slots.length > 0) { + setTimeSlots(c.schedule_config.slots.map(s => ({ ...s }))); + } + } + } + } catch (error) { + console.error('Failed to fetch campaign:', error); + toast.error('Failed to load campaign'); + router.replace(`/campaigns/${campaignId}`); + } finally { + setIsLoading(false); + } + }, [user, getAccessToken, campaignId, router]); + + // Fetch campaign limits + const fetchCampaignLimits = useCallback(async () => { + if (!user) return; + try { + const accessToken = await getAccessToken(); + const response = await getCampaignLimitsApiV1OrganizationsCampaignLimitsGet({ + headers: { 'Authorization': `Bearer ${accessToken}` }, + }); + + if (response.data) { + setOrgConcurrentLimit(response.data.concurrent_call_limit); + setFromNumbersCount(response.data.from_numbers_count); + } + } catch (error) { + console.error('Failed to fetch campaign limits:', error); + } + }, [user, getAccessToken]); + + // Initial load + useEffect(() => { + if (user) { + fetchCampaign(); + fetchCampaignLimits(); + } + }, [fetchCampaign, fetchCampaignLimits, user]); + + // Effective concurrency limit + const effectiveLimit = fromNumbersCount > 0 + ? Math.min(orgConcurrentLimit, fromNumbersCount) + : orgConcurrentLimit; + + // Handle form submission + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setSubmitError(null); + + if (!campaignName.trim()) { + toast.error('Campaign name is required'); + return; + } + + // Validate max_concurrency if provided + const maxConcurrencyValue = maxConcurrency ? parseInt(maxConcurrency) : null; + if (maxConcurrencyValue !== null) { + if (isNaN(maxConcurrencyValue) || maxConcurrencyValue < 1 || maxConcurrencyValue > 100) { + toast.error('Max concurrent calls must be between 1 and 100'); + return; + } + if (maxConcurrencyValue > effectiveLimit) { + if (fromNumbersCount > 0 && fromNumbersCount < orgConcurrentLimit) { + toast.error(`Max concurrent calls cannot exceed ${effectiveLimit}. You have ${fromNumbersCount} phone number(s) configured — add more CLIs to increase concurrency.`); + } else { + toast.error(`Max concurrent calls cannot exceed organization limit (${effectiveLimit})`); + } + return; + } + } + + // Validate schedule slots if enabled + if (scheduleEnabled) { + if (timeSlots.length === 0) { + toast.error('Add at least one time slot'); + return; + } + for (const slot of timeSlots) { + if (slot.start_time >= slot.end_time) { + toast.error('Start time must be before end time for each slot'); + return; + } + } + } + + setIsSubmitting(true); + + try { + const accessToken = await getAccessToken(); + + const retryConfig = { + enabled: retryEnabled, + max_retries: parseInt(maxRetries) || 2, + retry_delay_seconds: parseInt(retryDelaySeconds) || 120, + retry_on_busy: retryOnBusy, + retry_on_no_answer: retryOnNoAnswer, + retry_on_voicemail: retryOnVoicemail, + }; + + const timezoneValue = getTimezoneValue(scheduleTimezone); + const scheduleConfig = scheduleEnabled && timeSlots.length > 0 + ? { + enabled: true, + timezone: timezoneValue, + slots: timeSlots, + } + : { + enabled: false, + timezone: timezoneValue, + slots: [{ day_of_week: 0, start_time: '09:00', end_time: '17:00' }], + }; + + const response = await updateCampaignApiV1CampaignCampaignIdPatch({ + path: { campaign_id: campaignId }, + body: { + name: campaignName, + retry_config: retryConfig, + max_concurrency: maxConcurrencyValue, + schedule_config: scheduleConfig, + }, + headers: { 'Authorization': `Bearer ${accessToken}` }, + }); + + if (response.error) { + const errorDetail = (response.error as { detail?: string })?.detail; + const errorMessage = errorDetail || 'Failed to update campaign'; + setSubmitError(errorMessage); + toast.error(errorMessage); + return; + } + + if (response.data) { + toast.success('Campaign updated successfully'); + router.push(`/campaigns/${campaignId}`); + } + } catch (error) { + console.error('Failed to update campaign:', error); + const errorMessage = 'Failed to update campaign'; + setSubmitError(errorMessage); + toast.error(errorMessage); + } finally { + setIsSubmitting(false); + } + }; + + const handleBack = () => { + router.push(`/campaigns/${campaignId}`); + }; + + if (isLoading) { + return ( +
+
+
+
+
+
+ ); + } + + if (!campaign) { + return ( +
+

Campaign not found

+
+ ); + } + + return ( +
+
+ +

Edit Campaign

+

Modify campaign settings

+
+ + + + Campaign Settings + + Update name, concurrency, retry, and schedule configuration + + + +
+ {/* Campaign Name */} +
+ + setCampaignName(e.target.value)} + maxLength={255} + required + /> +
+ + + + + + {submitError && ( +
+ {submitError} +
+ )} + +
+ + +
+ +
+
+
+ ); +} diff --git a/ui/src/app/campaigns/[campaignId]/page.tsx b/ui/src/app/campaigns/[campaignId]/page.tsx index 2354b88..b3b328b 100644 --- a/ui/src/app/campaigns/[campaignId]/page.tsx +++ b/ui/src/app/campaigns/[campaignId]/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { ArrowLeft, Check, Pause, Play, RefreshCw, X } from 'lucide-react'; +import { ArrowLeft, Check, Clock, Pause, Pencil, Play, RefreshCw, X } from 'lucide-react'; import { useParams, useRouter, useSearchParams } from 'next/navigation'; import { useCallback, useEffect, useState } from 'react'; import { toast } from 'sonner'; @@ -10,7 +10,8 @@ import { getCampaignSourceDownloadUrlApiV1CampaignCampaignIdSourceDownloadUrlGet, pauseCampaignApiV1CampaignCampaignIdPausePost, resumeCampaignApiV1CampaignCampaignIdResumePost, - startCampaignApiV1CampaignCampaignIdStartPost} from '@/client/sdk.gen'; + startCampaignApiV1CampaignCampaignIdStartPost, +} from '@/client/sdk.gen'; import type { CampaignResponse } from '@/client/types.gen'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; @@ -236,31 +237,49 @@ export default function CampaignDetailPage() { } }; + const canEdit = campaign && ['created', 'running', 'paused'].includes(campaign.state); + // Render action button based on state const renderActionButton = () => { if (!campaign || isExecutingAction) return null; + const editButton = canEdit ? ( + + ) : null; + switch (campaign.state) { case 'created': return ( - +
+ {editButton} + +
); case 'running': return ( - +
+ {editButton} + +
); case 'paused': return ( - +
+ {editButton} + +
); default: return null; @@ -449,6 +468,51 @@ export default function CampaignDetailPage() { )} + + + + {/* Call Schedule (read-only) */} +
+
+ Call Schedule +
+ {campaign.schedule_config?.enabled ? ( + + + Enabled + + ) : ( + + + Not configured + + )} +
+
+ + {campaign.schedule_config?.enabled && ( +
+
+
Timezone
+
{campaign.schedule_config.timezone.replace(/_/g, ' ')}
+
+
+
Time Slots
+
+ {campaign.schedule_config.slots.map((slot, index) => { + const dayNames = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; + return ( +
+ {dayNames[slot.day_of_week]} + {slot.start_time} - {slot.end_time} +
+ ); + })} +
+
+
+ )} +
diff --git a/ui/src/app/campaigns/new/page.tsx b/ui/src/app/campaigns/new/page.tsx index b49aeef..7ef0754 100644 --- a/ui/src/app/campaigns/new/page.tsx +++ b/ui/src/app/campaigns/new/page.tsx @@ -3,6 +3,7 @@ import { ArrowLeft, ChevronDown, ChevronRight } from 'lucide-react'; import { useRouter } from 'next/navigation'; import { useCallback, useEffect, useState } from 'react'; +import type { ITimezoneOption } from 'react-timezone-select'; import { toast } from 'sonner'; import { @@ -23,9 +24,9 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; -import { Switch } from '@/components/ui/switch'; import { useAuth } from '@/lib/auth'; +import CampaignAdvancedSettings, { getTimezoneValue, type TimeSlot } from '../CampaignAdvancedSettings'; import CsvUploadSelector from '../CsvUploadSelector'; import GoogleSheetSelector from '../GoogleSheetSelector'; @@ -59,6 +60,18 @@ export default function NewCampaignPage() { const [retryOnBusy, setRetryOnBusy] = useState(true); const [retryOnNoAnswer, setRetryOnNoAnswer] = useState(true); const [retryOnVoicemail, setRetryOnVoicemail] = useState(true); + // Schedule config state + const [scheduleEnabled, setScheduleEnabled] = useState(false); + const [scheduleTimezone, setScheduleTimezone] = useState(() => { + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone; + } catch { + return 'UTC'; + } + }); + const [timeSlots, setTimeSlots] = useState([ + { day_of_week: 0, start_time: '09:00', end_time: '17:00' }, + ]); // Redirect if not authenticated useEffect(() => { @@ -163,7 +176,6 @@ export default function NewCampaignPage() { try { const accessToken = await getAccessToken(); - // Build retry_config only if user has modified settings from defaults const retryConfig = { enabled: retryEnabled, max_retries: parseInt(maxRetries) || 2, @@ -173,6 +185,16 @@ export default function NewCampaignPage() { retry_on_voicemail: retryOnVoicemail, }; + // Build schedule_config if enabled + const timezoneValue = getTimezoneValue(scheduleTimezone); + const scheduleConfig = scheduleEnabled && timeSlots.length > 0 + ? { + enabled: true, + timezone: timezoneValue, + slots: timeSlots, + } + : undefined; + const response = await createCampaignApiV1CampaignCreatePost({ body: { name: campaignName, @@ -181,6 +203,7 @@ export default function NewCampaignPage() { source_id: sourceId, retry_config: retryConfig, max_concurrency: maxConcurrencyValue, + schedule_config: scheduleConfig, }, headers: { 'Authorization': `Bearer ${accessToken}`, @@ -353,107 +376,32 @@ export default function NewCampaignPage() { )} - - {/* Max Concurrent Calls */} -
- - setMaxConcurrency(e.target.value)} - min={1} - max={effectiveLimit} - /> -

- Maximum number of simultaneous calls. Leave empty to use {effectiveLimit}. - {fromNumbersCount > 0 && ` You have ${fromNumbersCount} CLI${fromNumbersCount !== 1 ? 's' : ''} and an org limit of ${orgConcurrentLimit}.`} -

- {fromNumbersCount > 0 && fromNumbersCount < orgConcurrentLimit && ( -

- Concurrency is limited to {fromNumbersCount} by your configured phone numbers. To use the full org limit of {orgConcurrentLimit}, add more CLIs in Telephony Configuration. -

- )} - {fromNumbersCount === 0 && ( -

- No phone numbers configured. Add CLIs in Telephony Configuration before running the campaign. -

- )} -
- - {/* Retry Configuration */} -
-
-
- -

- Automatically retry failed calls -

-
- -
- - {retryEnabled && ( -
-
-
- - setMaxRetries(e.target.value)} - min={0} - max={10} - /> -
-
- - setRetryDelaySeconds(e.target.value)} - min={30} - max={3600} - /> -
-
- -
- -
-
- Busy Signal - -
-
- No Answer - -
-
- Voicemail - -
-
-
-
- )} -
+ + diff --git a/ui/src/client/sdk.gen.ts b/ui/src/client/sdk.gen.ts index 37f2105..339e0bc 100644 --- a/ui/src/client/sdk.gen.ts +++ b/ui/src/client/sdk.gen.ts @@ -3,7 +3,7 @@ import type { Client,Options as ClientOptions, TDataShape } from '@hey-api/client-fetch'; import { client as _heyApiClient } from './client.gen'; -import type { ArchiveApiKeyApiV1UserApiKeysApiKeyIdDeleteData, ArchiveApiKeyApiV1UserApiKeysApiKeyIdDeleteError, ArchiveApiKeyApiV1UserApiKeysApiKeyIdDeleteResponse, ArchiveServiceKeyApiV1UserServiceKeysServiceKeyIdDeleteData, ArchiveServiceKeyApiV1UserServiceKeysServiceKeyIdDeleteError, CompleteTransferFunctionCallApiV1TelephonyTransferResultTransferIdPostData, CompleteTransferFunctionCallApiV1TelephonyTransferResultTransferIdPostError, CreateApiKeyApiV1UserApiKeysPostData, CreateApiKeyApiV1UserApiKeysPostError, CreateApiKeyApiV1UserApiKeysPostResponse, CreateCampaignApiV1CampaignCreatePostData, CreateCampaignApiV1CampaignCreatePostError, CreateCampaignApiV1CampaignCreatePostResponse, CreateCredentialApiV1CredentialsPostData, CreateCredentialApiV1CredentialsPostError, CreateCredentialApiV1CredentialsPostResponse, CreateLoadTestApiV1LooptalkLoadTestsPostData, CreateLoadTestApiV1LooptalkLoadTestsPostError, CreateLoadTestApiV1LooptalkLoadTestsPostResponse, CreateOrUpdateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenPostData, CreateOrUpdateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenPostError, CreateOrUpdateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenPostResponse, CreateServiceKeyApiV1UserServiceKeysPostData, CreateServiceKeyApiV1UserServiceKeysPostError, CreateServiceKeyApiV1UserServiceKeysPostResponse, CreateSessionApiV1IntegrationSessionPostData, CreateSessionApiV1IntegrationSessionPostError, CreateSessionApiV1IntegrationSessionPostResponse, CreateTestSessionApiV1LooptalkTestSessionsPostData, CreateTestSessionApiV1LooptalkTestSessionsPostError, CreateTestSessionApiV1LooptalkTestSessionsPostResponse, CreateToolApiV1ToolsPostData, CreateToolApiV1ToolsPostError, CreateToolApiV1ToolsPostResponse, CreateWorkflowApiV1WorkflowCreateDefinitionPostData, CreateWorkflowApiV1WorkflowCreateDefinitionPostError, CreateWorkflowApiV1WorkflowCreateDefinitionPostResponse, CreateWorkflowFromTemplateApiV1WorkflowCreateTemplatePostData, CreateWorkflowFromTemplateApiV1WorkflowCreateTemplatePostError, CreateWorkflowFromTemplateApiV1WorkflowCreateTemplatePostResponse, CreateWorkflowRunApiV1WorkflowWorkflowIdRunsPostData, CreateWorkflowRunApiV1WorkflowWorkflowIdRunsPostError, CreateWorkflowRunApiV1WorkflowWorkflowIdRunsPostResponse, DeactivateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenDeleteData, DeactivateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenDeleteError, DeactivateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenDeleteResponse, DeleteCredentialApiV1CredentialsCredentialUuidDeleteData, DeleteCredentialApiV1CredentialsCredentialUuidDeleteError, DeleteCredentialApiV1CredentialsCredentialUuidDeleteResponse, DeleteDocumentApiV1KnowledgeBaseDocumentsDocumentUuidDeleteData, DeleteDocumentApiV1KnowledgeBaseDocumentsDocumentUuidDeleteError, DeleteToolApiV1ToolsToolUuidDeleteData, DeleteToolApiV1ToolsToolUuidDeleteError, DeleteToolApiV1ToolsToolUuidDeleteResponse, DownloadWorkflowArtifactApiV1PublicDownloadWorkflowTokenArtifactTypeGetData, DownloadWorkflowArtifactApiV1PublicDownloadWorkflowTokenArtifactTypeGetError, DuplicateWorkflowTemplateApiV1WorkflowTemplatesDuplicatePostData, DuplicateWorkflowTemplateApiV1WorkflowTemplatesDuplicatePostError, DuplicateWorkflowTemplateApiV1WorkflowTemplatesDuplicatePostResponse, GetActiveTestsApiV1LooptalkActiveTestsGetData, GetActiveTestsApiV1LooptalkActiveTestsGetError, GetApiKeysApiV1UserApiKeysGetData, GetApiKeysApiV1UserApiKeysGetError, GetApiKeysApiV1UserApiKeysGetResponse, GetAuthUserApiV1UserAuthUserGetData, GetAuthUserApiV1UserAuthUserGetError, GetAuthUserApiV1UserAuthUserGetResponse, GetCampaignApiV1CampaignCampaignIdGetData, GetCampaignApiV1CampaignCampaignIdGetError, GetCampaignApiV1CampaignCampaignIdGetResponse, GetCampaignLimitsApiV1OrganizationsCampaignLimitsGetData, GetCampaignLimitsApiV1OrganizationsCampaignLimitsGetError, GetCampaignLimitsApiV1OrganizationsCampaignLimitsGetResponse, GetCampaignProgressApiV1CampaignCampaignIdProgressGetData, GetCampaignProgressApiV1CampaignCampaignIdProgressGetError, GetCampaignProgressApiV1CampaignCampaignIdProgressGetResponse, GetCampaignRunsApiV1CampaignCampaignIdRunsGetData, GetCampaignRunsApiV1CampaignCampaignIdRunsGetError, GetCampaignRunsApiV1CampaignCampaignIdRunsGetResponse, GetCampaignsApiV1CampaignGetData, GetCampaignsApiV1CampaignGetError, GetCampaignsApiV1CampaignGetResponse, GetCampaignSourceDownloadUrlApiV1CampaignCampaignIdSourceDownloadUrlGetData, GetCampaignSourceDownloadUrlApiV1CampaignCampaignIdSourceDownloadUrlGetError, GetCampaignSourceDownloadUrlApiV1CampaignCampaignIdSourceDownloadUrlGetResponse, GetCredentialApiV1CredentialsCredentialUuidGetData, GetCredentialApiV1CredentialsCredentialUuidGetError, GetCredentialApiV1CredentialsCredentialUuidGetResponse, GetCurrentPeriodUsageApiV1OrganizationsUsageCurrentPeriodGetData, GetCurrentPeriodUsageApiV1OrganizationsUsageCurrentPeriodGetError, GetCurrentPeriodUsageApiV1OrganizationsUsageCurrentPeriodGetResponse, GetDailyReportApiV1OrganizationsReportsDailyGetData, GetDailyReportApiV1OrganizationsReportsDailyGetError, GetDailyReportApiV1OrganizationsReportsDailyGetResponse, GetDailyRunsDetailApiV1OrganizationsReportsDailyRunsGetData, GetDailyRunsDetailApiV1OrganizationsReportsDailyRunsGetError, GetDailyRunsDetailApiV1OrganizationsReportsDailyRunsGetResponse, GetDailyUsageBreakdownApiV1OrganizationsUsageDailyBreakdownGetData, GetDailyUsageBreakdownApiV1OrganizationsUsageDailyBreakdownGetError, GetDailyUsageBreakdownApiV1OrganizationsUsageDailyBreakdownGetResponse, GetDefaultConfigurationsApiV1UserConfigurationsDefaultsGetData, GetDefaultConfigurationsApiV1UserConfigurationsDefaultsGetResponse, GetDocumentApiV1KnowledgeBaseDocumentsDocumentUuidGetData, GetDocumentApiV1KnowledgeBaseDocumentsDocumentUuidGetError, GetDocumentApiV1KnowledgeBaseDocumentsDocumentUuidGetResponse, GetEmbedConfigApiV1PublicEmbedConfigTokenGetData, GetEmbedConfigApiV1PublicEmbedConfigTokenGetError, GetEmbedConfigApiV1PublicEmbedConfigTokenGetResponse, GetEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenGetData, GetEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenGetError, GetEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenGetResponse, GetFileMetadataApiV1S3FileMetadataGetData, GetFileMetadataApiV1S3FileMetadataGetError, GetFileMetadataApiV1S3FileMetadataGetResponse, GetIntegrationAccessTokenApiV1IntegrationIntegrationIdAccessTokenGetData, GetIntegrationAccessTokenApiV1IntegrationIntegrationIdAccessTokenGetError, GetIntegrationAccessTokenApiV1IntegrationIntegrationIdAccessTokenGetResponse, GetIntegrationsApiV1IntegrationGetData, GetIntegrationsApiV1IntegrationGetError, GetIntegrationsApiV1IntegrationGetResponse, GetLoadTestStatsApiV1LooptalkLoadTestsLoadTestGroupIdStatsGetData, GetLoadTestStatsApiV1LooptalkLoadTestsLoadTestGroupIdStatsGetError, GetLoadTestStatsApiV1LooptalkLoadTestsLoadTestGroupIdStatsGetResponse, GetPresignedUploadUrlApiV1S3PresignedUploadUrlPostData, GetPresignedUploadUrlApiV1S3PresignedUploadUrlPostError, GetPresignedUploadUrlApiV1S3PresignedUploadUrlPostResponse, GetPublicTurnCredentialsApiV1PublicEmbedTurnCredentialsSessionTokenGetData, GetPublicTurnCredentialsApiV1PublicEmbedTurnCredentialsSessionTokenGetError, GetPublicTurnCredentialsApiV1PublicEmbedTurnCredentialsSessionTokenGetResponse, GetServiceKeysApiV1UserServiceKeysGetData, GetServiceKeysApiV1UserServiceKeysGetError, GetServiceKeysApiV1UserServiceKeysGetResponse, GetSignedUrlApiV1S3SignedUrlGetData, GetSignedUrlApiV1S3SignedUrlGetError, GetSignedUrlApiV1S3SignedUrlGetResponse, GetTelephonyConfigurationApiV1OrganizationsTelephonyConfigGetData, GetTelephonyConfigurationApiV1OrganizationsTelephonyConfigGetError, GetTelephonyConfigurationApiV1OrganizationsTelephonyConfigGetResponse, GetTestSessionApiV1LooptalkTestSessionsTestSessionIdGetData, GetTestSessionApiV1LooptalkTestSessionsTestSessionIdGetError, GetTestSessionApiV1LooptalkTestSessionsTestSessionIdGetResponse, GetTestSessionConversationApiV1LooptalkTestSessionsTestSessionIdConversationGetData, GetTestSessionConversationApiV1LooptalkTestSessionsTestSessionIdConversationGetError, GetToolApiV1ToolsToolUuidGetData, GetToolApiV1ToolsToolUuidGetError, GetToolApiV1ToolsToolUuidGetResponse, GetTurnCredentialsApiV1TurnCredentialsGetData, GetTurnCredentialsApiV1TurnCredentialsGetError, GetTurnCredentialsApiV1TurnCredentialsGetResponse, GetUploadUrlApiV1KnowledgeBaseUploadUrlPostData, GetUploadUrlApiV1KnowledgeBaseUploadUrlPostError, GetUploadUrlApiV1KnowledgeBaseUploadUrlPostResponse, GetUsageHistoryApiV1OrganizationsUsageRunsGetData, GetUsageHistoryApiV1OrganizationsUsageRunsGetError, GetUsageHistoryApiV1OrganizationsUsageRunsGetResponse, GetUserConfigurationsApiV1UserConfigurationsUserGetData, GetUserConfigurationsApiV1UserConfigurationsUserGetError, GetUserConfigurationsApiV1UserConfigurationsUserGetResponse, GetVoicesApiV1UserConfigurationsVoicesProviderGetData, GetVoicesApiV1UserConfigurationsVoicesProviderGetError, GetVoicesApiV1UserConfigurationsVoicesProviderGetResponse, GetWorkflowApiV1WorkflowFetchWorkflowIdGetData, GetWorkflowApiV1WorkflowFetchWorkflowIdGetError, GetWorkflowApiV1WorkflowFetchWorkflowIdGetResponse, GetWorkflowCountApiV1WorkflowCountGetData, GetWorkflowCountApiV1WorkflowCountGetError, GetWorkflowCountApiV1WorkflowCountGetResponse, GetWorkflowOptionsApiV1OrganizationsReportsWorkflowsGetData, GetWorkflowOptionsApiV1OrganizationsReportsWorkflowsGetError, GetWorkflowOptionsApiV1OrganizationsReportsWorkflowsGetResponse, GetWorkflowRunApiV1WorkflowWorkflowIdRunsRunIdGetData, GetWorkflowRunApiV1WorkflowWorkflowIdRunsRunIdGetError, GetWorkflowRunApiV1WorkflowWorkflowIdRunsRunIdGetResponse, GetWorkflowRunsApiV1SuperuserWorkflowRunsGetData, GetWorkflowRunsApiV1SuperuserWorkflowRunsGetError, GetWorkflowRunsApiV1SuperuserWorkflowRunsGetResponse, GetWorkflowRunsApiV1WorkflowWorkflowIdRunsGetData, GetWorkflowRunsApiV1WorkflowWorkflowIdRunsGetError, GetWorkflowRunsApiV1WorkflowWorkflowIdRunsGetResponse, GetWorkflowsApiV1WorkflowFetchGetData, GetWorkflowsApiV1WorkflowFetchGetError, GetWorkflowsApiV1WorkflowFetchGetResponse, GetWorkflowsSummaryApiV1WorkflowSummaryGetData, GetWorkflowsSummaryApiV1WorkflowSummaryGetError, GetWorkflowsSummaryApiV1WorkflowSummaryGetResponse, GetWorkflowTemplatesApiV1WorkflowTemplatesGetData, GetWorkflowTemplatesApiV1WorkflowTemplatesGetResponse, HandleCloudonixCdrApiV1TelephonyCloudonixCdrPostData, HandleCloudonixStatusCallbackApiV1TelephonyCloudonixStatusCallbackWorkflowRunIdPostData, HandleCloudonixStatusCallbackApiV1TelephonyCloudonixStatusCallbackWorkflowRunIdPostError, HandleInboundFallbackApiV1TelephonyInboundFallbackPostData, HandleInboundTelephonyApiV1TelephonyInboundWorkflowIdPostData, HandleInboundTelephonyApiV1TelephonyInboundWorkflowIdPostError, HandleTwilioStatusCallbackApiV1TelephonyTwilioStatusCallbackWorkflowRunIdPostData, HandleTwilioStatusCallbackApiV1TelephonyTwilioStatusCallbackWorkflowRunIdPostError, HandleVobizHangupCallbackApiV1TelephonyVobizHangupCallbackWorkflowRunIdPostData, HandleVobizHangupCallbackApiV1TelephonyVobizHangupCallbackWorkflowRunIdPostError, HandleVobizHangupCallbackByWorkflowApiV1TelephonyVobizHangupCallbackWorkflowWorkflowIdPostData, HandleVobizHangupCallbackByWorkflowApiV1TelephonyVobizHangupCallbackWorkflowWorkflowIdPostError, HandleVobizRingCallbackApiV1TelephonyVobizRingCallbackWorkflowRunIdPostData, HandleVobizRingCallbackApiV1TelephonyVobizRingCallbackWorkflowRunIdPostError, HandleVonageEventsApiV1TelephonyVonageEventsWorkflowRunIdPostData, HandleVonageEventsApiV1TelephonyVonageEventsWorkflowRunIdPostError, HealthApiV1HealthGetData, HealthApiV1HealthGetResponse,ImpersonateApiV1SuperuserImpersonatePostData, ImpersonateApiV1SuperuserImpersonatePostError, ImpersonateApiV1SuperuserImpersonatePostResponse, InitializeEmbedSessionApiV1PublicEmbedInitPostData, InitializeEmbedSessionApiV1PublicEmbedInitPostError, InitializeEmbedSessionApiV1PublicEmbedInitPostResponse, InitiateCallApiV1PublicAgentUuidPostData, InitiateCallApiV1PublicAgentUuidPostError, InitiateCallApiV1PublicAgentUuidPostResponse, InitiateCallApiV1TelephonyInitiateCallPostData, InitiateCallApiV1TelephonyInitiateCallPostError, InitiateCallTransferApiV1TelephonyCallTransferPostData, InitiateCallTransferApiV1TelephonyCallTransferPostError, ListCredentialsApiV1CredentialsGetData, ListCredentialsApiV1CredentialsGetError, ListCredentialsApiV1CredentialsGetResponse, ListDocumentsApiV1KnowledgeBaseDocumentsGetData, ListDocumentsApiV1KnowledgeBaseDocumentsGetError, ListDocumentsApiV1KnowledgeBaseDocumentsGetResponse, ListTestSessionsApiV1LooptalkTestSessionsGetData, ListTestSessionsApiV1LooptalkTestSessionsGetError, ListTestSessionsApiV1LooptalkTestSessionsGetResponse, ListToolsApiV1ToolsGetData, ListToolsApiV1ToolsGetError, ListToolsApiV1ToolsGetResponse, OptionsConfigApiV1PublicEmbedConfigTokenOptionsData, OptionsConfigApiV1PublicEmbedConfigTokenOptionsError, OptionsInitApiV1PublicEmbedInitOptionsData, OptionsTurnCredentialsApiV1PublicEmbedTurnCredentialsSessionTokenOptionsData, OptionsTurnCredentialsApiV1PublicEmbedTurnCredentialsSessionTokenOptionsError, PauseCampaignApiV1CampaignCampaignIdPausePostData, PauseCampaignApiV1CampaignCampaignIdPausePostError, PauseCampaignApiV1CampaignCampaignIdPausePostResponse, ProcessDocumentApiV1KnowledgeBaseProcessDocumentPostData, ProcessDocumentApiV1KnowledgeBaseProcessDocumentPostError, ProcessDocumentApiV1KnowledgeBaseProcessDocumentPostResponse, ReactivateApiKeyApiV1UserApiKeysApiKeyIdReactivatePutData, ReactivateApiKeyApiV1UserApiKeysApiKeyIdReactivatePutError, ReactivateApiKeyApiV1UserApiKeysApiKeyIdReactivatePutResponse, ReactivateServiceKeyApiV1UserServiceKeysServiceKeyIdReactivatePutData, ReactivateServiceKeyApiV1UserServiceKeysServiceKeyIdReactivatePutError, ResumeCampaignApiV1CampaignCampaignIdResumePostData, ResumeCampaignApiV1CampaignCampaignIdResumePostError, ResumeCampaignApiV1CampaignCampaignIdResumePostResponse, SaveTelephonyConfigurationApiV1OrganizationsTelephonyConfigPostData, SaveTelephonyConfigurationApiV1OrganizationsTelephonyConfigPostError, SearchChunksApiV1KnowledgeBaseSearchPostData, SearchChunksApiV1KnowledgeBaseSearchPostError, SearchChunksApiV1KnowledgeBaseSearchPostResponse, SetAdminCommentApiV1SuperuserWorkflowRunsRunIdCommentPostData, SetAdminCommentApiV1SuperuserWorkflowRunsRunIdCommentPostError, SetAdminCommentApiV1SuperuserWorkflowRunsRunIdCommentPostResponse, StartCampaignApiV1CampaignCampaignIdStartPostData, StartCampaignApiV1CampaignCampaignIdStartPostError, StartCampaignApiV1CampaignCampaignIdStartPostResponse, StartTestSessionApiV1LooptalkTestSessionsTestSessionIdStartPostData, StartTestSessionApiV1LooptalkTestSessionsTestSessionIdStartPostError, StopTestSessionApiV1LooptalkTestSessionsTestSessionIdStopPostData, StopTestSessionApiV1LooptalkTestSessionsTestSessionIdStopPostError, UnarchiveToolApiV1ToolsToolUuidUnarchivePostData, UnarchiveToolApiV1ToolsToolUuidUnarchivePostError, UnarchiveToolApiV1ToolsToolUuidUnarchivePostResponse, UpdateCredentialApiV1CredentialsCredentialUuidPutData, UpdateCredentialApiV1CredentialsCredentialUuidPutError, UpdateCredentialApiV1CredentialsCredentialUuidPutResponse, UpdateIntegrationApiV1IntegrationIntegrationIdPutData, UpdateIntegrationApiV1IntegrationIntegrationIdPutError, UpdateIntegrationApiV1IntegrationIntegrationIdPutResponse, UpdateToolApiV1ToolsToolUuidPutData, UpdateToolApiV1ToolsToolUuidPutError, UpdateToolApiV1ToolsToolUuidPutResponse, UpdateUserConfigurationsApiV1UserConfigurationsUserPutData, UpdateUserConfigurationsApiV1UserConfigurationsUserPutError, UpdateUserConfigurationsApiV1UserConfigurationsUserPutResponse, UpdateWorkflowApiV1WorkflowWorkflowIdPutData, UpdateWorkflowApiV1WorkflowWorkflowIdPutError, UpdateWorkflowApiV1WorkflowWorkflowIdPutResponse, UpdateWorkflowStatusApiV1WorkflowWorkflowIdStatusPutData, UpdateWorkflowStatusApiV1WorkflowWorkflowIdStatusPutError, UpdateWorkflowStatusApiV1WorkflowWorkflowIdStatusPutResponse, ValidateUserConfigurationsApiV1UserConfigurationsUserValidateGetData, ValidateUserConfigurationsApiV1UserConfigurationsUserValidateGetError, ValidateUserConfigurationsApiV1UserConfigurationsUserValidateGetResponse, ValidateWorkflowApiV1WorkflowWorkflowIdValidatePostData, ValidateWorkflowApiV1WorkflowWorkflowIdValidatePostError, ValidateWorkflowApiV1WorkflowWorkflowIdValidatePostResponse } from './types.gen'; +import type { ArchiveApiKeyApiV1UserApiKeysApiKeyIdDeleteData, ArchiveApiKeyApiV1UserApiKeysApiKeyIdDeleteError, ArchiveApiKeyApiV1UserApiKeysApiKeyIdDeleteResponse, ArchiveServiceKeyApiV1UserServiceKeysServiceKeyIdDeleteData, ArchiveServiceKeyApiV1UserServiceKeysServiceKeyIdDeleteError, CompleteTransferFunctionCallApiV1TelephonyTransferResultTransferIdPostData, CompleteTransferFunctionCallApiV1TelephonyTransferResultTransferIdPostError, CreateApiKeyApiV1UserApiKeysPostData, CreateApiKeyApiV1UserApiKeysPostError, CreateApiKeyApiV1UserApiKeysPostResponse, CreateCampaignApiV1CampaignCreatePostData, CreateCampaignApiV1CampaignCreatePostError, CreateCampaignApiV1CampaignCreatePostResponse, CreateCredentialApiV1CredentialsPostData, CreateCredentialApiV1CredentialsPostError, CreateCredentialApiV1CredentialsPostResponse, CreateLoadTestApiV1LooptalkLoadTestsPostData, CreateLoadTestApiV1LooptalkLoadTestsPostError, CreateLoadTestApiV1LooptalkLoadTestsPostResponse, CreateOrUpdateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenPostData, CreateOrUpdateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenPostError, CreateOrUpdateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenPostResponse, CreateServiceKeyApiV1UserServiceKeysPostData, CreateServiceKeyApiV1UserServiceKeysPostError, CreateServiceKeyApiV1UserServiceKeysPostResponse, CreateSessionApiV1IntegrationSessionPostData, CreateSessionApiV1IntegrationSessionPostError, CreateSessionApiV1IntegrationSessionPostResponse, CreateTestSessionApiV1LooptalkTestSessionsPostData, CreateTestSessionApiV1LooptalkTestSessionsPostError, CreateTestSessionApiV1LooptalkTestSessionsPostResponse, CreateToolApiV1ToolsPostData, CreateToolApiV1ToolsPostError, CreateToolApiV1ToolsPostResponse, CreateWorkflowApiV1WorkflowCreateDefinitionPostData, CreateWorkflowApiV1WorkflowCreateDefinitionPostError, CreateWorkflowApiV1WorkflowCreateDefinitionPostResponse, CreateWorkflowFromTemplateApiV1WorkflowCreateTemplatePostData, CreateWorkflowFromTemplateApiV1WorkflowCreateTemplatePostError, CreateWorkflowFromTemplateApiV1WorkflowCreateTemplatePostResponse, CreateWorkflowRunApiV1WorkflowWorkflowIdRunsPostData, CreateWorkflowRunApiV1WorkflowWorkflowIdRunsPostError, CreateWorkflowRunApiV1WorkflowWorkflowIdRunsPostResponse, DeactivateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenDeleteData, DeactivateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenDeleteError, DeactivateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenDeleteResponse, DeleteCredentialApiV1CredentialsCredentialUuidDeleteData, DeleteCredentialApiV1CredentialsCredentialUuidDeleteError, DeleteCredentialApiV1CredentialsCredentialUuidDeleteResponse, DeleteDocumentApiV1KnowledgeBaseDocumentsDocumentUuidDeleteData, DeleteDocumentApiV1KnowledgeBaseDocumentsDocumentUuidDeleteError, DeleteToolApiV1ToolsToolUuidDeleteData, DeleteToolApiV1ToolsToolUuidDeleteError, DeleteToolApiV1ToolsToolUuidDeleteResponse, DownloadWorkflowArtifactApiV1PublicDownloadWorkflowTokenArtifactTypeGetData, DownloadWorkflowArtifactApiV1PublicDownloadWorkflowTokenArtifactTypeGetError, DuplicateWorkflowTemplateApiV1WorkflowTemplatesDuplicatePostData, DuplicateWorkflowTemplateApiV1WorkflowTemplatesDuplicatePostError, DuplicateWorkflowTemplateApiV1WorkflowTemplatesDuplicatePostResponse, GetActiveTestsApiV1LooptalkActiveTestsGetData, GetActiveTestsApiV1LooptalkActiveTestsGetError, GetApiKeysApiV1UserApiKeysGetData, GetApiKeysApiV1UserApiKeysGetError, GetApiKeysApiV1UserApiKeysGetResponse, GetAuthUserApiV1UserAuthUserGetData, GetAuthUserApiV1UserAuthUserGetError, GetAuthUserApiV1UserAuthUserGetResponse, GetCampaignApiV1CampaignCampaignIdGetData, GetCampaignApiV1CampaignCampaignIdGetError, GetCampaignApiV1CampaignCampaignIdGetResponse, GetCampaignLimitsApiV1OrganizationsCampaignLimitsGetData, GetCampaignLimitsApiV1OrganizationsCampaignLimitsGetError, GetCampaignLimitsApiV1OrganizationsCampaignLimitsGetResponse, GetCampaignProgressApiV1CampaignCampaignIdProgressGetData, GetCampaignProgressApiV1CampaignCampaignIdProgressGetError, GetCampaignProgressApiV1CampaignCampaignIdProgressGetResponse, GetCampaignRunsApiV1CampaignCampaignIdRunsGetData, GetCampaignRunsApiV1CampaignCampaignIdRunsGetError, GetCampaignRunsApiV1CampaignCampaignIdRunsGetResponse, GetCampaignsApiV1CampaignGetData, GetCampaignsApiV1CampaignGetError, GetCampaignsApiV1CampaignGetResponse, GetCampaignSourceDownloadUrlApiV1CampaignCampaignIdSourceDownloadUrlGetData, GetCampaignSourceDownloadUrlApiV1CampaignCampaignIdSourceDownloadUrlGetError, GetCampaignSourceDownloadUrlApiV1CampaignCampaignIdSourceDownloadUrlGetResponse, GetCredentialApiV1CredentialsCredentialUuidGetData, GetCredentialApiV1CredentialsCredentialUuidGetError, GetCredentialApiV1CredentialsCredentialUuidGetResponse, GetCurrentPeriodUsageApiV1OrganizationsUsageCurrentPeriodGetData, GetCurrentPeriodUsageApiV1OrganizationsUsageCurrentPeriodGetError, GetCurrentPeriodUsageApiV1OrganizationsUsageCurrentPeriodGetResponse, GetDailyReportApiV1OrganizationsReportsDailyGetData, GetDailyReportApiV1OrganizationsReportsDailyGetError, GetDailyReportApiV1OrganizationsReportsDailyGetResponse, GetDailyRunsDetailApiV1OrganizationsReportsDailyRunsGetData, GetDailyRunsDetailApiV1OrganizationsReportsDailyRunsGetError, GetDailyRunsDetailApiV1OrganizationsReportsDailyRunsGetResponse, GetDailyUsageBreakdownApiV1OrganizationsUsageDailyBreakdownGetData, GetDailyUsageBreakdownApiV1OrganizationsUsageDailyBreakdownGetError, GetDailyUsageBreakdownApiV1OrganizationsUsageDailyBreakdownGetResponse, GetDefaultConfigurationsApiV1UserConfigurationsDefaultsGetData, GetDefaultConfigurationsApiV1UserConfigurationsDefaultsGetResponse, GetDocumentApiV1KnowledgeBaseDocumentsDocumentUuidGetData, GetDocumentApiV1KnowledgeBaseDocumentsDocumentUuidGetError, GetDocumentApiV1KnowledgeBaseDocumentsDocumentUuidGetResponse, GetEmbedConfigApiV1PublicEmbedConfigTokenGetData, GetEmbedConfigApiV1PublicEmbedConfigTokenGetError, GetEmbedConfigApiV1PublicEmbedConfigTokenGetResponse, GetEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenGetData, GetEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenGetError, GetEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenGetResponse, GetFileMetadataApiV1S3FileMetadataGetData, GetFileMetadataApiV1S3FileMetadataGetError, GetFileMetadataApiV1S3FileMetadataGetResponse, GetIntegrationAccessTokenApiV1IntegrationIntegrationIdAccessTokenGetData, GetIntegrationAccessTokenApiV1IntegrationIntegrationIdAccessTokenGetError, GetIntegrationAccessTokenApiV1IntegrationIntegrationIdAccessTokenGetResponse, GetIntegrationsApiV1IntegrationGetData, GetIntegrationsApiV1IntegrationGetError, GetIntegrationsApiV1IntegrationGetResponse, GetLoadTestStatsApiV1LooptalkLoadTestsLoadTestGroupIdStatsGetData, GetLoadTestStatsApiV1LooptalkLoadTestsLoadTestGroupIdStatsGetError, GetLoadTestStatsApiV1LooptalkLoadTestsLoadTestGroupIdStatsGetResponse, GetPresignedUploadUrlApiV1S3PresignedUploadUrlPostData, GetPresignedUploadUrlApiV1S3PresignedUploadUrlPostError, GetPresignedUploadUrlApiV1S3PresignedUploadUrlPostResponse, GetPublicTurnCredentialsApiV1PublicEmbedTurnCredentialsSessionTokenGetData, GetPublicTurnCredentialsApiV1PublicEmbedTurnCredentialsSessionTokenGetError, GetPublicTurnCredentialsApiV1PublicEmbedTurnCredentialsSessionTokenGetResponse, GetServiceKeysApiV1UserServiceKeysGetData, GetServiceKeysApiV1UserServiceKeysGetError, GetServiceKeysApiV1UserServiceKeysGetResponse, GetSignedUrlApiV1S3SignedUrlGetData, GetSignedUrlApiV1S3SignedUrlGetError, GetSignedUrlApiV1S3SignedUrlGetResponse, GetTelephonyConfigurationApiV1OrganizationsTelephonyConfigGetData, GetTelephonyConfigurationApiV1OrganizationsTelephonyConfigGetError, GetTelephonyConfigurationApiV1OrganizationsTelephonyConfigGetResponse, GetTestSessionApiV1LooptalkTestSessionsTestSessionIdGetData, GetTestSessionApiV1LooptalkTestSessionsTestSessionIdGetError, GetTestSessionApiV1LooptalkTestSessionsTestSessionIdGetResponse, GetTestSessionConversationApiV1LooptalkTestSessionsTestSessionIdConversationGetData, GetTestSessionConversationApiV1LooptalkTestSessionsTestSessionIdConversationGetError, GetToolApiV1ToolsToolUuidGetData, GetToolApiV1ToolsToolUuidGetError, GetToolApiV1ToolsToolUuidGetResponse, GetTurnCredentialsApiV1TurnCredentialsGetData, GetTurnCredentialsApiV1TurnCredentialsGetError, GetTurnCredentialsApiV1TurnCredentialsGetResponse, GetUploadUrlApiV1KnowledgeBaseUploadUrlPostData, GetUploadUrlApiV1KnowledgeBaseUploadUrlPostError, GetUploadUrlApiV1KnowledgeBaseUploadUrlPostResponse, GetUsageHistoryApiV1OrganizationsUsageRunsGetData, GetUsageHistoryApiV1OrganizationsUsageRunsGetError, GetUsageHistoryApiV1OrganizationsUsageRunsGetResponse, GetUserConfigurationsApiV1UserConfigurationsUserGetData, GetUserConfigurationsApiV1UserConfigurationsUserGetError, GetUserConfigurationsApiV1UserConfigurationsUserGetResponse, GetVoicesApiV1UserConfigurationsVoicesProviderGetData, GetVoicesApiV1UserConfigurationsVoicesProviderGetError, GetVoicesApiV1UserConfigurationsVoicesProviderGetResponse, GetWorkflowApiV1WorkflowFetchWorkflowIdGetData, GetWorkflowApiV1WorkflowFetchWorkflowIdGetError, GetWorkflowApiV1WorkflowFetchWorkflowIdGetResponse, GetWorkflowCountApiV1WorkflowCountGetData, GetWorkflowCountApiV1WorkflowCountGetError, GetWorkflowCountApiV1WorkflowCountGetResponse, GetWorkflowOptionsApiV1OrganizationsReportsWorkflowsGetData, GetWorkflowOptionsApiV1OrganizationsReportsWorkflowsGetError, GetWorkflowOptionsApiV1OrganizationsReportsWorkflowsGetResponse, GetWorkflowRunApiV1WorkflowWorkflowIdRunsRunIdGetData, GetWorkflowRunApiV1WorkflowWorkflowIdRunsRunIdGetError, GetWorkflowRunApiV1WorkflowWorkflowIdRunsRunIdGetResponse, GetWorkflowRunsApiV1SuperuserWorkflowRunsGetData, GetWorkflowRunsApiV1SuperuserWorkflowRunsGetError, GetWorkflowRunsApiV1SuperuserWorkflowRunsGetResponse, GetWorkflowRunsApiV1WorkflowWorkflowIdRunsGetData, GetWorkflowRunsApiV1WorkflowWorkflowIdRunsGetError, GetWorkflowRunsApiV1WorkflowWorkflowIdRunsGetResponse, GetWorkflowsApiV1WorkflowFetchGetData, GetWorkflowsApiV1WorkflowFetchGetError, GetWorkflowsApiV1WorkflowFetchGetResponse, GetWorkflowsSummaryApiV1WorkflowSummaryGetData, GetWorkflowsSummaryApiV1WorkflowSummaryGetError, GetWorkflowsSummaryApiV1WorkflowSummaryGetResponse, GetWorkflowTemplatesApiV1WorkflowTemplatesGetData, GetWorkflowTemplatesApiV1WorkflowTemplatesGetResponse, HandleCloudonixCdrApiV1TelephonyCloudonixCdrPostData, HandleCloudonixStatusCallbackApiV1TelephonyCloudonixStatusCallbackWorkflowRunIdPostData, HandleCloudonixStatusCallbackApiV1TelephonyCloudonixStatusCallbackWorkflowRunIdPostError, HandleInboundFallbackApiV1TelephonyInboundFallbackPostData, HandleInboundTelephonyApiV1TelephonyInboundWorkflowIdPostData, HandleInboundTelephonyApiV1TelephonyInboundWorkflowIdPostError, HandleTwilioStatusCallbackApiV1TelephonyTwilioStatusCallbackWorkflowRunIdPostData, HandleTwilioStatusCallbackApiV1TelephonyTwilioStatusCallbackWorkflowRunIdPostError, HandleVobizHangupCallbackApiV1TelephonyVobizHangupCallbackWorkflowRunIdPostData, HandleVobizHangupCallbackApiV1TelephonyVobizHangupCallbackWorkflowRunIdPostError, HandleVobizHangupCallbackByWorkflowApiV1TelephonyVobizHangupCallbackWorkflowWorkflowIdPostData, HandleVobizHangupCallbackByWorkflowApiV1TelephonyVobizHangupCallbackWorkflowWorkflowIdPostError, HandleVobizRingCallbackApiV1TelephonyVobizRingCallbackWorkflowRunIdPostData, HandleVobizRingCallbackApiV1TelephonyVobizRingCallbackWorkflowRunIdPostError, HandleVonageEventsApiV1TelephonyVonageEventsWorkflowRunIdPostData, HandleVonageEventsApiV1TelephonyVonageEventsWorkflowRunIdPostError, HealthApiV1HealthGetData, HealthApiV1HealthGetResponse,ImpersonateApiV1SuperuserImpersonatePostData, ImpersonateApiV1SuperuserImpersonatePostError, ImpersonateApiV1SuperuserImpersonatePostResponse, InitializeEmbedSessionApiV1PublicEmbedInitPostData, InitializeEmbedSessionApiV1PublicEmbedInitPostError, InitializeEmbedSessionApiV1PublicEmbedInitPostResponse, InitiateCallApiV1PublicAgentUuidPostData, InitiateCallApiV1PublicAgentUuidPostError, InitiateCallApiV1PublicAgentUuidPostResponse, InitiateCallApiV1TelephonyInitiateCallPostData, InitiateCallApiV1TelephonyInitiateCallPostError, InitiateCallTransferApiV1TelephonyCallTransferPostData, InitiateCallTransferApiV1TelephonyCallTransferPostError, ListCredentialsApiV1CredentialsGetData, ListCredentialsApiV1CredentialsGetError, ListCredentialsApiV1CredentialsGetResponse, ListDocumentsApiV1KnowledgeBaseDocumentsGetData, ListDocumentsApiV1KnowledgeBaseDocumentsGetError, ListDocumentsApiV1KnowledgeBaseDocumentsGetResponse, ListTestSessionsApiV1LooptalkTestSessionsGetData, ListTestSessionsApiV1LooptalkTestSessionsGetError, ListTestSessionsApiV1LooptalkTestSessionsGetResponse, ListToolsApiV1ToolsGetData, ListToolsApiV1ToolsGetError, ListToolsApiV1ToolsGetResponse, OptionsConfigApiV1PublicEmbedConfigTokenOptionsData, OptionsConfigApiV1PublicEmbedConfigTokenOptionsError, OptionsInitApiV1PublicEmbedInitOptionsData, OptionsTurnCredentialsApiV1PublicEmbedTurnCredentialsSessionTokenOptionsData, OptionsTurnCredentialsApiV1PublicEmbedTurnCredentialsSessionTokenOptionsError, PauseCampaignApiV1CampaignCampaignIdPausePostData, PauseCampaignApiV1CampaignCampaignIdPausePostError, PauseCampaignApiV1CampaignCampaignIdPausePostResponse, ProcessDocumentApiV1KnowledgeBaseProcessDocumentPostData, ProcessDocumentApiV1KnowledgeBaseProcessDocumentPostError, ProcessDocumentApiV1KnowledgeBaseProcessDocumentPostResponse, ReactivateApiKeyApiV1UserApiKeysApiKeyIdReactivatePutData, ReactivateApiKeyApiV1UserApiKeysApiKeyIdReactivatePutError, ReactivateApiKeyApiV1UserApiKeysApiKeyIdReactivatePutResponse, ReactivateServiceKeyApiV1UserServiceKeysServiceKeyIdReactivatePutData, ReactivateServiceKeyApiV1UserServiceKeysServiceKeyIdReactivatePutError, ResumeCampaignApiV1CampaignCampaignIdResumePostData, ResumeCampaignApiV1CampaignCampaignIdResumePostError, ResumeCampaignApiV1CampaignCampaignIdResumePostResponse, SaveTelephonyConfigurationApiV1OrganizationsTelephonyConfigPostData, SaveTelephonyConfigurationApiV1OrganizationsTelephonyConfigPostError, SearchChunksApiV1KnowledgeBaseSearchPostData, SearchChunksApiV1KnowledgeBaseSearchPostError, SearchChunksApiV1KnowledgeBaseSearchPostResponse, SetAdminCommentApiV1SuperuserWorkflowRunsRunIdCommentPostData, SetAdminCommentApiV1SuperuserWorkflowRunsRunIdCommentPostError, SetAdminCommentApiV1SuperuserWorkflowRunsRunIdCommentPostResponse, StartCampaignApiV1CampaignCampaignIdStartPostData, StartCampaignApiV1CampaignCampaignIdStartPostError, StartCampaignApiV1CampaignCampaignIdStartPostResponse, StartTestSessionApiV1LooptalkTestSessionsTestSessionIdStartPostData, StartTestSessionApiV1LooptalkTestSessionsTestSessionIdStartPostError, StopTestSessionApiV1LooptalkTestSessionsTestSessionIdStopPostData, StopTestSessionApiV1LooptalkTestSessionsTestSessionIdStopPostError, UnarchiveToolApiV1ToolsToolUuidUnarchivePostData, UnarchiveToolApiV1ToolsToolUuidUnarchivePostError, UnarchiveToolApiV1ToolsToolUuidUnarchivePostResponse, UpdateCampaignApiV1CampaignCampaignIdPatchData, UpdateCampaignApiV1CampaignCampaignIdPatchError, UpdateCampaignApiV1CampaignCampaignIdPatchResponse, UpdateCredentialApiV1CredentialsCredentialUuidPutData, UpdateCredentialApiV1CredentialsCredentialUuidPutError, UpdateCredentialApiV1CredentialsCredentialUuidPutResponse, UpdateIntegrationApiV1IntegrationIntegrationIdPutData, UpdateIntegrationApiV1IntegrationIntegrationIdPutError, UpdateIntegrationApiV1IntegrationIntegrationIdPutResponse, UpdateToolApiV1ToolsToolUuidPutData, UpdateToolApiV1ToolsToolUuidPutError, UpdateToolApiV1ToolsToolUuidPutResponse, UpdateUserConfigurationsApiV1UserConfigurationsUserPutData, UpdateUserConfigurationsApiV1UserConfigurationsUserPutError, UpdateUserConfigurationsApiV1UserConfigurationsUserPutResponse, UpdateWorkflowApiV1WorkflowWorkflowIdPutData, UpdateWorkflowApiV1WorkflowWorkflowIdPutError, UpdateWorkflowApiV1WorkflowWorkflowIdPutResponse, UpdateWorkflowStatusApiV1WorkflowWorkflowIdStatusPutData, UpdateWorkflowStatusApiV1WorkflowWorkflowIdStatusPutError, UpdateWorkflowStatusApiV1WorkflowWorkflowIdStatusPutResponse, ValidateUserConfigurationsApiV1UserConfigurationsUserValidateGetData, ValidateUserConfigurationsApiV1UserConfigurationsUserValidateGetError, ValidateUserConfigurationsApiV1UserConfigurationsUserValidateGetResponse, ValidateWorkflowApiV1WorkflowWorkflowIdValidatePostData, ValidateWorkflowApiV1WorkflowWorkflowIdValidatePostError, ValidateWorkflowApiV1WorkflowWorkflowIdValidatePostResponse } from './types.gen'; export type Options = ClientOptions & { /** @@ -627,6 +627,21 @@ export const getCampaignApiV1CampaignCampaignIdGet = (options: Options) => { + return (options.client ?? _heyApiClient).patch({ + url: '/api/v1/campaign/{campaign_id}', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }); +}; + /** * Start Campaign * Start campaign execution diff --git a/ui/src/client/types.gen.ts b/ui/src/client/types.gen.ts index 8e1a683..2ecfad1 100644 --- a/ui/src/client/types.gen.ts +++ b/ui/src/client/types.gen.ts @@ -124,6 +124,7 @@ export type CampaignResponse = { completed_at: string | null; retry_config: RetryConfigResponse; max_concurrency?: number | null; + schedule_config?: ScheduleConfigResponse | null; }; /** @@ -244,6 +245,7 @@ export type CreateCampaignRequest = { source_id: string; retry_config?: RetryConfigRequest | null; max_concurrency?: number | null; + schedule_config?: ScheduleConfigRequest | null; }; /** @@ -777,6 +779,18 @@ export type S3SignedUrlResponse = { expires_in: number; }; +export type ScheduleConfigRequest = { + enabled?: boolean; + timezone?: string; + slots: Array; +}; + +export type ScheduleConfigResponse = { + enabled: boolean; + timezone: string; + slots: Array; +}; + export type ServiceKeyResponse = { name: string; id: number; @@ -862,6 +876,18 @@ export type TestSessionResponse = { completed_at: string | null; }; +export type TimeSlotRequest = { + day_of_week: number; + start_time: string; + end_time: string; +}; + +export type TimeSlotResponse = { + day_of_week: number; + start_time: string; + end_time: string; +}; + /** * A parameter that the tool accepts. */ @@ -1013,6 +1039,13 @@ export type TwilioConfigurationResponse = { from_numbers: Array; }; +export type UpdateCampaignRequest = { + name?: string | null; + retry_config?: RetryConfigRequest | null; + max_concurrency?: number | null; + schedule_config?: ScheduleConfigRequest | null; +}; + /** * Request schema for updating a webhook credential. */ @@ -2727,6 +2760,41 @@ export type GetCampaignApiV1CampaignCampaignIdGetResponses = { export type GetCampaignApiV1CampaignCampaignIdGetResponse = GetCampaignApiV1CampaignCampaignIdGetResponses[keyof GetCampaignApiV1CampaignCampaignIdGetResponses]; +export type UpdateCampaignApiV1CampaignCampaignIdPatchData = { + body: UpdateCampaignRequest; + headers?: { + authorization?: string | null; + 'X-API-Key'?: string | null; + }; + path: { + campaign_id: number; + }; + query?: never; + url: '/api/v1/campaign/{campaign_id}'; +}; + +export type UpdateCampaignApiV1CampaignCampaignIdPatchErrors = { + /** + * Not found + */ + 404: unknown; + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type UpdateCampaignApiV1CampaignCampaignIdPatchError = UpdateCampaignApiV1CampaignCampaignIdPatchErrors[keyof UpdateCampaignApiV1CampaignCampaignIdPatchErrors]; + +export type UpdateCampaignApiV1CampaignCampaignIdPatchResponses = { + /** + * Successful Response + */ + 200: CampaignResponse; +}; + +export type UpdateCampaignApiV1CampaignCampaignIdPatchResponse = UpdateCampaignApiV1CampaignCampaignIdPatchResponses[keyof UpdateCampaignApiV1CampaignCampaignIdPatchResponses]; + export type StartCampaignApiV1CampaignCampaignIdStartPostData = { body?: never; headers?: {