feat: add retry config during campaign creation

This commit is contained in:
Abhishek Kumar 2026-01-29 11:57:57 +05:30
parent db75d90535
commit 6f41e91f67
14 changed files with 1036 additions and 221 deletions

View file

@ -92,3 +92,13 @@ COUNTRY_CODES = {
"LU": "352", # Luxembourg "LU": "352", # Luxembourg
"IE": "353", # Ireland "IE": "353", # Ireland
} }
DEFAULT_ORG_CONCURRENCY_LIMIT = os.getenv("DEFAULT_ORG_CONCURRENCY_LIMIT", 2)
DEFAULT_CAMPAIGN_RETRY_CONFIG = {
"enabled": True,
"max_retries": 1,
"retry_delay_seconds": 120,
"retry_on_busy": True,
"retry_on_no_answer": True,
"retry_on_voicemail": False,
}

View file

@ -17,9 +17,16 @@ class CampaignClient(BaseDBClient):
source_id: str, source_id: str,
user_id: int, user_id: int,
organization_id: int, organization_id: int,
retry_config: Optional[dict] = None,
max_concurrency: Optional[int] = None,
) -> CampaignModel: ) -> CampaignModel:
"""Create a new campaign""" """Create a new campaign"""
async with self.async_session() as session: async with self.async_session() as session:
# Build orchestrator_metadata with max_concurrency if provided
orchestrator_metadata = {}
if max_concurrency is not None:
orchestrator_metadata["max_concurrency"] = max_concurrency
campaign = CampaignModel( campaign = CampaignModel(
name=name, name=name,
workflow_id=workflow_id, workflow_id=workflow_id,
@ -27,6 +34,10 @@ class CampaignClient(BaseDBClient):
source_id=source_id, source_id=source_id,
created_by=user_id, created_by=user_id,
organization_id=organization_id, organization_id=organization_id,
retry_config=retry_config
if retry_config
else CampaignModel.retry_config.default.arg,
orchestrator_metadata=orchestrator_metadata,
) )
session.add(campaign) session.add(campaign)
try: try:

View file

@ -22,6 +22,8 @@ from sqlalchemy import (
) )
from sqlalchemy.orm import declarative_base, relationship from sqlalchemy.orm import declarative_base, relationship
from api.constants import DEFAULT_CAMPAIGN_RETRY_CONFIG
from ..enums import ( from ..enums import (
CallType, CallType,
IntegrationAction, IntegrationAction,
@ -537,14 +539,7 @@ class CampaignModel(Base):
retry_config = Column( retry_config = Column(
JSON, JSON,
nullable=False, nullable=False,
default={ default=DEFAULT_CAMPAIGN_RETRY_CONFIG,
"enabled": True,
"max_retries": 2,
"retry_delay_seconds": 120,
"retry_on_busy": True,
"retry_on_no_answer": True,
"retry_on_voicemail": True,
},
server_default=text( server_default=text(
'\'{"enabled": true, "max_retries": 2, "retry_on_busy": true, "retry_on_no_answer": true, "retry_on_voicemail": true, "retry_delay_seconds": 120}\'::jsonb' '\'{"enabled": true, "max_retries": 2, "retry_on_busy": true, "retry_on_no_answer": true, "retry_on_voicemail": true, "retry_delay_seconds": 120}\'::jsonb'
), ),

View file

@ -4,22 +4,61 @@ from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from api.constants import DEFAULT_CAMPAIGN_RETRY_CONFIG, DEFAULT_ORG_CONCURRENCY_LIMIT
from api.db import db_client from api.db import db_client
from api.db.models import UserModel from api.db.models import UserModel
from api.enums import OrganizationConfigurationKey from api.enums import OrganizationConfigurationKey
from api.services.auth.depends import get_user from api.services.auth.depends import get_user
from api.services.campaign.runner import campaign_runner_service from api.services.campaign.runner import campaign_runner_service
from api.services.campaign.source_validator import (
validate_csv_source,
validate_google_sheet_source,
)
from api.services.quota_service import check_dograh_quota from api.services.quota_service import check_dograh_quota
from api.services.storage import storage_fs from api.services.storage import storage_fs
router = APIRouter(prefix="/campaign") router = APIRouter(prefix="/campaign")
async def _get_org_concurrent_limit(organization_id: int) -> int:
"""Get the concurrent call limit for an organization."""
try:
config = await db_client.get_configuration(
organization_id,
OrganizationConfigurationKey.CONCURRENT_CALL_LIMIT.value,
)
if config and config.value:
return int(config.value.get("value", DEFAULT_ORG_CONCURRENCY_LIMIT))
except Exception:
pass
return DEFAULT_ORG_CONCURRENCY_LIMIT
class RetryConfigRequest(BaseModel):
enabled: bool = True
max_retries: int = Field(default=2, ge=0, le=10)
retry_delay_seconds: int = Field(default=120, ge=30, le=3600)
retry_on_busy: bool = True
retry_on_no_answer: bool = True
retry_on_voicemail: bool = True
class RetryConfigResponse(BaseModel):
enabled: bool
max_retries: int
retry_delay_seconds: int
retry_on_busy: bool
retry_on_no_answer: bool
retry_on_voicemail: bool
class CreateCampaignRequest(BaseModel): class CreateCampaignRequest(BaseModel):
name: str = Field(..., min_length=1, max_length=255) name: str = Field(..., min_length=1, max_length=255)
workflow_id: int workflow_id: int
source_type: str = Field(..., pattern="^(google-sheet|csv)$") source_type: str = Field(..., pattern="^(google-sheet|csv)$")
source_id: str # Google Sheet URL or CSV file key 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)
class CampaignResponse(BaseModel): class CampaignResponse(BaseModel):
@ -36,6 +75,8 @@ class CampaignResponse(BaseModel):
created_at: datetime created_at: datetime
started_at: Optional[datetime] started_at: Optional[datetime]
completed_at: Optional[datetime] completed_at: Optional[datetime]
retry_config: RetryConfigResponse
max_concurrency: Optional[int] = None
class CampaignsResponse(BaseModel): class CampaignsResponse(BaseModel):
@ -63,26 +104,23 @@ class CampaignProgressResponse(BaseModel):
completed_at: Optional[datetime] completed_at: Optional[datetime]
@router.post("/create") # Default retry config for campaigns
async def create_campaign(
request: CreateCampaignRequest,
user: UserModel = Depends(get_user),
) -> CampaignResponse:
"""Create a new campaign"""
# Verify workflow exists and belongs to organization
workflow_name = await db_client.get_workflow_name(request.workflow_id, user.id)
if not workflow_name:
raise HTTPException(status_code=404, detail="Workflow not found")
campaign = await db_client.create_campaign(
name=request.name, def _build_campaign_response(campaign, workflow_name: str) -> CampaignResponse:
workflow_id=request.workflow_id, """Build a CampaignResponse from a campaign model."""
source_type=request.source_type, # Get retry_config from campaign or use defaults
source_id=request.source_id, retry_config = (
user_id=user.id, campaign.retry_config
organization_id=user.selected_organization_id, if campaign.retry_config
else DEFAULT_CAMPAIGN_RETRY_CONFIG
) )
# Get max_concurrency from orchestrator_metadata
max_concurrency = None
if campaign.orchestrator_metadata:
max_concurrency = campaign.orchestrator_metadata.get("max_concurrency")
return CampaignResponse( return CampaignResponse(
id=campaign.id, id=campaign.id,
name=campaign.name, name=campaign.name,
@ -97,9 +135,62 @@ async def create_campaign(
created_at=campaign.created_at, created_at=campaign.created_at,
started_at=campaign.started_at, started_at=campaign.started_at,
completed_at=campaign.completed_at, completed_at=campaign.completed_at,
retry_config=RetryConfigResponse(**retry_config),
max_concurrency=max_concurrency,
) )
@router.post("/create")
async def create_campaign(
request: CreateCampaignRequest,
user: UserModel = Depends(get_user),
) -> CampaignResponse:
"""Create a new campaign"""
# Verify workflow exists and belongs to organization
workflow_name = await db_client.get_workflow_name(request.workflow_id, user.id)
if not workflow_name:
raise HTTPException(status_code=404, detail="Workflow not found")
# Validate source data (phone_number column and format)
if request.source_type == "csv":
validation_result = await validate_csv_source(request.source_id)
if not validation_result.is_valid:
raise HTTPException(status_code=400, detail=validation_result.error.message)
elif request.source_type == "google-sheet":
validation_result = await validate_google_sheet_source(
request.source_id, user.selected_organization_id
)
if not validation_result.is_valid:
raise HTTPException(status_code=400, detail=validation_result.error.message)
# Validate max_concurrency against org limit if provided
if request.max_concurrency is not None:
org_limit = await _get_org_concurrent_limit(user.selected_organization_id)
if request.max_concurrency > org_limit:
raise HTTPException(
status_code=400,
detail=f"max_concurrency ({request.max_concurrency}) cannot exceed organization limit ({org_limit})",
)
# Build retry_config dict if provided
retry_config = None
if request.retry_config:
retry_config = request.retry_config.model_dump()
campaign = await db_client.create_campaign(
name=request.name,
workflow_id=request.workflow_id,
source_type=request.source_type,
source_id=request.source_id,
user_id=user.id,
organization_id=user.selected_organization_id,
retry_config=retry_config,
max_concurrency=request.max_concurrency,
)
return _build_campaign_response(campaign, workflow_name)
@router.get("/") @router.get("/")
async def get_campaigns( async def get_campaigns(
user: UserModel = Depends(get_user), user: UserModel = Depends(get_user),
@ -115,21 +206,7 @@ async def get_campaigns(
workflow_map = {w.id: w.name for w in workflows} workflow_map = {w.id: w.name for w in workflows}
campaign_responses = [ campaign_responses = [
CampaignResponse( _build_campaign_response(c, workflow_map.get(c.workflow_id, "Unknown"))
id=c.id,
name=c.name,
workflow_id=c.workflow_id,
workflow_name=workflow_map.get(c.workflow_id, "Unknown"),
state=c.state,
source_type=c.source_type,
source_id=c.source_id,
total_rows=c.total_rows,
processed_rows=c.processed_rows,
failed_rows=c.failed_rows,
created_at=c.created_at,
started_at=c.started_at,
completed_at=c.completed_at,
)
for c in campaigns for c in campaigns
] ]
@ -148,21 +225,7 @@ async def get_campaign(
workflow_name = await db_client.get_workflow_name(campaign.workflow_id, user.id) workflow_name = await db_client.get_workflow_name(campaign.workflow_id, user.id)
return CampaignResponse( return _build_campaign_response(campaign, workflow_name or "Unknown")
id=campaign.id,
name=campaign.name,
workflow_id=campaign.workflow_id,
workflow_name=workflow_name or "Unknown",
state=campaign.state,
source_type=campaign.source_type,
source_id=campaign.source_id,
total_rows=campaign.total_rows,
processed_rows=campaign.processed_rows,
failed_rows=campaign.failed_rows,
created_at=campaign.created_at,
started_at=campaign.started_at,
completed_at=campaign.completed_at,
)
@router.post("/{campaign_id}/start") @router.post("/{campaign_id}/start")
@ -203,21 +266,7 @@ async def start_campaign(
campaign = await db_client.get_campaign(campaign_id, user.selected_organization_id) 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) workflow_name = await db_client.get_workflow_name(campaign.workflow_id, user.id)
return CampaignResponse( return _build_campaign_response(campaign, workflow_name or "Unknown")
id=campaign.id,
name=campaign.name,
workflow_id=campaign.workflow_id,
workflow_name=workflow_name or "Unknown",
state=campaign.state,
source_type=campaign.source_type,
source_id=campaign.source_id,
total_rows=campaign.total_rows,
processed_rows=campaign.processed_rows,
failed_rows=campaign.failed_rows,
created_at=campaign.created_at,
started_at=campaign.started_at,
completed_at=campaign.completed_at,
)
@router.post("/{campaign_id}/pause") @router.post("/{campaign_id}/pause")
@ -241,21 +290,7 @@ async def pause_campaign(
campaign = await db_client.get_campaign(campaign_id, user.selected_organization_id) 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) workflow_name = await db_client.get_workflow_name(campaign.workflow_id, user.id)
return CampaignResponse( return _build_campaign_response(campaign, workflow_name or "Unknown")
id=campaign.id,
name=campaign.name,
workflow_id=campaign.workflow_id,
workflow_name=workflow_name or "Unknown",
state=campaign.state,
source_type=campaign.source_type,
source_id=campaign.source_id,
total_rows=campaign.total_rows,
processed_rows=campaign.processed_rows,
failed_rows=campaign.failed_rows,
created_at=campaign.created_at,
started_at=campaign.started_at,
completed_at=campaign.completed_at,
)
@router.get("/{campaign_id}/runs") @router.get("/{campaign_id}/runs")
@ -316,21 +351,7 @@ async def resume_campaign(
campaign = await db_client.get_campaign(campaign_id, user.selected_organization_id) 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) workflow_name = await db_client.get_workflow_name(campaign.workflow_id, user.id)
return CampaignResponse( return _build_campaign_response(campaign, workflow_name or "Unknown")
id=campaign.id,
name=campaign.name,
workflow_id=campaign.workflow_id,
workflow_name=workflow_name or "Unknown",
state=campaign.state,
source_type=campaign.source_type,
source_id=campaign.source_id,
total_rows=campaign.total_rows,
processed_rows=campaign.processed_rows,
failed_rows=campaign.failed_rows,
created_at=campaign.created_at,
started_at=campaign.started_at,
completed_at=campaign.completed_at,
)
@router.get("/{campaign_id}/progress") @router.get("/{campaign_id}/progress")

View file

@ -1,7 +1,9 @@
from typing import Union from typing import Union
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from api.constants import DEFAULT_CAMPAIGN_RETRY_CONFIG, DEFAULT_ORG_CONCURRENCY_LIMIT
from api.db import db_client from api.db import db_client
from api.db.models import UserModel from api.db.models import UserModel
from api.enums import OrganizationConfigurationKey from api.enums import OrganizationConfigurationKey
@ -210,3 +212,46 @@ def preserve_masked_fields(request, existing_config, config_value):
field_value, existing_config.value.get(field_name, "") field_value, existing_config.value.get(field_name, "")
): ):
config_value[field_name] = existing_config.value[field_name] config_value[field_name] = existing_config.value[field_name]
class RetryConfigResponse(BaseModel):
enabled: bool
max_retries: int
retry_delay_seconds: int
retry_on_busy: bool
retry_on_no_answer: bool
retry_on_voicemail: bool
class CampaignLimitsResponse(BaseModel):
concurrent_call_limit: int
default_retry_config: RetryConfigResponse
@router.get("/campaign-limits", response_model=CampaignLimitsResponse)
async def get_campaign_limits(user: UserModel = Depends(get_user)):
"""Get campaign limits for the user's organization.
Returns the organization's concurrent call limit and default retry configuration.
"""
if not user.selected_organization_id:
raise HTTPException(status_code=400, detail="No organization selected")
# Get concurrent call limit
concurrent_limit = DEFAULT_ORG_CONCURRENCY_LIMIT
try:
config = await db_client.get_configuration(
user.selected_organization_id,
OrganizationConfigurationKey.CONCURRENT_CALL_LIMIT.value,
)
if config and config.value:
concurrent_limit = int(
config.value.get("value", DEFAULT_ORG_CONCURRENCY_LIMIT)
)
except Exception:
pass
return CampaignLimitsResponse(
concurrent_call_limit=concurrent_limit,
default_retry_config=RetryConfigResponse(**DEFAULT_CAMPAIGN_RETRY_CONFIG),
)

View file

@ -5,6 +5,7 @@ from typing import Optional
from loguru import logger from loguru import logger
from api.constants import DEFAULT_ORG_CONCURRENCY_LIMIT
from api.db import db_client from api.db import db_client
from api.db.models import QueuedRunModel, WorkflowRunModel from api.db.models import QueuedRunModel, WorkflowRunModel
from api.enums import OrganizationConfigurationKey, WorkflowRunState from api.enums import OrganizationConfigurationKey, WorkflowRunState
@ -18,7 +19,7 @@ class CampaignCallDispatcher:
"""Manages rate-limited and concurrent-limited call dispatching""" """Manages rate-limited and concurrent-limited call dispatching"""
def __init__(self): def __init__(self):
self.default_concurrent_limit = 20 self.default_concurrent_limit = int(DEFAULT_ORG_CONCURRENCY_LIMIT)
async def get_telephony_provider(self, organization_id: int) -> TelephonyProvider: async def get_telephony_provider(self, organization_id: int) -> TelephonyProvider:
"""Get telephony provider instance for specific organization""" """Get telephony provider instance for specific organization"""
@ -132,7 +133,22 @@ class CampaignCallDispatcher:
) -> Optional[WorkflowRunModel]: ) -> Optional[WorkflowRunModel]:
"""Creates workflow run and initiates call with concurrent limiting""" """Creates workflow run and initiates call with concurrent limiting"""
# Get concurrent limit for organization # Get concurrent limit for organization
max_concurrent = await self.get_org_concurrent_limit(campaign.organization_id) org_concurrent_limit = await self.get_org_concurrent_limit(
campaign.organization_id
)
# Check for campaign-level max_concurrency in orchestrator_metadata
campaign_max_concurrency = None
if campaign.orchestrator_metadata:
campaign_max_concurrency = campaign.orchestrator_metadata.get(
"max_concurrency"
)
# Use the lower of campaign limit and org limit
if campaign_max_concurrency is not None:
max_concurrent = min(campaign_max_concurrency, org_concurrent_limit)
else:
max_concurrent = org_concurrent_limit
# Track wait time for alerting # Track wait time for alerting
wait_start = time.time() wait_start = time.time()

View file

@ -0,0 +1,265 @@
"""
Source validation for campaign data sources (CSV, Google Sheets).
Validates that:
- phone_number column exists
- All phone numbers include country code (start with '+')
"""
import csv
from dataclasses import dataclass
from io import StringIO
from typing import List, Optional
import httpx
from loguru import logger
from api.services.storage import storage_fs
@dataclass
class ValidationError:
"""Represents a validation error with details."""
message: str
invalid_rows: Optional[List[int]] = None
@dataclass
class ValidationResult:
"""Result of source validation."""
is_valid: bool
error: Optional[ValidationError] = None
def _validate_source_data(
headers: List[str], rows: List[List[str]]
) -> ValidationResult:
"""
Validate source data for campaign creation.
Args:
headers: List of column headers
rows: List of data rows (excluding header)
Returns:
ValidationResult with is_valid=True if valid, or error details if invalid
"""
# Normalize headers to lowercase for comparison
normalized_headers = [h.strip().lower() for h in headers]
# Check for phone_number column
if "phone_number" not in normalized_headers:
return ValidationResult(
is_valid=False,
error=ValidationError(
message="Source must contain a 'phone_number' column"
),
)
phone_number_idx = normalized_headers.index("phone_number")
# Validate phone numbers in all data rows
invalid_rows = []
for row_idx, row in enumerate(rows, start=2): # Start at 2 (1-indexed, skip header)
if len(row) <= phone_number_idx:
continue # Skip rows that don't have enough columns
phone_number = row[phone_number_idx].strip()
if phone_number and not phone_number.startswith("+"):
invalid_rows.append(row_idx)
if invalid_rows:
# Limit the number of rows shown in error message
if len(invalid_rows) > 5:
rows_str = f"{', '.join(map(str, invalid_rows[:5]))} and {len(invalid_rows) - 5} more"
else:
rows_str = ", ".join(map(str, invalid_rows))
return ValidationResult(
is_valid=False,
error=ValidationError(
message=f"Invalid phone numbers in rows: {rows_str}. All phone numbers must include country code (start with '+')",
invalid_rows=invalid_rows,
),
)
return ValidationResult(is_valid=True)
async def validate_csv_source(file_key: str) -> ValidationResult:
"""
Validate a CSV source file for campaign creation.
Args:
file_key: S3/MinIO file key for the CSV file
Returns:
ValidationResult with is_valid=True if valid, or error details if invalid
"""
# Get download URL using internal endpoint
signed_url = await storage_fs.aget_signed_url(
file_key, expiration=3600, use_internal_endpoint=True
)
if not signed_url:
return ValidationResult(
is_valid=False,
error=ValidationError(message=f"Failed to access CSV file: {file_key}"),
)
# Download CSV file
async with httpx.AsyncClient() as client:
try:
response = await client.get(signed_url)
response.raise_for_status()
csv_content = response.text
except httpx.HTTPError as e:
logger.error(f"Failed to download CSV file for validation: {e}")
return ValidationResult(
is_valid=False,
error=ValidationError(
message="Failed to download CSV file for validation"
),
)
# Parse CSV
try:
csv_file = StringIO(csv_content)
reader = csv.reader(csv_file)
rows = list(reader)
except Exception as e:
logger.error(f"Failed to parse CSV: {e}")
return ValidationResult(
is_valid=False,
error=ValidationError(message=f"Invalid CSV format: {str(e)}"),
)
if not rows or len(rows) < 2:
return ValidationResult(
is_valid=False,
error=ValidationError(
message="CSV file must have a header row and at least one data row"
),
)
headers = rows[0]
data_rows = rows[1:]
return _validate_source_data(headers, data_rows)
async def validate_google_sheet_source(
sheet_url: str, organization_id: int
) -> ValidationResult:
"""
Validate a Google Sheet source for campaign creation.
Args:
sheet_url: Google Sheets URL
organization_id: Organization ID to get integration credentials
Returns:
ValidationResult with is_valid=True if valid, or error details if invalid
"""
import re
from api.db import db_client
from api.services.integrations.nango import NangoService
# Extract sheet ID from URL
pattern = r"/spreadsheets/d/([a-zA-Z0-9-_]+)"
match = re.search(pattern, sheet_url)
if not match:
return ValidationResult(
is_valid=False,
error=ValidationError(message=f"Invalid Google Sheets URL: {sheet_url}"),
)
sheet_id = match.group(1)
# Get Google Sheets integration for the organization
integrations = await db_client.get_integrations_by_organization_id(organization_id)
integration = None
for intg in integrations:
if intg.provider == "google-sheet" and intg.is_active:
integration = intg
break
if not integration:
return ValidationResult(
is_valid=False,
error=ValidationError(
message="Google Sheets integration not found or inactive"
),
)
# Get OAuth token via Nango
try:
nango_service = NangoService()
token_data = await nango_service.get_access_token(
connection_id=integration.integration_id, provider_config_key="google-sheet"
)
access_token = token_data["credentials"]["access_token"]
except Exception as e:
logger.error(f"Failed to get Google Sheets access token: {e}")
return ValidationResult(
is_valid=False,
error=ValidationError(message="Failed to authenticate with Google Sheets"),
)
# Fetch sheet data
sheets_api_base = "https://sheets.googleapis.com/v4/spreadsheets"
async with httpx.AsyncClient() as client:
try:
# Get sheet metadata to find the first sheet name
metadata_url = f"{sheets_api_base}/{sheet_id}"
headers = {"Authorization": f"Bearer {access_token}"}
response = await client.get(metadata_url, headers=headers)
response.raise_for_status()
metadata = response.json()
if not metadata.get("sheets"):
return ValidationResult(
is_valid=False,
error=ValidationError(message="No sheets found in the spreadsheet"),
)
sheet_name = metadata["sheets"][0]["properties"]["title"]
# Fetch all data from sheet
data_url = f"{sheets_api_base}/{sheet_id}/values/{sheet_name}!A:Z"
response = await client.get(data_url, headers=headers)
response.raise_for_status()
data = response.json()
rows = data.get("values", [])
except httpx.HTTPStatusError as e:
logger.error(f"HTTP error fetching Google Sheet: {e.response.status_code}")
return ValidationResult(
is_valid=False,
error=ValidationError(
message=f"Failed to fetch Google Sheet data: {e.response.status_code}"
),
)
except Exception as e:
logger.error(f"Error fetching Google Sheet: {e}")
return ValidationResult(
is_valid=False,
error=ValidationError(message="Failed to fetch Google Sheet data"),
)
if not rows or len(rows) < 2:
return ValidationResult(
is_valid=False,
error=ValidationError(
message="Google Sheet must have a header row and at least one data row"
),
)
headers = rows[0]
data_rows = rows[1:]
return _validate_source_data(headers, data_rows)

View file

@ -132,7 +132,9 @@ export default function CsvUploadSelector({ accessToken, onFileUploaded, selecte
)} )}
</div> </div>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Upload a CSV file with contact data. Must include phone_number, first_name, and last_name columns. Max 10MB. Upload a CSV file with contact data. Must include phone_number column.
The columns can be accessed as initial_context in the workflow nodes. <br/>
Max 10MB.
</p> </p>
</div> </div>
); );

View file

@ -1,6 +1,6 @@
"use client"; "use client";
import { ArrowLeft, Pause, Play, RefreshCw } from 'lucide-react'; import { ArrowLeft, Check, Pause, Play, RefreshCw, X } from 'lucide-react';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
@ -16,6 +16,7 @@ import type { CampaignResponse, WorkflowRunResponse } from '@/client/types.gen';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator';
import { import {
Table, Table,
TableBody, TableBody,
@ -425,6 +426,76 @@ export default function CampaignDetailPage() {
</CardContent> </CardContent>
</Card> </Card>
{/* Campaign Settings */}
<Card className="mb-6">
<CardHeader>
<CardTitle>Campaign Settings</CardTitle>
<CardDescription>
Concurrency and retry configuration
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Concurrency Setting */}
<div>
<dt className="text-sm font-medium">Max Concurrent Calls</dt>
<dd className="mt-1">
{campaign.max_concurrency ? (
<span>{campaign.max_concurrency}</span>
) : (
<span className="text-muted-foreground">Using organization default</span>
)}
</dd>
</div>
<Separator />
{/* Retry Configuration */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Retries Enabled</span>
{campaign.retry_config.enabled ? (
<Badge variant="default" className="flex items-center gap-1">
<Check className="h-3 w-3" />
Enabled
</Badge>
) : (
<Badge variant="secondary" className="flex items-center gap-1">
<X className="h-3 w-3" />
Disabled
</Badge>
)}
</div>
{campaign.retry_config.enabled && (
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 pl-4 border-l-2 border-muted">
<div>
<dt className="text-sm text-muted-foreground">Max Retries</dt>
<dd className="mt-1 font-medium">{campaign.retry_config.max_retries}</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">Retry Delay</dt>
<dd className="mt-1 font-medium">{campaign.retry_config.retry_delay_seconds}s</dd>
</div>
<div className="col-span-2 md:col-span-1">
<dt className="text-sm text-muted-foreground">Retry On</dt>
<dd className="mt-1 flex flex-wrap gap-1">
{campaign.retry_config.retry_on_busy && (
<Badge variant="outline" className="text-xs">Busy</Badge>
)}
{campaign.retry_config.retry_on_no_answer && (
<Badge variant="outline" className="text-xs">No Answer</Badge>
)}
{campaign.retry_config.retry_on_voicemail && (
<Badge variant="outline" className="text-xs">Voicemail</Badge>
)}
</dd>
</div>
</div>
)}
</div>
</CardContent>
</Card>
{/* Workflow Runs */} {/* Workflow Runs */}
<Card> <Card>
<CardHeader> <CardHeader>

View file

@ -1,14 +1,19 @@
"use client"; "use client";
import { ArrowLeft } from 'lucide-react'; import { ArrowLeft, ChevronDown, ChevronRight } from 'lucide-react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { createCampaignApiV1CampaignCreatePost, getWorkflowsSummaryApiV1WorkflowSummaryGet } from '@/client/sdk.gen'; import {
createCampaignApiV1CampaignCreatePost,
getCampaignLimitsApiV1OrganizationsCampaignLimitsGet,
getWorkflowsSummaryApiV1WorkflowSummaryGet
} from '@/client/sdk.gen';
import type { WorkflowSummaryResponse } from '@/client/types.gen'; import type { WorkflowSummaryResponse } from '@/client/types.gen';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { import {
@ -18,6 +23,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { useAuth } from '@/lib/auth'; import { useAuth } from '@/lib/auth';
import CsvUploadSelector from '../CsvUploadSelector'; import CsvUploadSelector from '../CsvUploadSelector';
@ -34,12 +40,25 @@ export default function NewCampaignPage() {
const [sourceId, setSourceId] = useState(''); const [sourceId, setSourceId] = useState('');
const [selectedFileName, setSelectedFileName] = useState(''); const [selectedFileName, setSelectedFileName] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [createError, setCreateError] = useState<string | null>(null);
const [userAccessToken, setUserAccessToken] = useState<string>(''); const [userAccessToken, setUserAccessToken] = useState<string>('');
// Workflows state // Workflows state
const [workflows, setWorkflows] = useState<WorkflowSummaryResponse[]>([]); const [workflows, setWorkflows] = useState<WorkflowSummaryResponse[]>([]);
const [isLoadingWorkflows, setIsLoadingWorkflows] = useState(true); const [isLoadingWorkflows, setIsLoadingWorkflows] = useState(true);
// Advanced settings state
const [showAdvancedSettings, setShowAdvancedSettings] = useState(false);
const [orgConcurrentLimit, setOrgConcurrentLimit] = useState<number>(2);
const [maxConcurrency, setMaxConcurrency] = useState<string>('');
// Retry config state
const [retryEnabled, setRetryEnabled] = useState(true);
const [maxRetries, setMaxRetries] = useState<string>('2');
const [retryDelaySeconds, setRetryDelaySeconds] = useState<string>('120');
const [retryOnBusy, setRetryOnBusy] = useState(true);
const [retryOnNoAnswer, setRetryOnNoAnswer] = useState(true);
const [retryOnVoicemail, setRetryOnVoicemail] = useState(true);
// Redirect if not authenticated // Redirect if not authenticated
useEffect(() => { useEffect(() => {
if (!loading && !user) { if (!loading && !user) {
@ -70,45 +89,111 @@ export default function NewCampaignPage() {
} }
}, [user, getAccessToken]); }, [user, getAccessToken]);
// Initial load // Fetch campaign limits
useEffect(() => { const fetchCampaignLimits = useCallback(async () => {
if (user) { if (!user) return;
fetchWorkflows();
}
}, [fetchWorkflows, user]);
// Handle form submission
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!campaignName || !selectedWorkflowId || !sourceId) {
toast.error('Please fill in all fields');
return;
}
setIsSubmitting(true);
try { try {
const accessToken = await getAccessToken(); const accessToken = await getAccessToken();
const response = await createCampaignApiV1CampaignCreatePost({ const response = await getCampaignLimitsApiV1OrganizationsCampaignLimitsGet({
body: {
name: campaignName,
workflow_id: parseInt(selectedWorkflowId),
source_type: sourceType,
source_id: sourceId,
},
headers: { headers: {
'Authorization': `Bearer ${accessToken}`, 'Authorization': `Bearer ${accessToken}`,
} }
}); });
if (response.data) {
setOrgConcurrentLimit(response.data.concurrent_call_limit);
// Initialize retry config from defaults
const retryConfig = response.data.default_retry_config;
setRetryEnabled(retryConfig.enabled);
setMaxRetries(String(retryConfig.max_retries));
setRetryDelaySeconds(String(retryConfig.retry_delay_seconds));
setRetryOnBusy(retryConfig.retry_on_busy);
setRetryOnNoAnswer(retryConfig.retry_on_no_answer);
setRetryOnVoicemail(retryConfig.retry_on_voicemail);
}
} catch (error) {
console.error('Failed to fetch campaign limits:', error);
}
}, [user, getAccessToken]);
// Initial load
useEffect(() => {
if (user) {
fetchWorkflows();
fetchCampaignLimits();
}
}, [fetchWorkflows, fetchCampaignLimits, user]);
// Handle form submission
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setCreateError(null);
if (!campaignName || !selectedWorkflowId || !sourceId) {
toast.error('Please fill in all fields');
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 > orgConcurrentLimit) {
toast.error(`Max concurrent calls cannot exceed organization limit (${orgConcurrentLimit})`);
return;
}
}
setIsSubmitting(true);
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,
retry_delay_seconds: parseInt(retryDelaySeconds) || 120,
retry_on_busy: retryOnBusy,
retry_on_no_answer: retryOnNoAnswer,
retry_on_voicemail: retryOnVoicemail,
};
const response = await createCampaignApiV1CampaignCreatePost({
body: {
name: campaignName,
workflow_id: parseInt(selectedWorkflowId),
source_type: sourceType,
source_id: sourceId,
retry_config: retryConfig,
max_concurrency: maxConcurrencyValue,
},
headers: {
'Authorization': `Bearer ${accessToken}`,
}
});
if (response.error) {
// Extract error message from API response
const errorDetail = (response.error as { detail?: string })?.detail;
const errorMessage = errorDetail || 'Failed to create campaign';
setCreateError(errorMessage);
toast.error(errorMessage);
return;
}
if (response.data) { if (response.data) {
toast.success('Campaign created successfully'); toast.success('Campaign created successfully');
router.push(`/campaigns/${response.data.id}`); router.push(`/campaigns/${response.data.id}`);
} }
} catch (error) { } catch (error: unknown) {
console.error('Failed to create campaign:', error); console.error('Failed to create campaign:', error);
toast.error('Failed to create campaign'); const errorMessage = 'Failed to create campaign';
setCreateError(errorMessage);
toast.error(errorMessage);
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
} }
@ -131,7 +216,7 @@ export default function NewCampaignPage() {
}; };
return ( return (
<div className="container mx-auto p-6 space-y-6 max-w-2xl"> <div className="container mx-auto p-6 pb-12 space-y-6 max-w-2xl">
<div> <div>
<Button <Button
variant="ghost" variant="ghost"
@ -194,7 +279,7 @@ export default function NewCampaignPage() {
key={workflow.id} key={workflow.id}
value={workflow.id.toString()} value={workflow.id.toString()}
> >
{workflow.name} {workflow.name} (#{workflow.id})
</SelectItem> </SelectItem>
)) ))
)} )}
@ -243,6 +328,119 @@ export default function NewCampaignPage() {
/> />
)} )}
{/* Advanced Settings */}
<Collapsible
open={showAdvancedSettings}
onOpenChange={setShowAdvancedSettings}
className="border rounded-lg"
>
<CollapsibleTrigger className="flex items-center justify-between w-full p-4 hover:bg-muted/50 transition-colors">
<span className="font-medium">Advanced Settings</span>
{showAdvancedSettings ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</CollapsibleTrigger>
<CollapsibleContent className="px-4 pb-4 space-y-6">
{/* Max Concurrent Calls */}
<div className="space-y-2">
<Label htmlFor="max-concurrency">Max Concurrent Calls</Label>
<Input
id="max-concurrency"
type="number"
placeholder={`Organization limit: ${orgConcurrentLimit}`}
value={maxConcurrency}
onChange={(e) => setMaxConcurrency(e.target.value)}
min={1}
max={orgConcurrentLimit}
/>
<p className="text-sm text-muted-foreground">
Maximum number of simultaneous calls. Leave empty to use organization limit ({orgConcurrentLimit}).
</p>
</div>
{/* Retry Configuration */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Label htmlFor="retry-enabled">Enable Retries</Label>
<p className="text-sm text-muted-foreground">
Automatically retry failed calls
</p>
</div>
<Switch
id="retry-enabled"
checked={retryEnabled}
onCheckedChange={setRetryEnabled}
/>
</div>
{retryEnabled && (
<div className="space-y-4 pl-4 border-l-2 border-muted">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="max-retries">Max Retries</Label>
<Input
id="max-retries"
type="number"
value={maxRetries}
onChange={(e) => setMaxRetries(e.target.value)}
min={0}
max={10}
/>
</div>
<div className="space-y-2">
<Label htmlFor="retry-delay">Retry Delay (seconds)</Label>
<Input
id="retry-delay"
type="number"
value={retryDelaySeconds}
onChange={(e) => setRetryDelaySeconds(e.target.value)}
min={30}
max={3600}
/>
</div>
</div>
<div className="space-y-3">
<Label>Retry On</Label>
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm">Busy Signal</span>
<Switch
checked={retryOnBusy}
onCheckedChange={setRetryOnBusy}
/>
</div>
<div className="flex items-center justify-between">
<span className="text-sm">No Answer</span>
<Switch
checked={retryOnNoAnswer}
onCheckedChange={setRetryOnNoAnswer}
/>
</div>
<div className="flex items-center justify-between">
<span className="text-sm">Voicemail</span>
<Switch
checked={retryOnVoicemail}
onCheckedChange={setRetryOnVoicemail}
/>
</div>
</div>
</div>
</div>
)}
</div>
</CollapsibleContent>
</Collapsible>
{createError && (
<div className="rounded-md bg-destructive/15 p-3 text-sm text-destructive">
{createError}
</div>
)}
<div className="flex gap-4 pt-4"> <div className="flex gap-4 pt-4">
<Button <Button
type="submit" type="submit"

View file

@ -1,5 +1,6 @@
"use client"; "use client";
import { Plus, Trash2 } from "lucide-react";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
@ -50,8 +51,8 @@ interface TelephonyConfigForm {
// Cloudonix fields // Cloudonix fields
bearer_token?: string; bearer_token?: string;
domain_id?: string; domain_id?: string;
// Common field // Common field - multiple phone numbers
from_number: string; from_numbers: string[];
} }
export default function ConfigureTelephonyPage() { export default function ConfigureTelephonyPage() {
@ -73,10 +74,28 @@ export default function ConfigureTelephonyPage() {
} = useForm<TelephonyConfigForm>({ } = useForm<TelephonyConfigForm>({
defaultValues: { defaultValues: {
provider: "twilio", provider: "twilio",
from_numbers: [""],
}, },
}); });
const selectedProvider = watch("provider"); const selectedProvider = watch("provider");
const fromNumbers = watch("from_numbers") || [""];
const addPhoneNumber = () => {
setValue("from_numbers", [...fromNumbers, ""]);
};
const removePhoneNumber = (index: number) => {
if (fromNumbers.length > 1) {
setValue("from_numbers", fromNumbers.filter((_, i) => i !== index));
}
};
const updatePhoneNumber = (index: number, value: string) => {
const newNumbers = [...fromNumbers];
newNumbers[index] = value;
setValue("from_numbers", newNumbers);
};
useEffect(() => { useEffect(() => {
// Don't fetch config while auth is still loading // Don't fetch config while auth is still loading
@ -99,9 +118,7 @@ export default function ConfigureTelephonyPage() {
setValue("provider", "twilio"); setValue("provider", "twilio");
setValue("account_sid", response.data.twilio.account_sid); setValue("account_sid", response.data.twilio.account_sid);
setValue("auth_token", response.data.twilio.auth_token); setValue("auth_token", response.data.twilio.auth_token);
if (response.data.twilio.from_numbers?.length > 0) { setValue("from_numbers", response.data.twilio.from_numbers?.length > 0 ? response.data.twilio.from_numbers : [""]);
setValue("from_number", response.data.twilio.from_numbers[0]);
}
} else if (response.data?.vonage) { } else if (response.data?.vonage) {
setHasExistingConfig(true); setHasExistingConfig(true);
setValue("provider", "vonage"); setValue("provider", "vonage");
@ -109,26 +126,20 @@ export default function ConfigureTelephonyPage() {
setValue("private_key", response.data.vonage.private_key); setValue("private_key", response.data.vonage.private_key);
setValue("api_key", response.data.vonage.api_key || ""); setValue("api_key", response.data.vonage.api_key || "");
setValue("api_secret", response.data.vonage.api_secret || ""); setValue("api_secret", response.data.vonage.api_secret || "");
if (response.data.vonage.from_numbers?.length > 0) { setValue("from_numbers", response.data.vonage.from_numbers?.length > 0 ? response.data.vonage.from_numbers : [""]);
setValue("from_number", response.data.vonage.from_numbers[0]);
}
} else if (response.data?.vobiz) { } else if (response.data?.vobiz) {
setHasExistingConfig(true); setHasExistingConfig(true);
setValue("provider", "vobiz"); setValue("provider", "vobiz");
setValue("auth_id", response.data.vobiz.auth_id); setValue("auth_id", response.data.vobiz.auth_id);
setValue("vobiz_auth_token", response.data.vobiz.auth_token); setValue("vobiz_auth_token", response.data.vobiz.auth_token);
if (response.data.vobiz.from_numbers?.length > 0) { setValue("from_numbers", response.data.vobiz.from_numbers?.length > 0 ? response.data.vobiz.from_numbers : [""]);
setValue("from_number", response.data.vobiz.from_numbers[0]);
}
} else if ((response.data as TelephonyConfigurationResponse)?.cloudonix) { } else if ((response.data as TelephonyConfigurationResponse)?.cloudonix) {
const cloudonixConfig = (response.data as TelephonyConfigurationResponse).cloudonix as CloudonixConfigurationResponse; const cloudonixConfig = (response.data as TelephonyConfigurationResponse).cloudonix as CloudonixConfigurationResponse;
setHasExistingConfig(true); setHasExistingConfig(true);
setValue("provider", "cloudonix"); setValue("provider", "cloudonix");
setValue("bearer_token", cloudonixConfig.bearer_token); setValue("bearer_token", cloudonixConfig.bearer_token);
setValue("domain_id", cloudonixConfig.domain_id); setValue("domain_id", cloudonixConfig.domain_id);
if (cloudonixConfig.from_numbers?.length > 0) { setValue("from_numbers", cloudonixConfig.from_numbers?.length > 0 ? cloudonixConfig.from_numbers : [""]);
setValue("from_number", cloudonixConfig.from_numbers[0]);
}
} }
} }
} catch (error) { } catch (error) {
@ -152,17 +163,51 @@ export default function ConfigureTelephonyPage() {
| VobizConfigurationRequest | VobizConfigurationRequest
| CloudonixConfigurationRequest; | CloudonixConfigurationRequest;
const filteredNumbers = data.from_numbers.filter(n => n.trim() !== "");
// Validate phone numbers are provided (except for Cloudonix where optional)
if (data.provider !== "cloudonix" && filteredNumbers.length === 0) {
toast.error("At least one phone number is required");
setIsLoading(false);
return;
}
// Validate phone number format based on provider
const twilioPattern = /^\+[1-9]\d{1,14}$/;
const vonageVobizPattern = /^[1-9]\d{1,14}$/;
const cloudonixPattern = /^\+?[1-9]\d{1,14}$/;
let pattern: RegExp;
let formatMessage: string;
if (data.provider === "twilio") {
pattern = twilioPattern;
formatMessage = "with + prefix (e.g., +1234567890)";
} else if (data.provider === "cloudonix") {
pattern = cloudonixPattern;
formatMessage = "(e.g., +1234567890)";
} else {
pattern = vonageVobizPattern;
formatMessage = "without + prefix (e.g., 14155551234)";
}
const invalidNumbers = filteredNumbers.filter(n => !pattern.test(n));
if (invalidNumbers.length > 0) {
toast.error(`Invalid phone number format. Please enter numbers ${formatMessage}`);
setIsLoading(false);
return;
}
if (data.provider === "twilio") { if (data.provider === "twilio") {
requestBody = { requestBody = {
provider: data.provider, provider: data.provider,
from_numbers: [data.from_number], from_numbers: filteredNumbers,
account_sid: data.account_sid, account_sid: data.account_sid,
auth_token: data.auth_token, auth_token: data.auth_token,
} as TwilioConfigurationRequest; } as TwilioConfigurationRequest;
} else if (data.provider === "vonage") { } else if (data.provider === "vonage") {
requestBody = { requestBody = {
provider: data.provider, provider: data.provider,
from_numbers: [data.from_number], from_numbers: filteredNumbers,
application_id: data.application_id, application_id: data.application_id,
private_key: data.private_key, private_key: data.private_key,
api_key: data.api_key || undefined, api_key: data.api_key || undefined,
@ -171,7 +216,7 @@ export default function ConfigureTelephonyPage() {
} else if (data.provider === "vobiz") { } else if (data.provider === "vobiz") {
requestBody = { requestBody = {
provider: data.provider, provider: data.provider,
from_numbers: [data.from_number], from_numbers: filteredNumbers,
auth_id: data.auth_id, auth_id: data.auth_id,
auth_token: data.vobiz_auth_token, auth_token: data.vobiz_auth_token,
} as VobizConfigurationRequest; } as VobizConfigurationRequest;
@ -179,7 +224,7 @@ export default function ConfigureTelephonyPage() {
// Cloudonix // Cloudonix
requestBody = { requestBody = {
provider: data.provider, provider: data.provider,
from_numbers: data.from_number ? [data.from_number] : [], from_numbers: filteredNumbers,
bearer_token: data.bearer_token!, bearer_token: data.bearer_token!,
domain_id: data.domain_id!, domain_id: data.domain_id!,
} as CloudonixConfigurationRequest; } as CloudonixConfigurationRequest;
@ -416,23 +461,39 @@ export default function ConfigureTelephonyPage() {
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="from_number">From Phone Number</Label> <Label>CLI Phone Numbers</Label>
<Input {fromNumbers.map((number, index) => (
id="from_number" <div key={index} className="flex gap-2">
autoComplete="tel" <Input
placeholder="+1234567890" autoComplete="tel"
{...register("from_number", { placeholder="+1234567890"
required: "Phone number is required", value={number}
pattern: { onChange={(e) => updatePhoneNumber(index, e.target.value)}
value: /^\+[1-9]\d{1,14}$/, />
message: {fromNumbers.length > 1 && (
"Enter a valid phone number with country code (e.g., +1234567890)", <Button
}, type="button"
})} variant="outline"
/> size="icon"
{errors.from_number && ( onClick={() => removePhoneNumber(index)}
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
onClick={addPhoneNumber}
>
<Plus className="h-4 w-4 mr-2" />
Add Phone Number
</Button>
{fromNumbers.some(n => n.trim() !== "" && !/^\+[1-9]\d{1,14}$/.test(n)) && (
<p className="text-sm text-red-500"> <p className="text-sm text-red-500">
{errors.from_number.message} Enter valid phone numbers with country code (e.g., +1234567890)
</p> </p>
)} )}
</div> </div>
@ -497,23 +558,39 @@ export default function ConfigureTelephonyPage() {
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="from_number">From Phone Number</Label> <Label>CLI Phone Numbers</Label>
<Input {fromNumbers.map((number, index) => (
id="from_number" <div key={index} className="flex gap-2">
autoComplete="tel" <Input
placeholder="14155551234 (no + prefix for Vonage)" autoComplete="tel"
{...register("from_number", { placeholder="14155551234 (no + prefix for Vonage)"
required: "Phone number is required", value={number}
pattern: { onChange={(e) => updatePhoneNumber(index, e.target.value)}
value: /^[1-9]\d{1,14}$/, />
message: {fromNumbers.length > 1 && (
"Enter a valid phone number without + prefix (e.g., 14155551234)", <Button
}, type="button"
})} variant="outline"
/> size="icon"
{errors.from_number && ( onClick={() => removePhoneNumber(index)}
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
onClick={addPhoneNumber}
>
<Plus className="h-4 w-4 mr-2" />
Add Phone Number
</Button>
{fromNumbers.some(n => n.trim() !== "" && !/^[1-9]\d{1,14}$/.test(n)) && (
<p className="text-sm text-red-500"> <p className="text-sm text-red-500">
{errors.from_number.message} Enter valid phone numbers without + prefix (e.g., 14155551234)
</p> </p>
)} )}
</div> </div>
@ -564,23 +641,39 @@ export default function ConfigureTelephonyPage() {
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="from_number">From Phone Number</Label> <Label>CLI Phone Numbers</Label>
<Input {fromNumbers.map((number, index) => (
id="from_number" <div key={index} className="flex gap-2">
autoComplete="tel" <Input
placeholder="14155551234 (no + prefix for Vobiz)" autoComplete="tel"
{...register("from_number", { placeholder="14155551234 (no + prefix for Vobiz)"
required: "Phone number is required", value={number}
pattern: { onChange={(e) => updatePhoneNumber(index, e.target.value)}
value: /^[1-9]\d{1,14}$/, />
message: {fromNumbers.length > 1 && (
"Enter a valid phone number without + prefix (e.g., 14155551234)", <Button
}, type="button"
})} variant="outline"
/> size="icon"
{errors.from_number && ( onClick={() => removePhoneNumber(index)}
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
onClick={addPhoneNumber}
>
<Plus className="h-4 w-4 mr-2" />
Add Phone Number
</Button>
{fromNumbers.some(n => n.trim() !== "" && !/^[1-9]\d{1,14}$/.test(n)) && (
<p className="text-sm text-red-500"> <p className="text-sm text-red-500">
{errors.from_number.message} Enter valid phone numbers without + prefix (e.g., 14155551234)
</p> </p>
)} )}
</div> </div>
@ -635,24 +728,39 @@ export default function ConfigureTelephonyPage() {
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="from_number"> <Label>CLI Phone Numbers (Optional)</Label>
From Phone Number (Optional) {fromNumbers.map((number, index) => (
</Label> <div key={index} className="flex gap-2">
<Input <Input
id="from_number" autoComplete="tel"
autoComplete="tel" placeholder="+1234567890"
placeholder="+1234567890" value={number}
{...register("from_number", { onChange={(e) => updatePhoneNumber(index, e.target.value)}
pattern: { />
value: /^\+?[1-9]\d{1,14}$/, {fromNumbers.length > 1 && (
message: <Button
"Enter a valid phone number (e.g., +1234567890)", type="button"
}, variant="outline"
})} size="icon"
/> onClick={() => removePhoneNumber(index)}
{errors.from_number && ( >
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
onClick={addPhoneNumber}
>
<Plus className="h-4 w-4 mr-2" />
Add Phone Number
</Button>
{fromNumbers.some(n => n.trim() !== "" && !/^\+?[1-9]\d{1,14}$/.test(n)) && (
<p className="text-sm text-red-500"> <p className="text-sm text-red-500">
{errors.from_number.message} Enter valid phone numbers (e.g., +1234567890)
</p> </p>
)} )}
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">

File diff suppressed because one or more lines are too long

View file

@ -43,6 +43,11 @@ export type AuthUserResponse = {
export type CallType = 'inbound' | 'outbound'; export type CallType = 'inbound' | 'outbound';
export type CampaignLimitsResponse = {
concurrent_call_limit: number;
default_retry_config: RetryConfigResponse;
};
export type CampaignProgressResponse = { export type CampaignProgressResponse = {
campaign_id: number; campaign_id: number;
state: string; state: string;
@ -72,6 +77,8 @@ export type CampaignResponse = {
created_at: string; created_at: string;
started_at: string | null; started_at: string | null;
completed_at: string | null; completed_at: string | null;
retry_config: RetryConfigResponse;
max_concurrency?: number | null;
}; };
export type CampaignSourceDownloadResponse = { export type CampaignSourceDownloadResponse = {
@ -177,6 +184,8 @@ export type CreateCampaignRequest = {
workflow_id: number; workflow_id: number;
source_type: string; source_type: string;
source_id: string; source_id: string;
retry_config?: RetryConfigRequest | null;
max_concurrency?: number | null;
}; };
/** /**
@ -689,6 +698,24 @@ export type ProcessDocumentRequestSchema = {
embedding_service?: 'sentence_transformer' | 'openai'; embedding_service?: 'sentence_transformer' | 'openai';
}; };
export type RetryConfigRequest = {
enabled?: boolean;
max_retries?: number;
retry_delay_seconds?: number;
retry_on_busy?: boolean;
retry_on_no_answer?: boolean;
retry_on_voicemail?: boolean;
};
export type RetryConfigResponse = {
enabled: boolean;
max_retries: number;
retry_delay_seconds: number;
retry_on_busy: boolean;
retry_on_no_answer: boolean;
retry_on_voicemail: boolean;
};
export type S3SignedUrlResponse = { export type S3SignedUrlResponse = {
url: string; url: string;
expires_in: number; expires_in: number;
@ -3289,6 +3316,39 @@ export type SaveTelephonyConfigurationApiV1OrganizationsTelephonyConfigPostRespo
200: unknown; 200: unknown;
}; };
export type GetCampaignLimitsApiV1OrganizationsCampaignLimitsGetData = {
body?: never;
headers?: {
authorization?: string | null;
'X-API-Key'?: string | null;
};
path?: never;
query?: never;
url: '/api/v1/organizations/campaign-limits';
};
export type GetCampaignLimitsApiV1OrganizationsCampaignLimitsGetErrors = {
/**
* Not found
*/
404: unknown;
/**
* Validation Error
*/
422: HttpValidationError;
};
export type GetCampaignLimitsApiV1OrganizationsCampaignLimitsGetError = GetCampaignLimitsApiV1OrganizationsCampaignLimitsGetErrors[keyof GetCampaignLimitsApiV1OrganizationsCampaignLimitsGetErrors];
export type GetCampaignLimitsApiV1OrganizationsCampaignLimitsGetResponses = {
/**
* Successful Response
*/
200: CampaignLimitsResponse;
};
export type GetCampaignLimitsApiV1OrganizationsCampaignLimitsGetResponse = GetCampaignLimitsApiV1OrganizationsCampaignLimitsGetResponses[keyof GetCampaignLimitsApiV1OrganizationsCampaignLimitsGetResponses];
export type GetSignedUrlApiV1S3SignedUrlGetData = { export type GetSignedUrlApiV1S3SignedUrlGetData = {
body?: never; body?: never;
headers?: { headers?: {

View file

@ -34,7 +34,7 @@ const AppLayout: React.FC<AppLayoutProps> = ({
return ( return (
<SidebarProvider defaultOpen={!isWorkflowEditor}> <SidebarProvider defaultOpen={!isWorkflowEditor}>
<div className="flex h-screen w-full"> <div className="flex min-h-screen w-full">
<AppSidebar /> <AppSidebar />
<SidebarInset className="flex-1"> <SidebarInset className="flex-1">
{/* Optional header area for specific pages */} {/* Optional header area for specific pages */}
@ -60,7 +60,7 @@ const AppLayout: React.FC<AppLayoutProps> = ({
)} )}
{/* Main content area */} {/* Main content area */}
<main className="flex-1 overflow-auto"> <main className="flex-1">
{children} {children}
</main> </main>
</SidebarInset> </SidebarInset>