feat: add csv upload functionality for OSS (#29)

feat: add csv upload functionality
chore: remove redundant arq-worker from docker-compose
This commit is contained in:
Abhishek 2025-10-09 17:54:31 +05:30 committed by GitHub
parent 2633ff0a2a
commit 3babb5ced6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 941 additions and 234 deletions

1
.dockerignore Normal file
View file

@ -0,0 +1 @@
api/.env

View file

@ -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"]
CMD ["bash", "-c", "./scripts/start_services.sh && tail -f ./logs/latest/*.log"]

View file

@ -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",

View file

@ -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"

View file

@ -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

View file

@ -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)}"
)

View file

@ -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"
)

View file

@ -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")

View file

@ -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()

View file

@ -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()

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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__":

View file

@ -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

View file

@ -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:

View file

@ -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:

View file

@ -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"

View file

@ -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

View file

@ -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<HTMLInputElement>(null);
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
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 (
<div className="space-y-2">
<Label>CSV File</Label>
<div className="flex items-center gap-4">
<input
ref={fileInputRef}
type="file"
accept=".csv"
onChange={handleFileSelect}
className="hidden"
/>
<Button
type="button"
variant="outline"
onClick={handleButtonClick}
disabled={uploading}
>
{uploading ? `Uploading... ${uploadProgress}%` : 'Upload CSV File'}
</Button>
{selectedFileName && !uploading && (
<div className="flex-1 text-sm">
<span className="text-gray-600">Selected: </span>
<span className="text-blue-600">{selectedFileName}</span>
</div>
)}
</div>
<p className="text-sm text-gray-500">
Upload a CSV file with contact data. Must include phone_number, first_name, and last_name columns. Max 10MB.
</p>
</div>
);
}

View file

@ -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() {
<dd className="mt-1 capitalize">{campaign.source_type.replace('-', ' ')}</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">Source Sheet</dt>
<dt className="text-sm font-medium text-gray-500">
{campaign.source_type === 'csv' ? 'Source File' : 'Source Sheet'}
</dt>
<dd className="mt-1">
<a
href={campaign.source_id}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-800 hover:underline text-sm break-all"
>
{campaign.source_id}
</a>
{campaign.source_type === 'csv' ? (
<button
onClick={handleDownloadCsv}
className="text-blue-600 hover:text-blue-800 hover:underline text-sm break-all"
>
{campaign.source_id.split('/').pop()}
</button>
) : (
<a
href={campaign.source_id}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-800 hover:underline text-sm break-all"
>
{campaign.source_id}
</a>
)}
</dd>
</div>
<div>

View file

@ -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<string>('');
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<string>('');
@ -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() {
</SelectContent>
</Select>
<p className="text-sm text-gray-500">
Select the workflow to execute for each row in the spreadsheet
Select the workflow to execute for each row in the data source
</p>
</div>
<GoogleSheetSelector
accessToken={userAccessToken}
onSheetSelected={handleSheetSelected}
selectedSheetUrl={selectedSheetUrl}
/>
<div className="space-y-2">
<Label htmlFor="source-type">Data Source Type</Label>
<Select
value={sourceType}
onValueChange={(value) => {
setSourceType(value as 'google-sheet' | 'csv');
setSourceId('');
setSelectedFileName('');
}}
required
>
<SelectTrigger id="source-type">
<SelectValue placeholder="Select source type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="google-sheet">Google Sheet</SelectItem>
<SelectItem value="csv">CSV File</SelectItem>
</SelectContent>
</Select>
<p className="text-sm text-gray-500">
Choose where your contact data is stored
</p>
</div>
{sourceType === 'google-sheet' ? (
<GoogleSheetSelector
accessToken={userAccessToken}
onSheetSelected={handleSheetSelected}
selectedSheetUrl={sourceId}
/>
) : (
<CsvUploadSelector
accessToken={userAccessToken}
onFileUploaded={handleFileUploaded}
selectedFileName={selectedFileName}
/>
)}
<div className="flex gap-4 pt-4">
<Button
type="submit"
disabled={isSubmitting || !campaignName || !selectedWorkflowId || !selectedSheetUrl}
disabled={isSubmitting || !campaignName || !selectedWorkflowId || !sourceId}
>
{isSubmitting ? 'Creating...' : 'Create Campaign'}
</Button>

File diff suppressed because one or more lines are too long

View file

@ -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<CampaignResponse>;
};
@ -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?: {