From 3babb5ced61e259f480f8c0fdf225c57953fddf6 Mon Sep 17 00:00:00 2001 From: Abhishek Date: Thu, 9 Oct 2025 17:54:31 +0530 Subject: [PATCH] feat: add csv upload functionality for OSS (#29) feat: add csv upload functionality chore: remove redundant arq-worker from docker-compose --- .dockerignore | 1 + api/Dockerfile | 7 +- api/app.py | 32 ++-- api/constants.py | 6 +- api/logging_config.py | 3 +- api/routes/campaign.py | 78 +++++++-- api/routes/s3_signed_url.py | 79 +++++++++ .../campaign/campaign_orchestrator.py | 18 +- api/services/campaign/source_sync.py | 16 -- api/services/campaign/source_sync_factory.py | 19 ++ api/services/campaign/sources/csv.py | 140 +++++++++++++++ api/services/filesystem/base.py | 29 +++- api/services/filesystem/minio.py | 54 +++++- api/services/filesystem/s3.py | 31 +++- api/services/telephony/ari_client_manager.py | 9 - api/services/telephony/ari_manager.py | 13 +- api/tasks/arq.py | 2 +- api/tasks/campaign_tasks.py | 2 +- docker-compose.yaml | 54 +----- scripts/rolling_update_uvicorn.sh | 25 ++- scripts/start_services.sh | 162 +++++++++++------- ui/src/app/campaigns/CsvUploadSelector.tsx | 139 +++++++++++++++ ui/src/app/campaigns/[campaignId]/page.tsx | 57 +++++- ui/src/app/campaigns/new/page.tsx | 64 +++++-- ui/src/client/sdk.gen.ts | 42 ++++- ui/src/client/types.gen.ts | 93 ++++++++++ 26 files changed, 941 insertions(+), 234 deletions(-) create mode 100644 .dockerignore create mode 100644 api/services/campaign/source_sync_factory.py create mode 100644 api/services/campaign/sources/csv.py create mode 100644 ui/src/app/campaigns/CsvUploadSelector.tsx diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..19aaf83 --- /dev/null +++ b/.dockerignore @@ -0,0 +1 @@ +api/.env \ No newline at end of file diff --git a/api/Dockerfile b/api/Dockerfile index b5cef7c..1a69ea8 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -11,7 +11,7 @@ RUN apt-get update && apt-get install -y \ && rm -rf /var/lib/apt/lists/* # Copy and install requirements -COPY requirements.txt . +COPY api/requirements.txt . # Install dependencies to user directory for easy copying RUN pip install --user --no-cache-dir -r requirements.txt && \ @@ -53,7 +53,8 @@ ENV PYTHONDONTWRITEBYTECODE=1 ENV PYTHONUNBUFFERED=1 # Copy application code -COPY . ./api +COPY ./api ./api +COPY ./scripts/start_services.sh ./scripts/start_services.sh ENV PYTHONPATH=/app @@ -61,4 +62,4 @@ ENV PYTHONPATH=/app EXPOSE 8000 # Run the FastAPI app with uvicorn -CMD ["uvicorn", "api.app:app", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file +CMD ["bash", "-c", "./scripts/start_services.sh && tail -f ./logs/latest/*.log"] \ No newline at end of file diff --git a/api/app.py b/api/app.py index 9054be5..4c27afe 100644 --- a/api/app.py +++ b/api/app.py @@ -2,29 +2,22 @@ import sentry_sdk -from api.constants import ENABLE_SENTRY, REDIS_URL, SENTRY_DSN +from api.constants import DEPLOYMENT_MODE, ENABLE_TELEMETRY, REDIS_URL, SENTRY_DSN from api.logging_config import ENVIRONMENT, setup_logging # Set up logging and get the listener for cleanup -logging_queue_listener = setup_logging() +setup_logging() -if ENABLE_SENTRY: - if not SENTRY_DSN: - print( - "Warning: ENABLE_SENTRY is true but SENTRY_DSN is not configured. Sentry disabled." - ) - else: - sentry_sdk.init( - dsn=SENTRY_DSN, - # Add data like request headers and IP for users, - # see https://docs.sentry.io/platforms/python/data-management/data-collected/ for more info - send_default_pii=True, - environment=ENVIRONMENT, - ) - print(f"Sentry initialized in environment: {ENVIRONMENT}") -else: - print(f"Sentry disabled (ENABLE_SENTRY=false)") +if SENTRY_DSN and ( + DEPLOYMENT_MODE != "oss" or (DEPLOYMENT_MODE == "oss" and ENABLE_TELEMETRY) +): + sentry_sdk.init( + dsn=SENTRY_DSN, + send_default_pii=True, + environment=ENVIRONMENT, + ) + print(f"Sentry initialized in environment: {ENVIRONMENT}") import asyncio @@ -91,9 +84,6 @@ async def lifespan(app: FastAPI): await redis.aclose() - if logging_queue_listener is not None: - logging_queue_listener.stop() - app = FastAPI( title="Dograh API", diff --git a/api/constants.py b/api/constants.py index abe00ff..235c1e0 100644 --- a/api/constants.py +++ b/api/constants.py @@ -43,5 +43,9 @@ S3_BUCKET = os.environ.get("S3_BUCKET") S3_REGION = os.environ.get("S3_REGION", "us-east-1") # Sentry configuration -ENABLE_SENTRY = os.getenv("ENABLE_SENTRY", "false").lower() == "true" SENTRY_DSN = os.getenv("SENTRY_DSN") + + +ENABLE_ARI_STASIS = os.getenv("ENABLE_ARI_STASIS", "false").lower() == "true" +SERIALIZE_LOG_OUTPUT = os.getenv("SERIALIZE_LOG_OUTPUT", "false").lower() == "true" +ENABLE_TELEMETRY = os.getenv("ENABLE_TELEMETRY", "false").lower() == "true" diff --git a/api/logging_config.py b/api/logging_config.py index 82dfb73..ca56cc6 100644 --- a/api/logging_config.py +++ b/api/logging_config.py @@ -3,6 +3,7 @@ import sys import loguru +from api.constants import SERIALIZE_LOG_OUTPUT from api.enums import Environment from api.utils.worker import get_worker_id, is_worker_process from pipecat.utils.context import run_id_var, turn_var @@ -88,7 +89,7 @@ def setup_logging(): patched.add( actual_log_path, level=log_level, - serialize=True, # Use JSON serialization for structured logs + serialize=SERIALIZE_LOG_OUTPUT, # Use JSON serialization for structured logs enqueue=True, # Thread-safe writing backtrace=True, # Include full traceback in exceptions diagnose=False, # Don't include local variables in traceback for security diff --git a/api/routes/campaign.py b/api/routes/campaign.py index 195ce57..ec40757 100644 --- a/api/routes/campaign.py +++ b/api/routes/campaign.py @@ -9,6 +9,7 @@ from api.db.models import UserModel from api.enums import OrganizationConfigurationKey from api.services.auth.depends import get_user from api.services.campaign.runner import campaign_runner_service +from api.services.storage import storage_fs router = APIRouter(prefix="/campaign") @@ -16,7 +17,8 @@ router = APIRouter(prefix="/campaign") class CreateCampaignRequest(BaseModel): name: str = Field(..., min_length=1, max_length=255) workflow_id: int - source_id: str # Sheet URL + source_type: str = Field(..., pattern="^(google-sheet|csv)$") + source_id: str # Google Sheet URL or CSV file key class CampaignResponse(BaseModel): @@ -74,7 +76,7 @@ async def create_campaign( campaign = await db_client.create_campaign( name=request.name, workflow_id=request.workflow_id, - source_type="google-sheet", + source_type=request.source_type, source_id=request.source_id, user_id=user.id, organization_id=user.selected_organization_id, @@ -174,14 +176,10 @@ async def start_campaign( OrganizationConfigurationKey.TWILIO_CONFIGURATION.value, ) - if ( - not twilio_config - or not twilio_config.value - or not twilio_config.value.get("value") - ): + if not twilio_config or not twilio_config.value: raise HTTPException( status_code=401, - detail="Your organisation is not allowed to make phone call. Contact founders@dograh.com for further support.", + detail="You must configure telephony first by going to APP_URL/configure-telephony", ) # Verify campaign exists and belongs to organization @@ -286,14 +284,10 @@ async def resume_campaign( OrganizationConfigurationKey.TWILIO_CONFIGURATION.value, ) - if ( - not twilio_config - or not twilio_config.value - or not twilio_config.value.get("value") - ): + if not twilio_config or not twilio_config.value: raise HTTPException( status_code=401, - detail="Your organisation is not allowed to make phone call. Contact founders@dograh.com for further support.", + detail="You must configure telephony first by going to APP_URL/configure-telephony", ) # Verify campaign exists and belongs to organization @@ -345,3 +339,59 @@ async def get_campaign_progress( return CampaignProgressResponse(**progress) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) + + +class CampaignSourceDownloadResponse(BaseModel): + download_url: str + expires_in: int + + +@router.get("/{campaign_id}/source-download-url") +async def get_campaign_source_download_url( + campaign_id: int, + user: UserModel = Depends(get_user), +) -> CampaignSourceDownloadResponse: + """Get presigned download URL for campaign CSV source file + + Only works for CSV source type. For Google Sheets, use the source_id directly. + Validates that the campaign belongs to the user's organization for security. + """ + # Verify campaign exists and belongs to organization + campaign = await db_client.get_campaign(campaign_id, user.selected_organization_id) + if not campaign: + raise HTTPException(status_code=404, detail="Campaign not found") + + # Only generate download URL for CSV files + if campaign.source_type != "csv": + raise HTTPException( + status_code=400, + detail=f"Download URL only available for CSV sources. This campaign uses {campaign.source_type}", + ) + + # Verify the file key belongs to the user's organization + # File key format: campaigns/{org_id}/{uuid}_{filename}.csv + if not campaign.source_id.startswith(f"campaigns/{user.selected_organization_id}/"): + raise HTTPException( + status_code=403, + detail="Access denied: Source file does not belong to your organization", + ) + + # Generate presigned download URL + try: + download_url = await storage_fs.aget_signed_url( + campaign.source_id, + expiration=3600, # 1 hour + ) + + if not download_url: + raise HTTPException( + status_code=500, detail="Failed to generate download URL" + ) + + return CampaignSourceDownloadResponse( + download_url=download_url, expires_in=3600 + ) + except Exception as e: + raise HTTPException( + status_code=500, detail=f"Failed to generate download URL: {str(e)}" + ) diff --git a/api/routes/s3_signed_url.py b/api/routes/s3_signed_url.py index e0d1d12..2889278 100644 --- a/api/routes/s3_signed_url.py +++ b/api/routes/s3_signed_url.py @@ -1,8 +1,11 @@ +import re +import uuid from typing import Annotated, Any, Dict, Optional, TypedDict from botocore.exceptions import ClientError from fastapi import APIRouter, Depends, HTTPException, Query from loguru import logger +from pydantic import BaseModel, Field from api.db import db_client from api.enums import StorageBackend @@ -20,6 +23,20 @@ class FileMetadataResponse(TypedDict): metadata: Optional[Dict[str, Any]] +class PresignedUploadUrlRequest(BaseModel): + file_name: str = Field(..., pattern=r".*\.csv$", description="CSV filename") + file_size: int = Field( + ..., gt=0, le=10_485_760, description="File size in bytes (max 10MB)" + ) + content_type: str = Field(default="text/csv", description="File content type") + + +class PresignedUploadUrlResponse(BaseModel): + upload_url: str + file_key: str + expires_in: int + + router = APIRouter(prefix="/s3", tags=["s3"]) @@ -217,3 +234,65 @@ async def get_file_metadata( except Exception as exc: logger.error(f"Error getting file metadata: {exc}") raise HTTPException(status_code=500, detail="Failed to get file metadata") + + +@router.post( + "/presigned-upload-url", + response_model=PresignedUploadUrlResponse, + summary="Generate a presigned URL for direct CSV upload", +) +async def get_presigned_upload_url( + request: PresignedUploadUrlRequest, + user=Depends(get_user), +): + """Generate a presigned PUT URL for direct CSV file upload to S3/MinIO. + + This endpoint enables browser-to-storage uploads without passing through the backend + + Access Control: + * All authenticated users can upload CSV files scoped to their organization. + * Files are stored with organization-scoped keys for multi-tenancy. + + Returns: + * upload_url: Presigned URL (valid for 15 minutes) for PUT request + * file_key: Unique storage key to use as source_id in campaign creation + * expires_in: URL expiration time in seconds + """ + + # Sanitize filename - remove special chars, keep only alphanumeric, dash, underscore, and dot + sanitized_name = re.sub(r"[^a-zA-Z0-9._-]", "_", request.file_name) + + # Generate unique file key: campaigns/{org_id}/{uuid}_{filename}.csv + file_key = ( + f"campaigns/{user.selected_organization_id}/{uuid.uuid4()}_{sanitized_name}" + ) + + try: + # Generate presigned PUT URL using current storage backend + upload_url = await storage_fs.aget_presigned_put_url( + file_path=file_key, + expiration=900, # 15 minutes + content_type=request.content_type, + max_size=request.file_size, + ) + + if not upload_url: + raise HTTPException( + status_code=500, detail="Failed to generate presigned upload URL" + ) + + logger.info( + f"Generated presigned upload URL for user {user.id}, org {user.selected_organization_id}, file_key: {file_key}" + ) + + return PresignedUploadUrlResponse( + upload_url=upload_url, + file_key=file_key, + expires_in=900, + ) + + except Exception as exc: + logger.error(f"Error generating presigned upload URL: {exc}") + raise HTTPException( + status_code=500, detail="Failed to generate presigned upload URL" + ) diff --git a/api/services/campaign/campaign_orchestrator.py b/api/services/campaign/campaign_orchestrator.py index 72a0d5f..26346c5 100644 --- a/api/services/campaign/campaign_orchestrator.py +++ b/api/services/campaign/campaign_orchestrator.py @@ -7,7 +7,7 @@ for final completion after 1 hour of inactivity and handles retry events. from api.logging_config import setup_logging -logging_queue_listener = setup_logging() +setup_logging() import asyncio @@ -495,7 +495,7 @@ class CampaignOrchestrator: if self._pubsub: try: await self._pubsub.unsubscribe(RedisChannel.CAMPAIGN_EVENTS.value) - await self._pubsub.close() + await self._pubsub.aclose() except Exception as e: logger.error(f"Error closing pubsub: {e}") @@ -538,16 +538,12 @@ async def main(): if shutdown_task in done: logger.info("Shutdown signal received, stopping orchestrator...") orchestrator._running = False - # Wait for orchestrator to finish gracefully + # Cancel the orchestrator task immediately since it may be blocked + orchestrator_task.cancel() try: - await asyncio.wait_for(orchestrator_task, timeout=5.0) - except asyncio.TimeoutError: - logger.warning("Orchestrator shutdown timeout, cancelling...") - orchestrator_task.cancel() - try: - await orchestrator_task - except asyncio.CancelledError: - pass + await orchestrator_task + except asyncio.CancelledError: + logger.info("Orchestrator task cancelled successfully") except KeyboardInterrupt: logger.info("Keyboard interrupt received") diff --git a/api/services/campaign/source_sync.py b/api/services/campaign/source_sync.py index f92bdfd..05a4130 100644 --- a/api/services/campaign/source_sync.py +++ b/api/services/campaign/source_sync.py @@ -31,19 +31,3 @@ class CampaignSourceSyncService(ABC): f"Getting credentials for org {organization_id}, source {source_type}" ) return {} - - -def get_sync_service(source_type: str) -> CampaignSourceSyncService: - """Returns appropriate sync service based on source type""" - from .sources.google_sheets import GoogleSheetsSyncService - - services = { - "google-sheet": GoogleSheetsSyncService, - # Add more as needed: "hubspot": HubSpotSyncService, - } - - service_class = services.get(source_type) - if not service_class: - raise ValueError(f"Unknown source type: {source_type}") - - return service_class() diff --git a/api/services/campaign/source_sync_factory.py b/api/services/campaign/source_sync_factory.py new file mode 100644 index 0000000..2e05f1b --- /dev/null +++ b/api/services/campaign/source_sync_factory.py @@ -0,0 +1,19 @@ +from api.services.campaign.source_sync import CampaignSourceSyncService +from api.services.campaign.sources.csv import CSVSyncService +from api.services.campaign.sources.google_sheets import GoogleSheetsSyncService + + +def get_sync_service(source_type: str) -> CampaignSourceSyncService: + """Returns appropriate sync service based on source type""" + + services = { + "google-sheet": GoogleSheetsSyncService, + "csv": CSVSyncService, + # Add more as needed: "hubspot": HubSpotSyncService, + } + + service_class = services.get(source_type) + if not service_class: + raise ValueError(f"Unknown source type: {source_type}") + + return service_class() diff --git a/api/services/campaign/sources/csv.py b/api/services/campaign/sources/csv.py new file mode 100644 index 0000000..d65dfa5 --- /dev/null +++ b/api/services/campaign/sources/csv.py @@ -0,0 +1,140 @@ +import csv +import hashlib +from io import StringIO +from typing import Any, Dict, List + +import httpx +from loguru import logger + +from api.db import db_client +from api.services.campaign.source_sync import CampaignSourceSyncService +from api.services.storage import storage_fs + + +class CSVSyncService(CampaignSourceSyncService): + """Implementation for CSV file synchronization""" + + async def sync_source_data(self, campaign_id: int) -> int: + """ + Fetches data from CSV file in S3/MinIO and creates queued_runs + """ + # Get campaign + campaign = await db_client.get_campaign_by_id(campaign_id) + if not campaign: + raise ValueError(f"Campaign {campaign_id} not found") + + # 1. Get download URL using internal endpoint (for container-to-container access) + file_key = campaign.source_id + signed_url = await storage_fs.aget_signed_url( + file_key, expiration=3600, use_internal_endpoint=True + ) + + if not signed_url: + raise ValueError(f"Failed to generate download URL for file: {file_key}") + + # 2. 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: {e} for url: {signed_url}") + raise ValueError(f"Failed to download CSV file from storage: {str(e)}") + + # 3. Parse CSV + csv_data = self._parse_csv(csv_content) + + if not csv_data or len(csv_data) < 2: + logger.warning(f"No data found in CSV for campaign {campaign_id}") + return 0 + + headers = csv_data[0] # First row is headers + rows = csv_data[1:] # Rest is data + + # 4. Create hash of file_key for consistent source_uuid prefix + file_hash = hashlib.md5(file_key.encode()).hexdigest()[:8] + + # 5. Convert to queued_runs + queued_runs = [] + for idx, row_values in enumerate(rows, 1): + # Pad row to match headers length + padded_row = row_values + [""] * (len(headers) - len(row_values)) + + # Create context variables dict + context_vars = dict(zip(headers, padded_row)) + + # Skip if no phone number + if not context_vars.get("phone_number"): + logger.debug(f"Skipping row {idx}: no phone_number") + continue + + # Generate unique source UUID: csv_{hash(source_id)}_row_{idx} + source_uuid = f"csv_{file_hash}_row_{idx}" + + queued_runs.append( + { + "campaign_id": campaign_id, + "source_uuid": source_uuid, + "context_variables": context_vars, + "state": "queued", + } + ) + + # 6. Bulk insert + if queued_runs: + await db_client.bulk_create_queued_runs(queued_runs) + logger.info( + f"Created {len(queued_runs)} queued runs for campaign {campaign_id}" + ) + + # 7. Update campaign total_rows + await db_client.update_campaign( + campaign_id=campaign_id, + total_rows=len(queued_runs), + source_sync_status="completed", + ) + + return len(queued_runs) + + def _parse_csv(self, csv_content: str) -> List[List[str]]: + """Parse CSV content into rows""" + try: + csv_file = StringIO(csv_content) + reader = csv.reader(csv_file) + return list(reader) + except Exception as e: + logger.error(f"Failed to parse CSV: {e}") + raise ValueError(f"Invalid CSV format: {str(e)}") + + async def validate_source_schema(self, source_config: Dict[str, Any]) -> bool: + """Validate that required columns exist in CSV""" + required_columns = ["phone_number", "first_name", "last_name"] + + file_key = source_config.get("source_id") + if not file_key: + return False + + # 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 False + + # Download just enough to get headers + async with httpx.AsyncClient() as client: + try: + response = await client.get(signed_url) + response.raise_for_status() + + # Get just the first line for headers + first_line = response.text.split("\n")[0] + csv_file = StringIO(first_line) + reader = csv.reader(csv_file) + headers = next(reader, []) + + return all(col in headers for col in required_columns) + except Exception as e: + logger.error(f"Failed to validate CSV schema: {e}") + return False diff --git a/api/services/filesystem/base.py b/api/services/filesystem/base.py index f0eee3d..424edb9 100644 --- a/api/services/filesystem/base.py +++ b/api/services/filesystem/base.py @@ -33,13 +33,19 @@ class BaseFileSystem(ABC): @abstractmethod async def aget_signed_url( - self, file_path: str, expiration: int = 3600 + self, + file_path: str, + expiration: int = 3600, + force_inline: bool = False, + use_internal_endpoint: bool = False, ) -> Optional[str]: """Generate a signed URL for temporary access to a file. Args: file_path: Path to the file expiration: URL expiration time in seconds (default: 1 hour) + force_inline: Force inline display (browser preview vs download) + use_internal_endpoint: Use internal endpoint (for container-to-container access) Returns: Optional[str]: Signed URL if successful, None otherwise @@ -58,3 +64,24 @@ class BaseFileSystem(ABC): Contains: size, created_at, modified_at, etag, etc. """ pass + + @abstractmethod + async def aget_presigned_put_url( + self, + file_path: str, + expiration: int = 900, + content_type: str = "text/csv", + max_size: int = 10_485_760, + ) -> Optional[str]: + """Generate a presigned PUT URL for direct file upload. + + Args: + file_path: Path where the file should be uploaded + expiration: URL expiration time in seconds (default: 15 minutes) + content_type: MIME type of the file (default: text/csv) + max_size: Maximum file size in bytes (default: 10MB) + + Returns: + Optional[str]: Presigned PUT URL if successful, None otherwise + """ + pass diff --git a/api/services/filesystem/minio.py b/api/services/filesystem/minio.py index bbfc555..c149868 100644 --- a/api/services/filesystem/minio.py +++ b/api/services/filesystem/minio.py @@ -48,17 +48,28 @@ class MinioFileSystem(BaseFileSystem): if not self.client.bucket_exists(self.bucket_name): self.client.make_bucket(self.bucket_name) - # Set anonymous download policy for local development - # This allows unsigned URLs to work + # Set public read/write policy for local development + # This allows: + # 1. Anonymous downloads (s3:GetObject) + # 2. Anonymous uploads (s3:PutObject) - bypasses presigned URL signature issues + # 3. List bucket contents (s3:ListBucket) for debugging + # Note: This is set on every initialization to ensure policy is correct + # WARNING: Only use in local development, not production! policy = { "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Principal": {"AWS": "*"}, - "Action": ["s3:GetObject"], + "Action": ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"], "Resource": [f"arn:aws:s3:::{self.bucket_name}/*"], - } + }, + { + "Effect": "Allow", + "Principal": {"AWS": "*"}, + "Action": ["s3:ListBucket"], + "Resource": [f"arn:aws:s3:::{self.bucket_name}"], + }, ], } @@ -97,14 +108,19 @@ class MinioFileSystem(BaseFileSystem): return False async def aget_signed_url( - self, file_path: str, expiration: int = 3600, force_inline: bool = False + self, + file_path: str, + expiration: int = 3600, + force_inline: bool = False, + use_internal_endpoint: bool = False, ) -> Optional[str]: try: # For MinIO in local development, return unsigned URLs # This avoids signature mismatch issues when endpoint differs # MinIO must be configured to allow anonymous read access protocol = "https" if self.secure else "http" - url = f"{protocol}://{self.public_endpoint}/{self.bucket_name}/{file_path}" + endpoint = self.endpoint if use_internal_endpoint else self.public_endpoint + url = f"{protocol}://{endpoint}/{self.bucket_name}/{file_path}" return url except Exception as e: logger.error(f"Error generating MinIO URL: {e}") @@ -128,3 +144,29 @@ class MinioFileSystem(BaseFileSystem): } except S3Error: return None + + async def aget_presigned_put_url( + self, + file_path: str, + expiration: int = 900, + content_type: str = "text/csv", + max_size: int = 10_485_760, + ) -> Optional[str]: + """Generate an unsigned URL for direct file upload. + + For local MinIO development with anonymous upload enabled, we return + a simple unsigned URL instead of a presigned URL. This avoids signature + mismatch issues when the internal endpoint (minio:9000) differs from + the public endpoint (localhost:9000). + + The bucket policy allows anonymous s3:PutObject, so no signature is needed. + """ + try: + # Return unsigned URL for anonymous upload + protocol = "https" if self.secure else "http" + url = f"{protocol}://{self.public_endpoint}/{self.bucket_name}/{file_path}" + logger.debug(f"Generated unsigned upload URL: {url}") + return url + except Exception as e: + logger.error(f"Error generating MinIO upload URL: {e}") + return None diff --git a/api/services/filesystem/s3.py b/api/services/filesystem/s3.py index 7155e92..80383f3 100644 --- a/api/services/filesystem/s3.py +++ b/api/services/filesystem/s3.py @@ -45,7 +45,11 @@ class S3FileSystem(BaseFileSystem): return False async def aget_signed_url( - self, file_path: str, expiration: int = 3600, force_inline: bool = False + self, + file_path: str, + expiration: int = 3600, + force_inline: bool = False, + use_internal_endpoint: bool = False, ) -> Optional[str]: """Generate a presigned GET url for the given object. @@ -97,3 +101,28 @@ class S3FileSystem(BaseFileSystem): } except ClientError: return None + + async def aget_presigned_put_url( + self, + file_path: str, + expiration: int = 900, + content_type: str = "text/csv", + max_size: int = 10_485_760, + ) -> Optional[str]: + """Generate a presigned PUT URL for direct file upload.""" + try: + async with self.session.client( + "s3", region_name=self.region_name + ) as s3_client: + url = await s3_client.generate_presigned_url( + "put_object", + Params={ + "Bucket": self.bucket_name, + "Key": file_path, + "ContentType": content_type, + }, + ExpiresIn=expiration, + ) + return url + except ClientError: + return None diff --git a/api/services/telephony/ari_client_manager.py b/api/services/telephony/ari_client_manager.py index 17e4614..c47fcd4 100644 --- a/api/services/telephony/ari_client_manager.py +++ b/api/services/telephony/ari_client_manager.py @@ -426,16 +426,7 @@ async def setup_ari_client_supervisor( This is a drop-in replacement for the asyncari-based function. Uses AsyncARIClient instead of asyncari. - - If the *ENABLE_ARI_STASIS* environment variable is not set to ``"true"`` - (case-insensitive) the function returns ``None`` and no supervisor is - launched. """ - - if os.getenv("ENABLE_ARI_STASIS", "false").lower() != "true": - logger.info("ARI Stasis integration disabled via environment variable") - return None - logger.info("Starting ARI Client Supervisor with AsyncARIClient") supervisor = _ARIClientManagerSupervisor(on_channel_start, on_channel_end) diff --git a/api/services/telephony/ari_manager.py b/api/services/telephony/ari_manager.py index f8787e5..78e4ed3 100644 --- a/api/services/telephony/ari_manager.py +++ b/api/services/telephony/ari_manager.py @@ -16,7 +16,7 @@ import signal import time from typing import Dict, Optional -from api.constants import REDIS_URL +from api.constants import ENABLE_ARI_STASIS, REDIS_URL # --- Add logging setup before importing loguru --- from api.logging_config import setup_logging @@ -32,7 +32,7 @@ from api.services.telephony.stasis_event_protocol import ( parse_command, ) -logging_queue_listener = setup_logging() +setup_logging() import redis.asyncio as aioredis import redis.exceptions @@ -601,9 +601,13 @@ class ARIManager: async def run(self): """Main run loop for ARI Manager.""" - self._running = True + if not ENABLE_ARI_STASIS: + logger.info("ARI Stasis integration disabled via environment variable") + return # Setup ARI connection with supervisor + self._running = True + try: self._ari_client_supervisor = await setup_ari_client_supervisor( self.on_channel_start, self.on_channel_end @@ -737,9 +741,6 @@ async def main(): pass finally: await redis.aclose() - # --- Ensure Axiom logging listener is stopped gracefully --- - if logging_queue_listener is not None: - logging_queue_listener.stop() if __name__ == "__main__": diff --git a/api/tasks/arq.py b/api/tasks/arq.py index 4731560..8c570c4 100644 --- a/api/tasks/arq.py +++ b/api/tasks/arq.py @@ -9,7 +9,7 @@ from api.constants import REDIS_URL from api.logging_config import setup_logging from api.tasks.function_names import FunctionNames -logging_queue_listener = setup_logging() +setup_logging() # Now import ARQ and task dependencies from arq import create_pool diff --git a/api/tasks/campaign_tasks.py b/api/tasks/campaign_tasks.py index 49600b7..c02b36b 100644 --- a/api/tasks/campaign_tasks.py +++ b/api/tasks/campaign_tasks.py @@ -10,7 +10,7 @@ from api.services.campaign.campaign_event_protocol import BatchFailedEvent from api.services.campaign.campaign_event_publisher import ( get_campaign_event_publisher, ) -from api.services.campaign.source_sync import get_sync_service +from api.services.campaign.source_sync_factory import get_sync_service async def sync_campaign_source(ctx: Dict, campaign_id: int) -> None: diff --git a/docker-compose.yaml b/docker-compose.yaml index 05c0b36..247b3b4 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -45,6 +45,7 @@ services: environment: MINIO_ROOT_USER: minioadmin MINIO_ROOT_PASSWORD: minioadmin + MINIO_API_CORS_ALLOW_ORIGIN: "*" ports: - "127.0.0.1:9000:9000" # Bind to localhost explicitly - "127.0.0.1:9001:9001" @@ -90,8 +91,8 @@ services: # LANGFUSE_HOST: "https://langfuse.dograh.com" # Sentry - ENABLE_SETRY: "false" - SENTRY_DSN: "" + ENABLE_TELEMETRY: "${ENABLE_TELEMETRY:-true}" + SENTRY_DSN: "https://3acdb63d5f1f70430953353b82de61e0@o4509486225096704.ingest.us.sentry.io/4510152922693632" ports: - "8000:8000" @@ -104,12 +105,6 @@ services: condition: service_healthy cloudflared: condition: service_started - command: > - bash -c " - cd /app/api && - alembic upgrade head && - uvicorn api.app:app --host 0.0.0.0 --port 8000 - " healthcheck: test: [ @@ -123,49 +118,6 @@ services: networks: - app-network - arq-worker: - image: ${REGISTRY:-dograhai}/dograh-api:latest - volumes: - - shared-tmp:/tmp - environment: - # Core application config - ENVIRONMENT: "local" - LOG_LEVEL: "INFO" - - # Database configuration (using containerized postgres) - DATABASE_URL: "postgresql+asyncpg://postgres:postgres@postgres:5432/postgres" - - # Redis configuration (using containerized redis) - REDIS_URL: "redis://:redissecret@redis:6379" - - # Storage configuration - using local MinIO - ENABLE_AWS_S3: "false" - - # MinIO - MINIO_ENDPOINT: "minio:9000" - MINIO_ACCESS_KEY: "minioadmin" - MINIO_SECRET_KEY: "minioadmin" - MINIO_BUCKET: "voice-audio" - MINIO_SECURE: "false" - - # Sentry - ENABLE_SETRY: "false" - SENTRY_DSN: "" - command: > - bash -c " - cd /app/api && - python -m arq api.tasks.arq.WorkerSettings - " - depends_on: - postgres: - condition: service_healthy - redis: - condition: service_healthy - minio: - condition: service_healthy - networks: - - app-network - ui: image: ${REGISTRY:-dograhai}/dograh-ui:latest environment: diff --git a/scripts/rolling_update_uvicorn.sh b/scripts/rolling_update_uvicorn.sh index 8a15c87..357a905 100755 --- a/scripts/rolling_update_uvicorn.sh +++ b/scripts/rolling_update_uvicorn.sh @@ -4,18 +4,22 @@ set -e # Exit on error ### CONFIGURATION ############################################################# -ENV_FILE="api/.env" -RUN_DIR="run" -BASE_LOG_DIR="/home/ubuntu/dograh/logs" # Base logs directory (same as start_services.sh) + +# Determine BASE_DIR as parent of the scripts directory +BASE_DIR="$(cd "$(dirname "$(dirname "${BASH_SOURCE[0]}")")" && pwd)" + +ENV_FILE="$BASE_DIR/api/.env" +RUN_DIR="$BASE_DIR/run" +BASE_LOG_DIR="$BASE_DIR/logs" # Base logs directory (same as start_services.sh) LATEST_LINK="$BASE_LOG_DIR/latest" # Symlink to latest logs (same as start_services.sh) -VENV_PATH="/home/ubuntu/dograh/venv" +VENV_PATH="$BASE_DIR/venv" HEALTH_CHECK_ENDPOINT="/api/v1/health" # Adjust as needed MAX_WAIT_SECONDS=310 # Max wait for graceful shutdown (5 minutes + 10 seconds grace) # Load environment set -a && . "$ENV_FILE" && set +a -cd /home/ubuntu/dograh/app +cd "$BASE_DIR" ### FUNCTIONS ################################################################## @@ -166,10 +170,13 @@ start_new_uvicorn_workers() { log_info "Starting uvicorn with $FASTAPI_WORKERS workers on port $new_port" log_info "Logs: $LOG_FILE_PATH" - - # Start in new process group with setsid (same as start_services.sh) - # Each service gets its own LOG_FILE_PATH environment variable - setsid nohup bash -c "LOG_FILE_PATH='$LOG_FILE_PATH' uvicorn api.app:app --host 0.0.0.0 --port $new_port --workers $FASTAPI_WORKERS" >/dev/null 2>&1 & + + # Start in background (same pattern as start_services.sh) + ( + cd "$BASE_DIR" + export LOG_FILE_PATH="$log_dir/uvicorn-rollover-${timestamp}-${script_pid}.log" + exec uvicorn api.app:app --host 0.0.0.0 --port $new_port --workers $FASTAPI_WORKERS >>"$LOG_FILE_PATH" 2>&1 + ) & local new_pid=$! echo "$new_pid" > "$RUN_DIR/uvicorn_new.pid" diff --git a/scripts/start_services.sh b/scripts/start_services.sh index 083c06b..1ab984c 100755 --- a/scripts/start_services.sh +++ b/scripts/start_services.sh @@ -1,70 +1,101 @@ #!/usr/bin/env bash -# start_services.sh - set -e # Exit on error -### CONFIGURATION ############################################################# -ENV_FILE="api/.env" -RUN_DIR="run" # where we keep *.pid -BASE_LOG_DIR="/home/ubuntu/dograh/logs" # base logs directory +############################################################################### +### CONFIGURATION +############################################################################### + +# Determine BASE_DIR as parent of the scripts directory +BASE_DIR="$(cd "$(dirname "$(dirname "${BASH_SOURCE[0]}")")" && pwd)" + +ENV_FILE="$BASE_DIR/api/.env" +RUN_DIR="$BASE_DIR/run" # Where we keep *.pid +BASE_LOG_DIR="$BASE_DIR/logs" # Base logs directory + TIMESTAMP=$(date +"%Y%m%d_%H%M%S") -LOG_DIR="$BASE_LOG_DIR/$TIMESTAMP" # timestamped log directory -LATEST_LINK="$BASE_LOG_DIR/latest" # symlink to latest logs -VENV_PATH="/home/ubuntu/dograh/venv" +LOG_DIR="$BASE_LOG_DIR/$TIMESTAMP" # Timestamped log directory +LATEST_LINK="$BASE_LOG_DIR/latest" # Symlink to latest logs +VENV_PATH="$BASE_DIR/venv" + ARQ_WORKERS=${ARQ_WORKERS:-1} # Log startup -echo "Starting Dograh Services at $(date)" +cd "$BASE_DIR" +echo "Starting Dograh Services at $(date) in BASE_DIR: ${BASE_DIR}" -### 1) Load environment vars so that configurations like FASTAPI_WORKERS are loaded -set -a && . "$ENV_FILE" && set +a +############################################################################### +### 1) Load environment variables +############################################################################### -cd /home/ubuntu/dograh/app - -if [[ -z "${FASTAPI_PORT:-}" ]]; then - echo "Error: FASTAPI_PORT environment variable is not set." - exit 1 +# Load environment from a file if it exists +if [[ -f "$ENV_FILE" ]]; then + set -a && . "$ENV_FILE" && set +a fi -if [[ -z "${FASTAPI_WORKERS:-}" ]]; then - echo "Error: FASTAPI_WORKERS environment variable is not set." - exit 1 -fi +FASTAPI_PORT=${FASTAPI_PORT:-8000} +FASTAPI_WORKERS=${FASTAPI_WORKERS:-1} -# map "service name" → "command to run" -declare -A SERVICES=( - [ari_manager]="python -m api.services.telephony.ari_manager" - [campaign_orchestrator]="python -m api.services.campaign.campaign_orchestrator" - [uvicorn]="uvicorn api.app:app --host 0.0.0.0 --port $FASTAPI_PORT --workers $FASTAPI_WORKERS" +############################################################################### +### 2) Define services +############################################################################### + +# Map "service name" → "command to run" +# Using arrays for bash 3.2 compatibility +SERVICE_NAMES=( + "ari_manager" + "campaign_orchestrator" + "uvicorn" ) -# Add ARQ workers dynamically based on ARQ_WORKERS environment variable +SERVICE_COMMANDS=( + "python -m api.services.telephony.ari_manager" + "python -m api.services.campaign.campaign_orchestrator" + "uvicorn api.app:app --host 0.0.0.0 --port $FASTAPI_PORT --workers $FASTAPI_WORKERS" +) + +# Add ARQ workers dynamically for ((i=1; i<=ARQ_WORKERS; i++)); do - SERVICES[arq$i]="python -m arq api.tasks.arq.WorkerSettings --custom-log-dict api.tasks.arq.LOG_CONFIG" + SERVICE_NAMES+=("arq$i") + SERVICE_COMMANDS+=("python -m arq api.tasks.arq.WorkerSettings --custom-log-dict api.tasks.arq.LOG_CONFIG") done -### 2) Activate virtual environment ######################################### -source ${VENV_PATH}/bin/activate +############################################################################### +### 3) Activate virtual environment +############################################################################### + +if [[ -d "$VENV_PATH" && -f "$VENV_PATH/bin/activate" ]]; then + source "$VENV_PATH/bin/activate" + echo "Virtual environment activated: $VENV_PATH" +else + echo "Warning: Virtual environment not found at $VENV_PATH" + echo "Continuing without virtual environment activation..." +fi + +############################################################################### +### 4) Stop old services +############################################################################### -### 3) Stop old services (only via PID files) ################################# mkdir -p "$RUN_DIR" -for name in "${!SERVICES[@]}"; do +for name in "${SERVICE_NAMES[@]}"; do pidfile="$RUN_DIR/$name.pid" + if [[ -f $pidfile ]]; then oldpid=$(<"$pidfile") - if kill -0 "$oldpid"; then + + if kill -0 "$oldpid" 2>/dev/null; then echo "Stopping $name (PID $oldpid and its process group)…" + # Kill the entire process group (negative PID) - # First try SIGTERM - kill -TERM -"$oldpid" || kill -TERM "$oldpid" || true + kill -TERM -"$oldpid" 2>/dev/null || kill -TERM "$oldpid" 2>/dev/null || true sleep 4 - # If still running, use SIGKILL - if kill -0 "$oldpid"; then + + if kill -0 "$oldpid" 2>/dev/null; then echo "⚠️ $name did not exit cleanly, forcing stop..." - kill -KILL -"$oldpid" || kill -KILL "$oldpid" || true + kill -KILL -"$oldpid" 2>/dev/null || kill -KILL "$oldpid" 2>/dev/null || true sleep 1 fi fi + rm -f "$pidfile" else echo "No PID file for $name, skipping stop." @@ -74,14 +105,19 @@ done # Clean up any port tracking files for uvicorn rm -f "$RUN_DIR/uvicorn.port" "$RUN_DIR/uvicorn_new.port" "$RUN_DIR/uvicorn_old.pid" -### 4) Run migrations ######################################################### -alembic -c api/alembic.ini upgrade head +############################################################################### +### 5) Run migrations +############################################################################### -### 5) Prepare logs ########################################################### -mkdir -p "$BASE_LOG_DIR" -mkdir -p "$LOG_DIR" +alembic -c "$BASE_DIR/api/alembic.ini" upgrade head -# Remove old symlink if it exists and create new one +############################################################################### +### 6) Prepare logs +############################################################################### + +mkdir -p "$BASE_LOG_DIR" "$LOG_DIR" + +# Remove old symlink and create a new one if [[ -L "$LATEST_LINK" ]]; then rm "$LATEST_LINK" fi @@ -90,33 +126,37 @@ ln -s "$TIMESTAMP" "$LATEST_LINK" echo "Log directory: $LOG_DIR" echo "Latest symlink: $LATEST_LINK -> $TIMESTAMP" -### 7) Start services ######################################################### -for name in "${!SERVICES[@]}"; do - cmd=${SERVICES[$name]} +############################################################################### +### 7) Start services +############################################################################### + +for i in "${!SERVICE_NAMES[@]}"; do + name="${SERVICE_NAMES[$i]}" + cmd="${SERVICE_COMMANDS[$i]}" echo "→ Starting $name" - - # Export LOG_FILE_PATH for this specific service - export LOG_FILE_PATH="$LOG_DIR/$name.log" - - # Start in new process group with setsid - # Each service gets its own LOG_FILE_PATH environment variable - setsid nohup bash -c "LOG_FILE_PATH='$LOG_DIR/$name.log' $cmd" >/dev/null 2>&1 & - - # Get the PID of the setsid process + + ( + cd "$BASE_DIR" + export LOG_FILE_PATH="$LOG_DIR/$name.log" + exec $cmd >>"$LOG_DIR/$name.log" 2>&1 + ) & + pid=$! echo $pid >"$RUN_DIR/$name.pid" - - # For uvicorn, also save the port for rolling updates + echo " Started with PID $pid" + if [[ "$name" == "uvicorn" ]]; then echo "$FASTAPI_PORT" >"$RUN_DIR/uvicorn.port" fi done -disown -a -### 8) Summary ################################################################# +############################################################################### +### 8) Summary +############################################################################### + echo echo "──────────────────────────────────────────────────" -for name in "${!SERVICES[@]}"; do +for name in "${SERVICE_NAMES[@]}"; do pid=$(<"$RUN_DIR/$name.pid") echo "✓ $name (PID $pid) → $LOG_DIR/$name.log" done diff --git a/ui/src/app/campaigns/CsvUploadSelector.tsx b/ui/src/app/campaigns/CsvUploadSelector.tsx new file mode 100644 index 0000000..eb67f29 --- /dev/null +++ b/ui/src/app/campaigns/CsvUploadSelector.tsx @@ -0,0 +1,139 @@ +'use client'; + +import { useRef, useState } from 'react'; +import { toast } from 'sonner'; + +import { Button } from '@/components/ui/button'; +import { Label } from '@/components/ui/label'; +import logger from '@/lib/logger'; + +interface CsvUploadSelectorProps { + accessToken: string; + onFileUploaded: (fileKey: string, fileName: string) => void; + selectedFileName?: string; +} + +interface PresignedUploadUrlResponse { + upload_url: string; + file_key: string; + expires_in: number; +} + +const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB + +export default function CsvUploadSelector({ accessToken, onFileUploaded, selectedFileName }: CsvUploadSelectorProps) { + const [uploading, setUploading] = useState(false); + const [uploadProgress, setUploadProgress] = useState(0); + const fileInputRef = useRef(null); + + const handleFileSelect = async (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) return; + + // Validate file type + if (!file.name.endsWith('.csv')) { + toast.error('Please select a CSV file'); + return; + } + + // Validate file size + if (file.size > MAX_FILE_SIZE) { + toast.error('File size must be less than 10MB'); + return; + } + + setUploading(true); + setUploadProgress(0); + + try { + // Step 1: Request presigned upload URL + logger.info('Requesting presigned upload URL for:', file.name); + const presignedResponse = await fetch('/api/v1/s3/presigned-upload-url', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + file_name: file.name, + file_size: file.size, + content_type: 'text/csv', + }), + }); + + if (!presignedResponse.ok) { + const error = await presignedResponse.json(); + throw new Error(error.detail || 'Failed to get upload URL'); + } + + const presignedData: PresignedUploadUrlResponse = await presignedResponse.json(); + logger.info('Received presigned URL, uploading file...'); + + // Step 2: Upload file directly to S3/MinIO + const uploadResponse = await fetch(presignedData.upload_url, { + method: 'PUT', + body: file, + headers: { + 'Content-Type': 'text/csv', + }, + }); + + if (!uploadResponse.ok) { + throw new Error('Failed to upload file to storage'); + } + + setUploadProgress(100); + logger.info('File uploaded successfully, file_key:', presignedData.file_key); + + // Step 3: Notify parent with file_key + onFileUploaded(presignedData.file_key, file.name); + toast.success(`File uploaded: ${file.name}`); + } catch (error) { + logger.error('Error uploading CSV:', error); + toast.error(error instanceof Error ? error.message : 'Failed to upload CSV file'); + } finally { + setUploading(false); + setUploadProgress(0); + // Reset file input + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + } + }; + + const handleButtonClick = () => { + fileInputRef.current?.click(); + }; + + return ( +
+ +
+ + + {selectedFileName && !uploading && ( +
+ Selected: + {selectedFileName} +
+ )} +
+

+ Upload a CSV file with contact data. Must include phone_number, first_name, and last_name columns. Max 10MB. +

+
+ ); +} diff --git a/ui/src/app/campaigns/[campaignId]/page.tsx b/ui/src/app/campaigns/[campaignId]/page.tsx index 2763946..effe8b3 100644 --- a/ui/src/app/campaigns/[campaignId]/page.tsx +++ b/ui/src/app/campaigns/[campaignId]/page.tsx @@ -8,6 +8,7 @@ import { toast } from 'sonner'; import { getCampaignApiV1CampaignCampaignIdGet, getCampaignRunsApiV1CampaignCampaignIdRunsGet, + getCampaignSourceDownloadUrlApiV1CampaignCampaignIdSourceDownloadUrlGet, pauseCampaignApiV1CampaignCampaignIdPausePost, resumeCampaignApiV1CampaignCampaignIdResumePost, startCampaignApiV1CampaignCampaignIdStartPost} from '@/client/sdk.gen'; @@ -125,6 +126,33 @@ export default function CampaignDetailPage() { } }; + // Handle CSV download + const handleDownloadCsv = async () => { + if (!user || !campaign || campaign.source_type !== 'csv') return; + + try { + const accessToken = await getAccessToken(); + const response = await getCampaignSourceDownloadUrlApiV1CampaignCampaignIdSourceDownloadUrlGet({ + path: { + campaign_id: campaignId, + }, + headers: { + 'Authorization': `Bearer ${accessToken}`, + } + }); + + if (response.data?.download_url) { + // Open download URL in new tab + window.open(response.data.download_url, '_blank'); + } else { + toast.error('Failed to get download URL'); + } + } catch (error) { + console.error('Failed to download CSV:', error); + toast.error('Failed to download CSV file'); + } + }; + // Handle start campaign const handleStart = async () => { if (!user) return; @@ -354,16 +382,27 @@ export default function CampaignDetailPage() {
{campaign.source_type.replace('-', ' ')}
-
Source Sheet
+
+ {campaign.source_type === 'csv' ? 'Source File' : 'Source Sheet'} +
- - {campaign.source_id} - + {campaign.source_type === 'csv' ? ( + + ) : ( + + {campaign.source_id} + + )}
diff --git a/ui/src/app/campaigns/new/page.tsx b/ui/src/app/campaigns/new/page.tsx index 5608450..84e588d 100644 --- a/ui/src/app/campaigns/new/page.tsx +++ b/ui/src/app/campaigns/new/page.tsx @@ -20,6 +20,7 @@ import { } from '@/components/ui/select'; import { useAuth } from '@/lib/auth'; +import CsvUploadSelector from '../CsvUploadSelector'; import GoogleSheetSelector from '../GoogleSheetSelector'; export default function NewCampaignPage() { @@ -29,7 +30,9 @@ export default function NewCampaignPage() { // Form state const [campaignName, setCampaignName] = useState(''); const [selectedWorkflowId, setSelectedWorkflowId] = useState(''); - const [selectedSheetUrl, setSelectedSheetUrl] = useState(''); + const [sourceType, setSourceType] = useState<'google-sheet' | 'csv'>('csv'); + const [sourceId, setSourceId] = useState(''); + const [selectedFileName, setSelectedFileName] = useState(''); const [isSubmitting, setIsSubmitting] = useState(false); const [userAccessToken, setUserAccessToken] = useState(''); @@ -78,7 +81,7 @@ export default function NewCampaignPage() { const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); - if (!campaignName || !selectedWorkflowId || !selectedSheetUrl) { + if (!campaignName || !selectedWorkflowId || !sourceId) { toast.error('Please fill in all fields'); return; } @@ -91,7 +94,8 @@ export default function NewCampaignPage() { body: { name: campaignName, workflow_id: parseInt(selectedWorkflowId), - source_id: selectedSheetUrl, + source_type: sourceType, + source_id: sourceId, }, headers: { 'Authorization': `Bearer ${accessToken}`, @@ -117,7 +121,13 @@ export default function NewCampaignPage() { // Handle sheet selection const handleSheetSelected = (sheetUrl: string) => { - setSelectedSheetUrl(sheetUrl); + setSourceId(sheetUrl); + }; + + // Handle CSV file upload + const handleFileUploaded = (fileKey: string, fileName: string) => { + setSourceId(fileKey); + setSelectedFileName(fileName); }; return ( @@ -191,20 +201,52 @@ export default function NewCampaignPage() {

- Select the workflow to execute for each row in the spreadsheet + Select the workflow to execute for each row in the data source

- +
+ + +

+ Choose where your contact data is stored +

+
+ + {sourceType === 'google-sheet' ? ( + + ) : ( + + )}
diff --git a/ui/src/client/sdk.gen.ts b/ui/src/client/sdk.gen.ts index 0d8cf5a..a01e7d9 100644 --- a/ui/src/client/sdk.gen.ts +++ b/ui/src/client/sdk.gen.ts @@ -3,7 +3,7 @@ import type { Client,Options as ClientOptions, TDataShape } from '@hey-api/client-fetch'; import { client as _heyApiClient } from './client.gen'; -import type { ArchiveApiKeyApiV1UserApiKeysApiKeyIdDeleteData, ArchiveApiKeyApiV1UserApiKeysApiKeyIdDeleteError, ArchiveApiKeyApiV1UserApiKeysApiKeyIdDeleteResponse, ArchiveServiceKeyApiV1UserServiceKeysServiceKeyIdDeleteData, ArchiveServiceKeyApiV1UserServiceKeysServiceKeyIdDeleteError, CreateApiKeyApiV1UserApiKeysPostData, CreateApiKeyApiV1UserApiKeysPostError, CreateApiKeyApiV1UserApiKeysPostResponse, CreateCampaignApiV1CampaignCreatePostData, CreateCampaignApiV1CampaignCreatePostError, CreateCampaignApiV1CampaignCreatePostResponse, CreateLoadTestApiV1LooptalkLoadTestsPostData, CreateLoadTestApiV1LooptalkLoadTestsPostError, CreateLoadTestApiV1LooptalkLoadTestsPostResponse, CreateServiceKeyApiV1UserServiceKeysPostData, CreateServiceKeyApiV1UserServiceKeysPostError, CreateServiceKeyApiV1UserServiceKeysPostResponse, CreateSessionApiV1IntegrationSessionPostData, CreateSessionApiV1IntegrationSessionPostError, CreateSessionApiV1IntegrationSessionPostResponse, CreateTestSessionApiV1LooptalkTestSessionsPostData, CreateTestSessionApiV1LooptalkTestSessionsPostError, CreateTestSessionApiV1LooptalkTestSessionsPostResponse, CreateWorkflowApiV1WorkflowCreateDefinitionPostData, CreateWorkflowApiV1WorkflowCreateDefinitionPostError, CreateWorkflowApiV1WorkflowCreateDefinitionPostResponse, CreateWorkflowFromTemplateApiV1WorkflowCreateTemplatePostData, CreateWorkflowFromTemplateApiV1WorkflowCreateTemplatePostError, CreateWorkflowFromTemplateApiV1WorkflowCreateTemplatePostResponse, CreateWorkflowRunApiV1WorkflowWorkflowIdRunsPostData, CreateWorkflowRunApiV1WorkflowWorkflowIdRunsPostError, CreateWorkflowRunApiV1WorkflowWorkflowIdRunsPostResponse, DuplicateWorkflowTemplateApiV1WorkflowTemplatesDuplicatePostData, DuplicateWorkflowTemplateApiV1WorkflowTemplatesDuplicatePostError, DuplicateWorkflowTemplateApiV1WorkflowTemplatesDuplicatePostResponse, GetActiveTestsApiV1LooptalkActiveTestsGetData, GetActiveTestsApiV1LooptalkActiveTestsGetError, GetApiKeysApiV1UserApiKeysGetData, GetApiKeysApiV1UserApiKeysGetError, GetApiKeysApiV1UserApiKeysGetResponse, GetAuthUserApiV1UserAuthUserGetData, GetAuthUserApiV1UserAuthUserGetError, GetAuthUserApiV1UserAuthUserGetResponse, GetCampaignApiV1CampaignCampaignIdGetData, GetCampaignApiV1CampaignCampaignIdGetError, GetCampaignApiV1CampaignCampaignIdGetResponse, GetCampaignProgressApiV1CampaignCampaignIdProgressGetData, GetCampaignProgressApiV1CampaignCampaignIdProgressGetError, GetCampaignProgressApiV1CampaignCampaignIdProgressGetResponse, GetCampaignRunsApiV1CampaignCampaignIdRunsGetData, GetCampaignRunsApiV1CampaignCampaignIdRunsGetError, GetCampaignRunsApiV1CampaignCampaignIdRunsGetResponse, GetCampaignsApiV1CampaignGetData, GetCampaignsApiV1CampaignGetError, GetCampaignsApiV1CampaignGetResponse, GetCurrentPeriodUsageApiV1OrganizationsUsageCurrentPeriodGetData, GetCurrentPeriodUsageApiV1OrganizationsUsageCurrentPeriodGetError, GetCurrentPeriodUsageApiV1OrganizationsUsageCurrentPeriodGetResponse, GetDailyReportApiV1OrganizationsReportsDailyGetData, GetDailyReportApiV1OrganizationsReportsDailyGetError, GetDailyReportApiV1OrganizationsReportsDailyGetResponse, GetDailyRunsDetailApiV1OrganizationsReportsDailyRunsGetData, GetDailyRunsDetailApiV1OrganizationsReportsDailyRunsGetError, GetDailyRunsDetailApiV1OrganizationsReportsDailyRunsGetResponse, GetDailyUsageBreakdownApiV1OrganizationsUsageDailyBreakdownGetData, GetDailyUsageBreakdownApiV1OrganizationsUsageDailyBreakdownGetError, GetDailyUsageBreakdownApiV1OrganizationsUsageDailyBreakdownGetResponse, GetDefaultConfigurationsApiV1UserConfigurationsDefaultsGetData, GetDefaultConfigurationsApiV1UserConfigurationsDefaultsGetResponse, GetFileMetadataApiV1S3FileMetadataGetData, GetFileMetadataApiV1S3FileMetadataGetError, GetFileMetadataApiV1S3FileMetadataGetResponse, GetIntegrationAccessTokenApiV1IntegrationIntegrationIdAccessTokenGetData, GetIntegrationAccessTokenApiV1IntegrationIntegrationIdAccessTokenGetError, GetIntegrationAccessTokenApiV1IntegrationIntegrationIdAccessTokenGetResponse, GetIntegrationsApiV1IntegrationGetData, GetIntegrationsApiV1IntegrationGetError, GetIntegrationsApiV1IntegrationGetResponse, GetLoadTestStatsApiV1LooptalkLoadTestsLoadTestGroupIdStatsGetData, GetLoadTestStatsApiV1LooptalkLoadTestsLoadTestGroupIdStatsGetError, GetLoadTestStatsApiV1LooptalkLoadTestsLoadTestGroupIdStatsGetResponse, GetServiceKeysApiV1UserServiceKeysGetData, GetServiceKeysApiV1UserServiceKeysGetError, GetServiceKeysApiV1UserServiceKeysGetResponse, GetSignedUrlApiV1S3SignedUrlGetData, GetSignedUrlApiV1S3SignedUrlGetError, GetSignedUrlApiV1S3SignedUrlGetResponse, GetTelephonyConfigurationApiV1OrganizationsTelephonyConfigGetData, GetTelephonyConfigurationApiV1OrganizationsTelephonyConfigGetError, GetTelephonyConfigurationApiV1OrganizationsTelephonyConfigGetResponse, GetTestSessionApiV1LooptalkTestSessionsTestSessionIdGetData, GetTestSessionApiV1LooptalkTestSessionsTestSessionIdGetError, GetTestSessionApiV1LooptalkTestSessionsTestSessionIdGetResponse, GetTestSessionConversationApiV1LooptalkTestSessionsTestSessionIdConversationGetData, GetTestSessionConversationApiV1LooptalkTestSessionsTestSessionIdConversationGetError, GetUsageHistoryApiV1OrganizationsUsageRunsGetData, GetUsageHistoryApiV1OrganizationsUsageRunsGetError, GetUsageHistoryApiV1OrganizationsUsageRunsGetResponse, GetUserConfigurationsApiV1UserConfigurationsUserGetData, GetUserConfigurationsApiV1UserConfigurationsUserGetError, GetUserConfigurationsApiV1UserConfigurationsUserGetResponse, GetWorkflowApiV1WorkflowFetchWorkflowIdGetData, GetWorkflowApiV1WorkflowFetchWorkflowIdGetError, GetWorkflowApiV1WorkflowFetchWorkflowIdGetResponse, GetWorkflowOptionsApiV1OrganizationsReportsWorkflowsGetData, GetWorkflowOptionsApiV1OrganizationsReportsWorkflowsGetError, GetWorkflowOptionsApiV1OrganizationsReportsWorkflowsGetResponse, GetWorkflowRunApiV1WorkflowWorkflowIdRunsRunIdGetData, GetWorkflowRunApiV1WorkflowWorkflowIdRunsRunIdGetError, GetWorkflowRunApiV1WorkflowWorkflowIdRunsRunIdGetResponse, GetWorkflowRunsApiV1SuperuserWorkflowRunsGetData, GetWorkflowRunsApiV1SuperuserWorkflowRunsGetError, GetWorkflowRunsApiV1SuperuserWorkflowRunsGetResponse, GetWorkflowRunsApiV1WorkflowWorkflowIdRunsGetData, GetWorkflowRunsApiV1WorkflowWorkflowIdRunsGetError, GetWorkflowRunsApiV1WorkflowWorkflowIdRunsGetResponse, GetWorkflowsApiV1WorkflowFetchGetData, GetWorkflowsApiV1WorkflowFetchGetError, GetWorkflowsApiV1WorkflowFetchGetResponse, GetWorkflowsSummaryApiV1WorkflowSummaryGetData, GetWorkflowsSummaryApiV1WorkflowSummaryGetError, GetWorkflowsSummaryApiV1WorkflowSummaryGetResponse, GetWorkflowTemplatesApiV1WorkflowTemplatesGetData, GetWorkflowTemplatesApiV1WorkflowTemplatesGetResponse, HealthApiV1HealthGetData,ImpersonateApiV1SuperuserImpersonatePostData, ImpersonateApiV1SuperuserImpersonatePostError, ImpersonateApiV1SuperuserImpersonatePostResponse, InitiateCallApiV1TwilioInitiateCallPostData, InitiateCallApiV1TwilioInitiateCallPostError, ListTestSessionsApiV1LooptalkTestSessionsGetData, ListTestSessionsApiV1LooptalkTestSessionsGetError, ListTestSessionsApiV1LooptalkTestSessionsGetResponse, OfferApiV1PipecatRtcOfferPostData, OfferApiV1PipecatRtcOfferPostError, PauseCampaignApiV1CampaignCampaignIdPausePostData, PauseCampaignApiV1CampaignCampaignIdPausePostError, PauseCampaignApiV1CampaignCampaignIdPausePostResponse, ReactivateApiKeyApiV1UserApiKeysApiKeyIdReactivatePutData, ReactivateApiKeyApiV1UserApiKeysApiKeyIdReactivatePutError, ReactivateApiKeyApiV1UserApiKeysApiKeyIdReactivatePutResponse, ReactivateServiceKeyApiV1UserServiceKeysServiceKeyIdReactivatePutData, ReactivateServiceKeyApiV1UserServiceKeysServiceKeyIdReactivatePutError, ResumeCampaignApiV1CampaignCampaignIdResumePostData, ResumeCampaignApiV1CampaignCampaignIdResumePostError, ResumeCampaignApiV1CampaignCampaignIdResumePostResponse, SaveTelephonyConfigurationApiV1OrganizationsTelephonyConfigPostData, SaveTelephonyConfigurationApiV1OrganizationsTelephonyConfigPostError, SetAdminCommentApiV1SuperuserWorkflowRunsRunIdCommentPostData, SetAdminCommentApiV1SuperuserWorkflowRunsRunIdCommentPostError, SetAdminCommentApiV1SuperuserWorkflowRunsRunIdCommentPostResponse, StartCampaignApiV1CampaignCampaignIdStartPostData, StartCampaignApiV1CampaignCampaignIdStartPostError, StartCampaignApiV1CampaignCampaignIdStartPostResponse, StartTestSessionApiV1LooptalkTestSessionsTestSessionIdStartPostData, StartTestSessionApiV1LooptalkTestSessionsTestSessionIdStartPostError, StopTestSessionApiV1LooptalkTestSessionsTestSessionIdStopPostData, StopTestSessionApiV1LooptalkTestSessionsTestSessionIdStopPostError, UpdateIntegrationApiV1IntegrationIntegrationIdPutData, UpdateIntegrationApiV1IntegrationIntegrationIdPutError, UpdateIntegrationApiV1IntegrationIntegrationIdPutResponse, UpdateUserConfigurationsApiV1UserConfigurationsUserPutData, UpdateUserConfigurationsApiV1UserConfigurationsUserPutError, UpdateUserConfigurationsApiV1UserConfigurationsUserPutResponse, UpdateWorkflowApiV1WorkflowWorkflowIdPutData, UpdateWorkflowApiV1WorkflowWorkflowIdPutError, UpdateWorkflowApiV1WorkflowWorkflowIdPutResponse, UpdateWorkflowStatusApiV1WorkflowWorkflowIdStatusPutData, UpdateWorkflowStatusApiV1WorkflowWorkflowIdStatusPutError, UpdateWorkflowStatusApiV1WorkflowWorkflowIdStatusPutResponse, ValidateUserConfigurationsApiV1UserConfigurationsUserValidateGetData, ValidateUserConfigurationsApiV1UserConfigurationsUserValidateGetError, ValidateUserConfigurationsApiV1UserConfigurationsUserValidateGetResponse, ValidateWorkflowApiV1WorkflowWorkflowIdValidatePostData, ValidateWorkflowApiV1WorkflowWorkflowIdValidatePostError, ValidateWorkflowApiV1WorkflowWorkflowIdValidatePostResponse } from './types.gen'; +import type { ArchiveApiKeyApiV1UserApiKeysApiKeyIdDeleteData, ArchiveApiKeyApiV1UserApiKeysApiKeyIdDeleteError, ArchiveApiKeyApiV1UserApiKeysApiKeyIdDeleteResponse, ArchiveServiceKeyApiV1UserServiceKeysServiceKeyIdDeleteData, ArchiveServiceKeyApiV1UserServiceKeysServiceKeyIdDeleteError, CreateApiKeyApiV1UserApiKeysPostData, CreateApiKeyApiV1UserApiKeysPostError, CreateApiKeyApiV1UserApiKeysPostResponse, CreateCampaignApiV1CampaignCreatePostData, CreateCampaignApiV1CampaignCreatePostError, CreateCampaignApiV1CampaignCreatePostResponse, CreateLoadTestApiV1LooptalkLoadTestsPostData, CreateLoadTestApiV1LooptalkLoadTestsPostError, CreateLoadTestApiV1LooptalkLoadTestsPostResponse, CreateServiceKeyApiV1UserServiceKeysPostData, CreateServiceKeyApiV1UserServiceKeysPostError, CreateServiceKeyApiV1UserServiceKeysPostResponse, CreateSessionApiV1IntegrationSessionPostData, CreateSessionApiV1IntegrationSessionPostError, CreateSessionApiV1IntegrationSessionPostResponse, CreateTestSessionApiV1LooptalkTestSessionsPostData, CreateTestSessionApiV1LooptalkTestSessionsPostError, CreateTestSessionApiV1LooptalkTestSessionsPostResponse, CreateWorkflowApiV1WorkflowCreateDefinitionPostData, CreateWorkflowApiV1WorkflowCreateDefinitionPostError, CreateWorkflowApiV1WorkflowCreateDefinitionPostResponse, CreateWorkflowFromTemplateApiV1WorkflowCreateTemplatePostData, CreateWorkflowFromTemplateApiV1WorkflowCreateTemplatePostError, CreateWorkflowFromTemplateApiV1WorkflowCreateTemplatePostResponse, CreateWorkflowRunApiV1WorkflowWorkflowIdRunsPostData, CreateWorkflowRunApiV1WorkflowWorkflowIdRunsPostError, CreateWorkflowRunApiV1WorkflowWorkflowIdRunsPostResponse, DuplicateWorkflowTemplateApiV1WorkflowTemplatesDuplicatePostData, DuplicateWorkflowTemplateApiV1WorkflowTemplatesDuplicatePostError, DuplicateWorkflowTemplateApiV1WorkflowTemplatesDuplicatePostResponse, GetActiveTestsApiV1LooptalkActiveTestsGetData, GetActiveTestsApiV1LooptalkActiveTestsGetError, GetApiKeysApiV1UserApiKeysGetData, GetApiKeysApiV1UserApiKeysGetError, GetApiKeysApiV1UserApiKeysGetResponse, GetAuthUserApiV1UserAuthUserGetData, GetAuthUserApiV1UserAuthUserGetError, GetAuthUserApiV1UserAuthUserGetResponse, GetCampaignApiV1CampaignCampaignIdGetData, GetCampaignApiV1CampaignCampaignIdGetError, GetCampaignApiV1CampaignCampaignIdGetResponse, GetCampaignProgressApiV1CampaignCampaignIdProgressGetData, GetCampaignProgressApiV1CampaignCampaignIdProgressGetError, GetCampaignProgressApiV1CampaignCampaignIdProgressGetResponse, GetCampaignRunsApiV1CampaignCampaignIdRunsGetData, GetCampaignRunsApiV1CampaignCampaignIdRunsGetError, GetCampaignRunsApiV1CampaignCampaignIdRunsGetResponse, GetCampaignsApiV1CampaignGetData, GetCampaignsApiV1CampaignGetError, GetCampaignsApiV1CampaignGetResponse, GetCampaignSourceDownloadUrlApiV1CampaignCampaignIdSourceDownloadUrlGetData, GetCampaignSourceDownloadUrlApiV1CampaignCampaignIdSourceDownloadUrlGetError, GetCampaignSourceDownloadUrlApiV1CampaignCampaignIdSourceDownloadUrlGetResponse, GetCurrentPeriodUsageApiV1OrganizationsUsageCurrentPeriodGetData, GetCurrentPeriodUsageApiV1OrganizationsUsageCurrentPeriodGetError, GetCurrentPeriodUsageApiV1OrganizationsUsageCurrentPeriodGetResponse, GetDailyReportApiV1OrganizationsReportsDailyGetData, GetDailyReportApiV1OrganizationsReportsDailyGetError, GetDailyReportApiV1OrganizationsReportsDailyGetResponse, GetDailyRunsDetailApiV1OrganizationsReportsDailyRunsGetData, GetDailyRunsDetailApiV1OrganizationsReportsDailyRunsGetError, GetDailyRunsDetailApiV1OrganizationsReportsDailyRunsGetResponse, GetDailyUsageBreakdownApiV1OrganizationsUsageDailyBreakdownGetData, GetDailyUsageBreakdownApiV1OrganizationsUsageDailyBreakdownGetError, GetDailyUsageBreakdownApiV1OrganizationsUsageDailyBreakdownGetResponse, GetDefaultConfigurationsApiV1UserConfigurationsDefaultsGetData, GetDefaultConfigurationsApiV1UserConfigurationsDefaultsGetResponse, GetFileMetadataApiV1S3FileMetadataGetData, GetFileMetadataApiV1S3FileMetadataGetError, GetFileMetadataApiV1S3FileMetadataGetResponse, GetIntegrationAccessTokenApiV1IntegrationIntegrationIdAccessTokenGetData, GetIntegrationAccessTokenApiV1IntegrationIntegrationIdAccessTokenGetError, GetIntegrationAccessTokenApiV1IntegrationIntegrationIdAccessTokenGetResponse, GetIntegrationsApiV1IntegrationGetData, GetIntegrationsApiV1IntegrationGetError, GetIntegrationsApiV1IntegrationGetResponse, GetLoadTestStatsApiV1LooptalkLoadTestsLoadTestGroupIdStatsGetData, GetLoadTestStatsApiV1LooptalkLoadTestsLoadTestGroupIdStatsGetError, GetLoadTestStatsApiV1LooptalkLoadTestsLoadTestGroupIdStatsGetResponse, GetPresignedUploadUrlApiV1S3PresignedUploadUrlPostData, GetPresignedUploadUrlApiV1S3PresignedUploadUrlPostError, GetPresignedUploadUrlApiV1S3PresignedUploadUrlPostResponse, GetServiceKeysApiV1UserServiceKeysGetData, GetServiceKeysApiV1UserServiceKeysGetError, GetServiceKeysApiV1UserServiceKeysGetResponse, GetSignedUrlApiV1S3SignedUrlGetData, GetSignedUrlApiV1S3SignedUrlGetError, GetSignedUrlApiV1S3SignedUrlGetResponse, GetTelephonyConfigurationApiV1OrganizationsTelephonyConfigGetData, GetTelephonyConfigurationApiV1OrganizationsTelephonyConfigGetError, GetTelephonyConfigurationApiV1OrganizationsTelephonyConfigGetResponse, GetTestSessionApiV1LooptalkTestSessionsTestSessionIdGetData, GetTestSessionApiV1LooptalkTestSessionsTestSessionIdGetError, GetTestSessionApiV1LooptalkTestSessionsTestSessionIdGetResponse, GetTestSessionConversationApiV1LooptalkTestSessionsTestSessionIdConversationGetData, GetTestSessionConversationApiV1LooptalkTestSessionsTestSessionIdConversationGetError, GetUsageHistoryApiV1OrganizationsUsageRunsGetData, GetUsageHistoryApiV1OrganizationsUsageRunsGetError, GetUsageHistoryApiV1OrganizationsUsageRunsGetResponse, GetUserConfigurationsApiV1UserConfigurationsUserGetData, GetUserConfigurationsApiV1UserConfigurationsUserGetError, GetUserConfigurationsApiV1UserConfigurationsUserGetResponse, GetWorkflowApiV1WorkflowFetchWorkflowIdGetData, GetWorkflowApiV1WorkflowFetchWorkflowIdGetError, GetWorkflowApiV1WorkflowFetchWorkflowIdGetResponse, GetWorkflowOptionsApiV1OrganizationsReportsWorkflowsGetData, GetWorkflowOptionsApiV1OrganizationsReportsWorkflowsGetError, GetWorkflowOptionsApiV1OrganizationsReportsWorkflowsGetResponse, GetWorkflowRunApiV1WorkflowWorkflowIdRunsRunIdGetData, GetWorkflowRunApiV1WorkflowWorkflowIdRunsRunIdGetError, GetWorkflowRunApiV1WorkflowWorkflowIdRunsRunIdGetResponse, GetWorkflowRunsApiV1SuperuserWorkflowRunsGetData, GetWorkflowRunsApiV1SuperuserWorkflowRunsGetError, GetWorkflowRunsApiV1SuperuserWorkflowRunsGetResponse, GetWorkflowRunsApiV1WorkflowWorkflowIdRunsGetData, GetWorkflowRunsApiV1WorkflowWorkflowIdRunsGetError, GetWorkflowRunsApiV1WorkflowWorkflowIdRunsGetResponse, GetWorkflowsApiV1WorkflowFetchGetData, GetWorkflowsApiV1WorkflowFetchGetError, GetWorkflowsApiV1WorkflowFetchGetResponse, GetWorkflowsSummaryApiV1WorkflowSummaryGetData, GetWorkflowsSummaryApiV1WorkflowSummaryGetError, GetWorkflowsSummaryApiV1WorkflowSummaryGetResponse, GetWorkflowTemplatesApiV1WorkflowTemplatesGetData, GetWorkflowTemplatesApiV1WorkflowTemplatesGetResponse, HealthApiV1HealthGetData,ImpersonateApiV1SuperuserImpersonatePostData, ImpersonateApiV1SuperuserImpersonatePostError, ImpersonateApiV1SuperuserImpersonatePostResponse, InitiateCallApiV1TwilioInitiateCallPostData, InitiateCallApiV1TwilioInitiateCallPostError, ListTestSessionsApiV1LooptalkTestSessionsGetData, ListTestSessionsApiV1LooptalkTestSessionsGetError, ListTestSessionsApiV1LooptalkTestSessionsGetResponse, OfferApiV1PipecatRtcOfferPostData, OfferApiV1PipecatRtcOfferPostError, PauseCampaignApiV1CampaignCampaignIdPausePostData, PauseCampaignApiV1CampaignCampaignIdPausePostError, PauseCampaignApiV1CampaignCampaignIdPausePostResponse, ReactivateApiKeyApiV1UserApiKeysApiKeyIdReactivatePutData, ReactivateApiKeyApiV1UserApiKeysApiKeyIdReactivatePutError, ReactivateApiKeyApiV1UserApiKeysApiKeyIdReactivatePutResponse, ReactivateServiceKeyApiV1UserServiceKeysServiceKeyIdReactivatePutData, ReactivateServiceKeyApiV1UserServiceKeysServiceKeyIdReactivatePutError, ResumeCampaignApiV1CampaignCampaignIdResumePostData, ResumeCampaignApiV1CampaignCampaignIdResumePostError, ResumeCampaignApiV1CampaignCampaignIdResumePostResponse, SaveTelephonyConfigurationApiV1OrganizationsTelephonyConfigPostData, SaveTelephonyConfigurationApiV1OrganizationsTelephonyConfigPostError, SetAdminCommentApiV1SuperuserWorkflowRunsRunIdCommentPostData, SetAdminCommentApiV1SuperuserWorkflowRunsRunIdCommentPostError, SetAdminCommentApiV1SuperuserWorkflowRunsRunIdCommentPostResponse, StartCampaignApiV1CampaignCampaignIdStartPostData, StartCampaignApiV1CampaignCampaignIdStartPostError, StartCampaignApiV1CampaignCampaignIdStartPostResponse, StartTestSessionApiV1LooptalkTestSessionsTestSessionIdStartPostData, StartTestSessionApiV1LooptalkTestSessionsTestSessionIdStartPostError, StopTestSessionApiV1LooptalkTestSessionsTestSessionIdStopPostData, StopTestSessionApiV1LooptalkTestSessionsTestSessionIdStopPostError, UpdateIntegrationApiV1IntegrationIntegrationIdPutData, UpdateIntegrationApiV1IntegrationIntegrationIdPutError, UpdateIntegrationApiV1IntegrationIntegrationIdPutResponse, UpdateUserConfigurationsApiV1UserConfigurationsUserPutData, UpdateUserConfigurationsApiV1UserConfigurationsUserPutError, UpdateUserConfigurationsApiV1UserConfigurationsUserPutResponse, UpdateWorkflowApiV1WorkflowWorkflowIdPutData, UpdateWorkflowApiV1WorkflowWorkflowIdPutError, UpdateWorkflowApiV1WorkflowWorkflowIdPutResponse, UpdateWorkflowStatusApiV1WorkflowWorkflowIdStatusPutData, UpdateWorkflowStatusApiV1WorkflowWorkflowIdStatusPutError, UpdateWorkflowStatusApiV1WorkflowWorkflowIdStatusPutResponse, ValidateUserConfigurationsApiV1UserConfigurationsUserValidateGetData, ValidateUserConfigurationsApiV1UserConfigurationsUserValidateGetError, ValidateUserConfigurationsApiV1UserConfigurationsUserValidateGetResponse, ValidateWorkflowApiV1WorkflowWorkflowIdValidatePostData, ValidateWorkflowApiV1WorkflowWorkflowIdValidatePostError, ValidateWorkflowApiV1WorkflowWorkflowIdValidatePostResponse } from './types.gen'; export type Options = ClientOptions & { /** @@ -519,6 +519,20 @@ export const getCampaignProgressApiV1CampaignCampaignIdProgressGet = (options: Options) => { + return (options.client ?? _heyApiClient).get({ + url: '/api/v1/campaign/{campaign_id}/source-download-url', + ...options + }); +}; + /** * Get Integrations * Get all integrations for the user's selected organization. @@ -644,6 +658,32 @@ export const getFileMetadataApiV1S3FileMetadataGet = (options: Options) => { + return (options.client ?? _heyApiClient).post({ + url: '/api/v1/s3/presigned-upload-url', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }); +}; + /** * Get Service Keys * Get all service keys for the user's organization. diff --git a/ui/src/client/types.gen.ts b/ui/src/client/types.gen.ts index ccfd68c..39b7473 100644 --- a/ui/src/client/types.gen.ts +++ b/ui/src/client/types.gen.ts @@ -72,6 +72,11 @@ export type CampaignResponse = { completed_at: string | null; }; +export type CampaignSourceDownloadResponse = { + download_url: string; + expires_in: number; +}; + export type CampaignsResponse = { campaigns: Array; }; @@ -91,6 +96,7 @@ export type CreateApiKeyResponse = { export type CreateCampaignRequest = { name: string; workflow_id: number; + source_type: string; source_id: string; }; @@ -287,6 +293,27 @@ export type LoadTestStatsResponse = { }>; }; +export type PresignedUploadUrlRequest = { + /** + * CSV filename + */ + file_name: string; + /** + * File size in bytes (max 10MB) + */ + file_size: number; + /** + * File content type + */ + content_type?: string; +}; + +export type PresignedUploadUrlResponse = { + upload_url: string; + file_key: string; + expires_in: number; +}; + export type RtcOfferRequest = { pc_id: string | null; sdp: string; @@ -1758,6 +1785,40 @@ export type GetCampaignProgressApiV1CampaignCampaignIdProgressGetResponses = { export type GetCampaignProgressApiV1CampaignCampaignIdProgressGetResponse = GetCampaignProgressApiV1CampaignCampaignIdProgressGetResponses[keyof GetCampaignProgressApiV1CampaignCampaignIdProgressGetResponses]; +export type GetCampaignSourceDownloadUrlApiV1CampaignCampaignIdSourceDownloadUrlGetData = { + body?: never; + headers?: { + authorization?: string | null; + }; + path: { + campaign_id: number; + }; + query?: never; + url: '/api/v1/campaign/{campaign_id}/source-download-url'; +}; + +export type GetCampaignSourceDownloadUrlApiV1CampaignCampaignIdSourceDownloadUrlGetErrors = { + /** + * Not found + */ + 404: unknown; + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type GetCampaignSourceDownloadUrlApiV1CampaignCampaignIdSourceDownloadUrlGetError = GetCampaignSourceDownloadUrlApiV1CampaignCampaignIdSourceDownloadUrlGetErrors[keyof GetCampaignSourceDownloadUrlApiV1CampaignCampaignIdSourceDownloadUrlGetErrors]; + +export type GetCampaignSourceDownloadUrlApiV1CampaignCampaignIdSourceDownloadUrlGetResponses = { + /** + * Successful Response + */ + 200: CampaignSourceDownloadResponse; +}; + +export type GetCampaignSourceDownloadUrlApiV1CampaignCampaignIdSourceDownloadUrlGetResponse = GetCampaignSourceDownloadUrlApiV1CampaignCampaignIdSourceDownloadUrlGetResponses[keyof GetCampaignSourceDownloadUrlApiV1CampaignCampaignIdSourceDownloadUrlGetResponses]; + export type GetIntegrationsApiV1IntegrationGetData = { body?: never; headers?: { @@ -2028,6 +2089,38 @@ export type GetFileMetadataApiV1S3FileMetadataGetResponses = { export type GetFileMetadataApiV1S3FileMetadataGetResponse = GetFileMetadataApiV1S3FileMetadataGetResponses[keyof GetFileMetadataApiV1S3FileMetadataGetResponses]; +export type GetPresignedUploadUrlApiV1S3PresignedUploadUrlPostData = { + body: PresignedUploadUrlRequest; + headers?: { + authorization?: string | null; + }; + path?: never; + query?: never; + url: '/api/v1/s3/presigned-upload-url'; +}; + +export type GetPresignedUploadUrlApiV1S3PresignedUploadUrlPostErrors = { + /** + * Not found + */ + 404: unknown; + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type GetPresignedUploadUrlApiV1S3PresignedUploadUrlPostError = GetPresignedUploadUrlApiV1S3PresignedUploadUrlPostErrors[keyof GetPresignedUploadUrlApiV1S3PresignedUploadUrlPostErrors]; + +export type GetPresignedUploadUrlApiV1S3PresignedUploadUrlPostResponses = { + /** + * Successful Response + */ + 200: PresignedUploadUrlResponse; +}; + +export type GetPresignedUploadUrlApiV1S3PresignedUploadUrlPostResponse = GetPresignedUploadUrlApiV1S3PresignedUploadUrlPostResponses[keyof GetPresignedUploadUrlApiV1S3PresignedUploadUrlPostResponses]; + export type GetServiceKeysApiV1UserServiceKeysGetData = { body?: never; headers?: {