mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-22 08:38:13 +02:00
feat: add retry config during campaign creation
This commit is contained in:
parent
db75d90535
commit
6f41e91f67
14 changed files with 1036 additions and 221 deletions
|
|
@ -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,
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
265
api/services/campaign/source_validator.py
Normal file
265
api/services/campaign/source_validator.py
Normal 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)
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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?: {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue