Initial Commit 🚀 🚀

This commit is contained in:
Abhishek Kumar 2025-09-09 14:37:32 +05:30
commit 4f2a629340
444 changed files with 76863 additions and 0 deletions

0
api/routes/__init__.py Normal file
View file

347
api/routes/campaign.py Normal file
View file

@ -0,0 +1,347 @@
from datetime import datetime
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from api.db import db_client
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
router = APIRouter(prefix="/campaign")
class CreateCampaignRequest(BaseModel):
name: str = Field(..., min_length=1, max_length=255)
workflow_id: int
source_id: str # Sheet URL
class CampaignResponse(BaseModel):
id: int
name: str
workflow_id: int
workflow_name: str
state: str
source_type: str
source_id: str
total_rows: Optional[int]
processed_rows: int
failed_rows: int
created_at: datetime
started_at: Optional[datetime]
completed_at: Optional[datetime]
class CampaignsResponse(BaseModel):
campaigns: List[CampaignResponse]
class WorkflowRunResponse(BaseModel):
id: int
workflow_id: int
state: str
created_at: datetime
completed_at: Optional[datetime]
class CampaignProgressResponse(BaseModel):
campaign_id: int
state: str
total_rows: int
processed_rows: int
failed_calls: int
progress_percentage: float
source_sync: dict
rate_limit: int
started_at: Optional[datetime]
completed_at: Optional[datetime]
@router.post("/create")
async def create_campaign(
request: CreateCampaignRequest,
user: UserModel = Depends(get_user),
) -> CampaignResponse:
"""Create a new campaign"""
# Verify workflow exists and belongs to organization
workflow_name = await db_client.get_workflow_name(request.workflow_id, user.id)
if not workflow_name:
raise HTTPException(status_code=404, detail="Workflow not found")
campaign = await db_client.create_campaign(
name=request.name,
workflow_id=request.workflow_id,
source_type="google-sheet",
source_id=request.source_id,
user_id=user.id,
organization_id=user.selected_organization_id,
)
return CampaignResponse(
id=campaign.id,
name=campaign.name,
workflow_id=campaign.workflow_id,
workflow_name=workflow_name,
state=campaign.state,
source_type=campaign.source_type,
source_id=campaign.source_id,
total_rows=campaign.total_rows,
processed_rows=campaign.processed_rows,
failed_rows=campaign.failed_rows,
created_at=campaign.created_at,
started_at=campaign.started_at,
completed_at=campaign.completed_at,
)
@router.get("/")
async def get_campaigns(
user: UserModel = Depends(get_user),
) -> CampaignsResponse:
"""Get campaigns for user's organization"""
campaigns = await db_client.get_campaigns(user.selected_organization_id)
# Get workflow names for all campaigns
workflow_ids = list(set(c.workflow_id for c in campaigns))
workflows = await db_client.get_workflows_by_ids(
workflow_ids, user.selected_organization_id
)
workflow_map = {w.id: w.name for w in workflows}
campaign_responses = [
CampaignResponse(
id=c.id,
name=c.name,
workflow_id=c.workflow_id,
workflow_name=workflow_map.get(c.workflow_id, "Unknown"),
state=c.state,
source_type=c.source_type,
source_id=c.source_id,
total_rows=c.total_rows,
processed_rows=c.processed_rows,
failed_rows=c.failed_rows,
created_at=c.created_at,
started_at=c.started_at,
completed_at=c.completed_at,
)
for c in campaigns
]
return CampaignsResponse(campaigns=campaign_responses)
@router.get("/{campaign_id}")
async def get_campaign(
campaign_id: int,
user: UserModel = Depends(get_user),
) -> CampaignResponse:
"""Get campaign details"""
campaign = await db_client.get_campaign(campaign_id, user.selected_organization_id)
if not campaign:
raise HTTPException(status_code=404, detail="Campaign not found")
workflow_name = await db_client.get_workflow_name(campaign.workflow_id, user.id)
return CampaignResponse(
id=campaign.id,
name=campaign.name,
workflow_id=campaign.workflow_id,
workflow_name=workflow_name or "Unknown",
state=campaign.state,
source_type=campaign.source_type,
source_id=campaign.source_id,
total_rows=campaign.total_rows,
processed_rows=campaign.processed_rows,
failed_rows=campaign.failed_rows,
created_at=campaign.created_at,
started_at=campaign.started_at,
completed_at=campaign.completed_at,
)
@router.post("/{campaign_id}/start")
async def start_campaign(
campaign_id: int,
user: UserModel = Depends(get_user),
) -> CampaignResponse:
"""Start campaign execution"""
# Check if organization has TWILIO_PHONE_NUMBERS configured
twilio_config = await db_client.get_configuration(
user.selected_organization_id,
OrganizationConfigurationKey.TWILIO_PHONE_NUMBERS.value,
)
if (
not twilio_config
or not twilio_config.value
or not twilio_config.value.get("value")
):
raise HTTPException(
status_code=401,
detail="Your organisation is not allowed to make phone call. Contact founders@dograh.com for further support.",
)
# 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")
# Start the campaign using the runner service
try:
await campaign_runner_service.start_campaign(campaign_id)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
# Get updated campaign
campaign = await db_client.get_campaign(campaign_id, user.selected_organization_id)
workflow_name = await db_client.get_workflow_name(campaign.workflow_id, user.id)
return CampaignResponse(
id=campaign.id,
name=campaign.name,
workflow_id=campaign.workflow_id,
workflow_name=workflow_name or "Unknown",
state=campaign.state,
source_type=campaign.source_type,
source_id=campaign.source_id,
total_rows=campaign.total_rows,
processed_rows=campaign.processed_rows,
failed_rows=campaign.failed_rows,
created_at=campaign.created_at,
started_at=campaign.started_at,
completed_at=campaign.completed_at,
)
@router.post("/{campaign_id}/pause")
async def pause_campaign(
campaign_id: int,
user: UserModel = Depends(get_user),
) -> CampaignResponse:
"""Pause campaign execution"""
# 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")
# Pause the campaign using the runner service
try:
await campaign_runner_service.pause_campaign(campaign_id)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
# Get updated campaign
campaign = await db_client.get_campaign(campaign_id, user.selected_organization_id)
workflow_name = await db_client.get_workflow_name(campaign.workflow_id, user.id)
return CampaignResponse(
id=campaign.id,
name=campaign.name,
workflow_id=campaign.workflow_id,
workflow_name=workflow_name or "Unknown",
state=campaign.state,
source_type=campaign.source_type,
source_id=campaign.source_id,
total_rows=campaign.total_rows,
processed_rows=campaign.processed_rows,
failed_rows=campaign.failed_rows,
created_at=campaign.created_at,
started_at=campaign.started_at,
completed_at=campaign.completed_at,
)
@router.get("/{campaign_id}/runs")
async def get_campaign_runs(
campaign_id: int,
user: UserModel = Depends(get_user),
) -> List[WorkflowRunResponse]:
"""Get campaign workflow runs"""
runs = await db_client.get_campaign_runs(campaign_id, user.selected_organization_id)
return [
WorkflowRunResponse(
id=run.id,
workflow_id=run.workflow_id,
state="completed" if run.is_completed else "running",
created_at=run.created_at,
completed_at=run.created_at if run.is_completed else None,
)
for run in runs
]
@router.post("/{campaign_id}/resume")
async def resume_campaign(
campaign_id: int,
user: UserModel = Depends(get_user),
) -> CampaignResponse:
"""Resume a paused campaign"""
# Check if organization has TWILIO_PHONE_NUMBERS configured
twilio_config = await db_client.get_configuration(
user.selected_organization_id,
OrganizationConfigurationKey.TWILIO_PHONE_NUMBERS.value,
)
if (
not twilio_config
or not twilio_config.value
or not twilio_config.value.get("value")
):
raise HTTPException(
status_code=401,
detail="Your organisation is not allowed to make phone call. Contact founders@dograh.com for further support.",
)
# 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")
# Resume the campaign using the runner service
try:
await campaign_runner_service.resume_campaign(campaign_id)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
# Get updated campaign
campaign = await db_client.get_campaign(campaign_id, user.selected_organization_id)
workflow_name = await db_client.get_workflow_name(campaign.workflow_id, user.id)
return CampaignResponse(
id=campaign.id,
name=campaign.name,
workflow_id=campaign.workflow_id,
workflow_name=workflow_name or "Unknown",
state=campaign.state,
source_type=campaign.source_type,
source_id=campaign.source_id,
total_rows=campaign.total_rows,
processed_rows=campaign.processed_rows,
failed_rows=campaign.failed_rows,
created_at=campaign.created_at,
started_at=campaign.started_at,
completed_at=campaign.completed_at,
)
@router.get("/{campaign_id}/progress")
async def get_campaign_progress(
campaign_id: int,
user: UserModel = Depends(get_user),
) -> CampaignProgressResponse:
"""Get current campaign progress and statistics"""
# 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")
# Get progress from runner service
try:
progress = await campaign_runner_service.get_campaign_status(campaign_id)
return CampaignProgressResponse(**progress)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))

262
api/routes/integration.py Normal file
View file

@ -0,0 +1,262 @@
from dataclasses import dataclass
from typing import Any, Dict, List, Optional, TypedDict
from fastapi import APIRouter, Depends, HTTPException, Request
from loguru import logger
from pydantic import BaseModel
from api.db import db_client
from api.db.models import UserModel
from api.services.auth.depends import get_user
from api.services.integrations.nango import nango_service
router = APIRouter(prefix="/integration")
@dataclass
class IntegrationResponse:
id: int
integration_id: str
organisation_id: int
created_by: Optional[int]
provider: str
is_active: bool
created_at: str
action: str
provider_data: dict
class SessionResponse(TypedDict):
session_token: str
expires_at: str
class WebhookResponse(TypedDict):
status: str
message: str
class UpdateIntegrationRequest(BaseModel):
selected_files: List[Dict[str, Any]]
class AccessTokenResponse(BaseModel):
access_token: Optional[str]
refresh_token: Optional[str]
expires_at: Optional[str]
connection_id: str
def build_integration_response(integration) -> IntegrationResponse:
"""Build a standardized integration response with provider-specific data."""
provider_data = {}
if integration.provider == "google-sheet":
# For Google Sheets, include selected_files
provider_data["selected_files"] = integration.connection_details.get(
"selected_files", []
)
elif integration.provider == "slack":
# For Slack, include channel information
channel = integration.connection_details.get("connection_config", {}).get(
"incoming_webhook.channel"
)
if channel:
provider_data["channel"] = channel
return IntegrationResponse(
id=integration.id,
integration_id=integration.integration_id,
organisation_id=integration.organisation_id,
created_by=integration.created_by,
provider=integration.provider,
is_active=integration.is_active,
created_at=integration.created_at.isoformat(),
action=integration.action,
provider_data=provider_data,
)
@router.get("/")
async def get_integrations(
user: UserModel = Depends(get_user),
) -> list[IntegrationResponse]:
"""
Get all integrations for the user's selected organization.
Returns:
List of integrations associated with the user's selected organization
"""
if not user.selected_organization_id:
raise HTTPException(
status_code=400, detail="No organization selected for the user"
)
integrations = await db_client.get_integrations_by_organization_id(
user.selected_organization_id
)
return [build_integration_response(integration) for integration in integrations]
@router.post("/session")
async def create_session(
user: UserModel = Depends(get_user),
) -> SessionResponse:
"""
Create a Nango session for the user's selected organization.
Returns:
Session token and ID for the created session
"""
if not user.selected_organization_id:
raise HTTPException(
status_code=400, detail="No organization selected for the user"
)
try:
session_data = await nango_service.create_session(
user_id=str(user.id), organization_id=user.selected_organization_id
)
return {
"session_token": session_data["data"]["token"],
"expires_at": session_data["data"]["expires_at"],
}
except ValueError as e:
raise HTTPException(status_code=500, detail=str(e))
except Exception as e:
raise HTTPException(
status_code=500, detail=f"Failed to create session: {str(e)}"
)
@router.put("/{integration_id}")
async def update_integration(
integration_id: int,
request: UpdateIntegrationRequest,
user: UserModel = Depends(get_user),
) -> IntegrationResponse:
"""
Update an integration's selected files (for Google Sheets).
Args:
integration_id: The ID of the integration to update
request: The update request containing selected files
user: The authenticated user
Returns:
Updated integration details
"""
if not user.selected_organization_id:
raise HTTPException(
status_code=400, detail="No organization selected for the user"
)
# Get the integration first to verify ownership
integrations = await db_client.get_integrations_by_organization_id(
user.selected_organization_id
)
integration = next((i for i in integrations if i.id == integration_id), None)
if not integration:
raise HTTPException(status_code=404, detail="Integration not found")
# Only allow updating selected_files for google-sheet provider
if integration.provider != "google-sheet":
raise HTTPException(
status_code=400,
detail="This endpoint only supports updating Google Sheet integrations",
)
# Update the connection_details with the new selected_files
updated_connection_details = integration.connection_details.copy()
updated_connection_details["selected_files"] = request.selected_files
# Update the integration
updated_integration = await db_client.update_integration_connection_details(
integration_id=integration_id, connection_details=updated_connection_details
)
if not updated_integration:
raise HTTPException(status_code=500, detail="Failed to update integration")
return build_integration_response(updated_integration)
@router.get("/{integration_id}/access-token")
async def get_integration_access_token(
integration_id: int,
user: UserModel = Depends(get_user),
) -> AccessTokenResponse:
"""
Get the latest access token for an integration from Nango.
Args:
integration_id: The ID of the integration
user: The authenticated user
Returns:
Dict containing access token and expiration info
"""
if not user.selected_organization_id:
raise HTTPException(
status_code=400, detail="No organization selected for the user"
)
# Get the integration to verify ownership and get connection details
integrations = await db_client.get_integrations_by_organization_id(
user.selected_organization_id
)
integration = next((i for i in integrations if i.id == integration_id), None)
if not integration:
raise HTTPException(status_code=404, detail="Integration not found")
try:
# Fetch the latest access token from Nango
token_data = await nango_service.get_access_token(
connection_id=integration.integration_id,
provider_config_key=integration.provider,
)
# Extract relevant fields
return AccessTokenResponse(
access_token=token_data.get("credentials", {}).get("access_token"),
refresh_token=token_data.get("credentials", {}).get("refresh_token"),
expires_at=token_data.get("credentials", {}).get("expires_at"),
connection_id=integration.integration_id,
)
except Exception as e:
logger.error(f"Failed to get access token: {str(e)}")
raise HTTPException(
status_code=500, detail=f"Failed to fetch access token: {str(e)}"
)
@router.post("/webhook", include_in_schema=False)
async def handle_nango_webhook(
request: Request,
) -> WebhookResponse:
"""
Handle Nango integration webhook requests.
Processes webhook events from Nango when integrations are created/updated
and stores the integration details in the database.
Args:
request: The raw FastAPI request object
Returns:
WebhookResponse with status and message
"""
raw_body = await request.body()
# Get signature from headers (you may need to adjust the header name)
signature = request.headers.get("X-Nango-Signature")
# Use the nango service to process the webhook
result = await nango_service.process_webhook(raw_body, signature)
return result

316
api/routes/looptalk.py Normal file
View file

@ -0,0 +1,316 @@
from datetime import datetime
from typing import Any, Dict, List, Optional
from fastapi import (
APIRouter,
BackgroundTasks,
Depends,
HTTPException,
WebSocket,
)
from pydantic import BaseModel, Field
from api.db import db_client
from api.db.models import UserModel
from api.services.auth.depends import get_user
from api.services.looptalk.orchestrator import LoopTalkTestOrchestrator
router = APIRouter(prefix="/looptalk")
# Request/Response Models
class CreateTestSessionRequest(BaseModel):
name: str
actor_workflow_id: int
adversary_workflow_id: int
config: Dict[str, Any] = Field(default_factory=dict)
class StartTestSessionRequest(BaseModel):
test_session_id: int
class CreateLoadTestRequest(BaseModel):
name_prefix: str
actor_workflow_id: int
adversary_workflow_id: int
test_count: int = Field(ge=1, le=10)
config: Dict[str, Any] = Field(default_factory=dict)
class TestSessionResponse(BaseModel):
id: int
name: str
status: str
actor_workflow_id: int
adversary_workflow_id: int
load_test_group_id: Optional[str]
test_index: Optional[int]
config: Dict[str, Any]
results: Optional[Dict[str, Any]]
error: Optional[str]
created_at: datetime
started_at: Optional[datetime]
completed_at: Optional[datetime]
class ConversationResponse(BaseModel):
id: int
test_session_id: int
duration_seconds: Optional[int]
actor_recording_url: Optional[str]
adversary_recording_url: Optional[str]
combined_recording_url: Optional[str]
transcript: Optional[Dict[str, Any]]
metrics: Optional[Dict[str, Any]]
created_at: datetime
ended_at: Optional[datetime]
# Note: Turn tracking is handled by Langfuse, not exposed via API
class LoadTestStatsResponse(BaseModel):
total: int
pending: int
running: int
completed: int
failed: int
sessions: List[Dict[str, Any]]
# Singleton orchestrator instance
_orchestrator: Optional[LoopTalkTestOrchestrator] = None
def get_orchestrator() -> LoopTalkTestOrchestrator:
"""Get or create the LoopTalk orchestrator instance."""
global _orchestrator
if _orchestrator is None:
_orchestrator = LoopTalkTestOrchestrator(db_client=db_client)
return _orchestrator
@router.post("/test-sessions", response_model=TestSessionResponse)
async def create_test_session(
request: CreateTestSessionRequest, user: UserModel = Depends(get_user)
):
"""Create a new LoopTalk test session."""
# Verify user has access to both workflows
actor_workflow = await db_client.get_workflow(request.actor_workflow_id, user.id)
if not actor_workflow:
raise HTTPException(status_code=404, detail="Actor workflow not found")
adversary_workflow = await db_client.get_workflow(
request.adversary_workflow_id, user.id
)
if not adversary_workflow:
raise HTTPException(status_code=404, detail="Adversary workflow not found")
# Create test session
test_session = await db_client.create_test_session(
organization_id=user.selected_organization_id,
name=request.name,
actor_workflow_id=request.actor_workflow_id,
adversary_workflow_id=request.adversary_workflow_id,
config=request.config,
)
return test_session
@router.get("/test-sessions", response_model=List[TestSessionResponse])
async def list_test_sessions(
status: Optional[str] = None,
load_test_group_id: Optional[str] = None,
limit: int = 20,
offset: int = 0,
user: UserModel = Depends(get_user),
):
"""List LoopTalk test sessions."""
test_sessions = await db_client.list_test_sessions(
organization_id=user.selected_organization_id,
status=status,
load_test_group_id=load_test_group_id,
limit=limit,
offset=offset,
)
return test_sessions
@router.get("/test-sessions/{test_session_id}", response_model=TestSessionResponse)
async def get_test_session(test_session_id: int, user: UserModel = Depends(get_user)):
"""Get a specific test session."""
test_session = await db_client.get_test_session(
test_session_id=test_session_id, organization_id=user.selected_organization_id
)
if not test_session:
raise HTTPException(status_code=404, detail="Test session not found")
return test_session
@router.post("/test-sessions/{test_session_id}/start")
async def start_test_session(
test_session_id: int,
background_tasks: BackgroundTasks,
user: UserModel = Depends(get_user),
orchestrator: LoopTalkTestOrchestrator = Depends(get_orchestrator),
):
"""Start a LoopTalk test session."""
# Verify test session exists and user has access
test_session = await db_client.get_test_session(
test_session_id=test_session_id, organization_id=user.selected_organization_id
)
if not test_session:
raise HTTPException(status_code=404, detail="Test session not found")
if test_session.status != "pending":
raise HTTPException(
status_code=400,
detail=f"Test session is {test_session.status}, not pending",
)
# Start test session in background
background_tasks.add_task(
orchestrator.start_test_session,
test_session_id=test_session_id,
organization_id=user.selected_organization_id,
)
return {"message": "Test session starting", "test_session_id": test_session_id}
@router.post("/test-sessions/{test_session_id}/stop")
async def stop_test_session(
test_session_id: int,
user: UserModel = Depends(get_user),
orchestrator: LoopTalkTestOrchestrator = Depends(get_orchestrator),
):
"""Stop a running test session."""
# Verify test session exists and user has access
test_session = await db_client.get_test_session(
test_session_id=test_session_id, organization_id=user.selected_organization_id
)
if not test_session:
raise HTTPException(status_code=404, detail="Test session not found")
if test_session.status != "running":
raise HTTPException(
status_code=400,
detail=f"Test session is {test_session.status}, not running",
)
# Stop test session
result = await orchestrator.stop_test_session(test_session_id=test_session_id)
return result
@router.get("/test-sessions/{test_session_id}/conversation")
async def get_test_session_conversation(
test_session_id: int, user: UserModel = Depends(get_user)
):
"""Get conversation details for a test session."""
# Verify test session exists and user has access
test_session = await db_client.get_test_session(
test_session_id=test_session_id, organization_id=user.selected_organization_id
)
if not test_session:
raise HTTPException(status_code=404, detail="Test session not found")
# Get conversation
if test_session.conversations:
conversation = test_session.conversations[
0
] # For now, one conversation per session
# Note: Turn details are available in Langfuse, not here
return {
"conversation": conversation,
"message": "Turn details are tracked in Langfuse",
}
return {"conversation": None}
@router.post("/load-tests", response_model=Dict[str, Any])
async def create_load_test(
request: CreateLoadTestRequest,
background_tasks: BackgroundTasks,
user: UserModel = Depends(get_user),
orchestrator: LoopTalkTestOrchestrator = Depends(get_orchestrator),
):
"""Create and start a load test."""
# Verify user has access to both workflows
actor_workflow = await db_client.get_workflow(request.actor_workflow_id, user.id)
if not actor_workflow:
raise HTTPException(status_code=404, detail="Actor workflow not found")
adversary_workflow = await db_client.get_workflow(
request.adversary_workflow_id, user.id
)
if not adversary_workflow:
raise HTTPException(status_code=404, detail="Adversary workflow not found")
# Start load test in background
result = await orchestrator.start_load_test(
organization_id=user.selected_organization_id,
name_prefix=request.name_prefix,
actor_workflow_id=request.actor_workflow_id,
adversary_workflow_id=request.adversary_workflow_id,
config=request.config,
test_count=request.test_count,
)
return result
@router.get(
"/load-tests/{load_test_group_id}/stats", response_model=LoadTestStatsResponse
)
async def get_load_test_stats(
load_test_group_id: str, user: UserModel = Depends(get_user)
):
"""Get statistics for a load test group."""
stats = await db_client.get_load_test_group_stats(
load_test_group_id=load_test_group_id,
organization_id=user.selected_organization_id,
)
return stats
@router.get("/active-tests")
async def get_active_tests(
orchestrator: LoopTalkTestOrchestrator = Depends(get_orchestrator),
user: UserModel = Depends(get_user),
):
"""Get information about currently active test sessions."""
return orchestrator.get_active_test_info()
@router.websocket("/test-sessions/{test_session_id}/audio-stream")
async def audio_stream_websocket(
websocket: WebSocket,
test_session_id: int,
role: str = "mixed", # "actor", "adversary", or "mixed"
token: Optional[str] = None,
):
"""WebSocket endpoint for real-time audio streaming from LoopTalk test sessions."""
# TODO: to be implemented
pass

39
api/routes/main.py Normal file
View file

@ -0,0 +1,39 @@
from fastapi import APIRouter
from loguru import logger
from api.routes.campaign import router as campaign_router
from api.routes.integration import router as integration_router
from api.routes.looptalk import router as looptalk_router
from api.routes.organization_usage import router as organization_usage_router
from api.routes.reports import router as reports_router
from api.routes.rtc_offer import router as rtc_offer_router
from api.routes.s3_signed_url import router as s3_router
from api.routes.service_keys import router as service_keys_router
from api.routes.superuser import router as superuser_router
from api.routes.twilio import router as twilio_router
from api.routes.user import router as user_router
from api.routes.workflow import router as workflow_router
router = APIRouter(
tags=["main"],
responses={404: {"description": "Not found"}},
)
router.include_router(twilio_router)
router.include_router(rtc_offer_router)
router.include_router(superuser_router)
router.include_router(workflow_router)
router.include_router(user_router)
router.include_router(campaign_router)
router.include_router(integration_router)
router.include_router(s3_router)
router.include_router(service_keys_router)
router.include_router(looptalk_router)
router.include_router(organization_usage_router)
router.include_router(reports_router)
@router.get("/health")
async def health():
logger.debug("Health endpoint called")
return {"message": "OK"}

View file

@ -0,0 +1,180 @@
import json
from datetime import datetime, timedelta
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from api.db import db_client
from api.db.models import UserModel
from api.services.auth.depends import get_user
router = APIRouter(prefix="/organizations")
class CurrentUsageResponse(BaseModel):
period_start: str
period_end: str
used_dograh_tokens: float
quota_dograh_tokens: int
percentage_used: float
next_refresh_date: str
quota_enabled: bool
total_duration_seconds: int
# New USD fields
used_amount_usd: Optional[float] = None
quota_amount_usd: Optional[float] = None
currency: Optional[str] = None
price_per_second_usd: Optional[float] = None
class WorkflowRunUsageResponse(BaseModel):
id: int
workflow_id: int
workflow_name: Optional[str]
name: str
created_at: str
dograh_token_usage: float
call_duration_seconds: int
recording_url: Optional[str] = None
transcript_url: Optional[str] = None
phone_number: Optional[str] = None
disposition: Optional[str] = None
initial_context: Optional[Dict[str, Any]] = None
gathered_context: Optional[Dict[str, Any]] = None
# New USD field
charge_usd: Optional[float] = None
class UsageHistoryResponse(BaseModel):
runs: List[WorkflowRunUsageResponse]
total_dograh_tokens: float
total_duration_seconds: int
total_count: int
page: int
limit: int
total_pages: int
class DailyUsageItem(BaseModel):
date: str
minutes: float
cost_usd: Optional[float] = None
dograh_tokens: float
call_count: int
class DailyUsageBreakdownResponse(BaseModel):
breakdown: List[DailyUsageItem]
total_minutes: float
total_cost_usd: Optional[float] = None
total_dograh_tokens: float
currency: Optional[str] = None
@router.get("/usage/current-period", response_model=CurrentUsageResponse)
async def get_current_period_usage(user: UserModel = Depends(get_user)):
"""Get current billing period usage for the user's organization."""
if not user.selected_organization_id:
raise HTTPException(status_code=400, detail="No organization selected")
try:
usage = await db_client.get_current_usage(user.selected_organization_id)
return usage
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/usage/runs", response_model=UsageHistoryResponse)
async def get_usage_history(
start_date: Optional[str] = Query(None, description="ISO format date string"),
end_date: Optional[str] = Query(None, description="ISO format date string"),
page: int = Query(1, ge=1),
limit: int = Query(50, ge=1, le=100),
filters: Optional[str] = Query(None, description="JSON string of filters"),
user: UserModel = Depends(get_user),
):
"""Get paginated workflow runs with usage for the organization."""
if not user.selected_organization_id:
raise HTTPException(status_code=400, detail="No organization selected")
# Parse dates if provided
start_dt = datetime.fromisoformat(start_date) if start_date else None
end_dt = datetime.fromisoformat(end_date) if end_date else None
# Parse filters if provided
parsed_filters = None
if filters:
try:
parsed_filters = json.loads(filters)
except json.JSONDecodeError:
raise HTTPException(status_code=400, detail="Invalid filters format")
try:
offset = (page - 1) * limit
(
runs,
total_count,
total_tokens,
total_duration,
) = await db_client.get_usage_history(
user.selected_organization_id,
start_date=start_dt,
end_date=end_dt,
limit=limit,
offset=offset,
filters=parsed_filters,
)
total_pages = (total_count + limit - 1) // limit
return {
"runs": runs,
"total_dograh_tokens": total_tokens,
"total_duration_seconds": total_duration,
"total_count": total_count,
"page": page,
"limit": limit,
"total_pages": total_pages,
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/usage/daily-breakdown", response_model=DailyUsageBreakdownResponse)
async def get_daily_usage_breakdown(
days: int = Query(7, ge=1, le=30, description="Number of days to include"),
user: UserModel = Depends(get_user),
):
"""Get daily usage breakdown for the last N days. Only available for organizations with pricing."""
if not user.selected_organization_id:
raise HTTPException(status_code=400, detail="No organization selected")
try:
# Get organization to check if it has pricing
org = await db_client.get_organization_by_id(user.selected_organization_id)
if not org or org.price_per_second_usd is None:
raise HTTPException(
status_code=400,
detail="Daily breakdown is only available for organizations with pricing configured",
)
# Calculate date range
end_date = datetime.now()
start_date = end_date - timedelta(days=days - 1)
start_date = start_date.replace(hour=0, minute=0, second=0, microsecond=0)
# Get daily breakdown
breakdown = await db_client.get_daily_usage_breakdown(
user.selected_organization_id,
start_date,
end_date,
org.price_per_second_usd,
user_id=user.id,
)
return breakdown
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))

132
api/routes/reports.py Normal file
View file

@ -0,0 +1,132 @@
from datetime import datetime
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from api.db.models import UserModel
from api.services.auth.depends import get_user
from api.services.reports import DailyReportService
router = APIRouter(prefix="/organizations/reports")
class DailyReportResponse(BaseModel):
date: str
timezone: str
workflow_id: Optional[int]
metrics: Dict[str, int]
disposition_distribution: List[Dict[str, Any]]
call_duration_distribution: List[Dict[str, Any]]
class WorkflowOption(BaseModel):
id: int
name: str
class WorkflowRunDetail(BaseModel):
phone_number: str
disposition: str
duration_seconds: float
workflow_id: int
run_id: int
workflow_name: str
created_at: str
@router.get("/daily", response_model=DailyReportResponse)
async def get_daily_report(
date: str = Query(..., description="Date in YYYY-MM-DD format"),
timezone: str = Query(..., description="IANA timezone (e.g., 'America/New_York')"),
workflow_id: Optional[int] = Query(
None, description="Optional workflow ID to filter by"
),
user: UserModel = Depends(get_user),
) -> DailyReportResponse:
"""
Get daily report for the specified date and timezone.
If workflow_id is provided, filters results to that specific workflow.
If workflow_id is None, includes all workflows for the organization.
"""
if not user.selected_organization_id:
raise HTTPException(status_code=400, detail="No organization selected")
# Validate date format
try:
datetime.strptime(date, "%Y-%m-%d")
except ValueError:
raise HTTPException(
status_code=400, detail="Invalid date format. Use YYYY-MM-DD"
)
report_service = DailyReportService()
try:
report = await report_service.get_daily_report(
organization_id=user.selected_organization_id,
date=date,
timezone=timezone,
workflow_id=workflow_id,
)
return DailyReportResponse(**report)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/workflows", response_model=List[WorkflowOption])
async def get_workflow_options(
user: UserModel = Depends(get_user),
) -> List[WorkflowOption]:
"""
Get all workflows for the user's organization.
Used to populate the workflow selector dropdown in the reports page.
"""
if not user.selected_organization_id:
raise HTTPException(status_code=400, detail="No organization selected")
report_service = DailyReportService()
workflows = await report_service.get_workflows_for_organization(
organization_id=user.selected_organization_id
)
return [WorkflowOption(**w) for w in workflows]
@router.get("/daily/runs", response_model=List[WorkflowRunDetail])
async def get_daily_runs_detail(
date: str = Query(..., description="Date in YYYY-MM-DD format"),
timezone: str = Query(..., description="IANA timezone (e.g., 'America/New_York')"),
workflow_id: Optional[int] = Query(
None, description="Optional workflow ID to filter by"
),
user: UserModel = Depends(get_user),
) -> List[WorkflowRunDetail]:
"""
Get detailed workflow runs for the specified date.
Used for CSV export functionality.
"""
if not user.selected_organization_id:
raise HTTPException(status_code=400, detail="No organization selected")
# Validate date format
try:
datetime.strptime(date, "%Y-%m-%d")
except ValueError:
raise HTTPException(
status_code=400, detail="Invalid date format. Use YYYY-MM-DD"
)
report_service = DailyReportService()
try:
runs = await report_service.get_daily_runs_detail(
organization_id=user.selected_organization_id,
date=date,
timezone=timezone,
workflow_id=workflow_id,
)
return [WorkflowRunDetail(**run) for run in runs]
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))

77
api/routes/rtc_offer.py Normal file
View file

@ -0,0 +1,77 @@
from typing import Dict
from fastapi import APIRouter, BackgroundTasks, Depends
from loguru import logger
from pipecat.transports.network.webrtc_connection import SmallWebRTCConnection
from pipecat.utils.context import set_current_run_id
from pydantic import BaseModel
from api.db.models import UserModel
from api.services.auth.depends import get_user
from api.services.pipecat.run_pipeline import run_pipeline_smallwebrtc
router = APIRouter(prefix="/pipecat")
pcs_map: Dict[str, SmallWebRTCConnection] = {}
ice_servers = ["stun:stun.l.google.com:19302"]
class RTCOfferRequest(BaseModel):
pc_id: str | None
sdp: str
type: str
workflow_id: int
workflow_run_id: int
restart_pc: bool = False
call_context_vars: dict | None = None
@router.post("/rtc-offer")
async def offer(
request: RTCOfferRequest,
background_tasks: BackgroundTasks,
user: UserModel = Depends(get_user),
):
pc_id = request.pc_id
if pc_id and pc_id in pcs_map:
# Ensure run_id context is available for logs even when reusing an existing PC.
set_current_run_id(request.workflow_run_id)
pipecat_connection = pcs_map[pc_id]
logger.info(f"Reusing existing connection for pc_id: {pc_id}")
await pipecat_connection.renegotiate(
sdp=request.sdp,
type=request.type,
restart_pc=request.restart_pc,
)
else:
# Set the run_id *before* creating the SmallWebRTCConnection so that all
# async tasks and event-handler coroutines spawned inside the
# constructor inherit the correct context variable value. Otherwise the
# default ("NA") leaks into the log output produced by those tasks.
set_current_run_id(request.workflow_run_id)
pipecat_connection = SmallWebRTCConnection(ice_servers)
await pipecat_connection.initialize(sdp=request.sdp, type=request.type)
@pipecat_connection.event_handler("closed")
async def handle_disconnected(webrtc_connection: SmallWebRTCConnection):
logger.info(
f"In pipecat connection closed handler. Popping peer connection pc_id: {webrtc_connection.pc_id} from pcs_map"
)
pcs_map.pop(webrtc_connection.pc_id, None)
background_tasks.add_task(
run_pipeline_smallwebrtc,
pipecat_connection,
request.workflow_id,
request.workflow_run_id,
user.id,
request.call_context_vars or {},
)
answer = pipecat_connection.get_answer()
pcs_map[answer["pc_id"]] = pipecat_connection
return answer

219
api/routes/s3_signed_url.py Normal file
View file

@ -0,0 +1,219 @@
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 api.db import db_client
from api.enums import StorageBackend
from api.services.auth.depends import get_user
from api.services.storage import get_storage_for_backend, storage_fs
class S3SignedUrlResponse(TypedDict):
url: str
expires_in: int
class FileMetadataResponse(TypedDict):
key: str
metadata: Optional[Dict[str, Any]]
router = APIRouter(prefix="/s3", tags=["s3"])
async def _validate_and_extract_workflow_run_id(
key: str, allow_special_paths: bool = False
) -> Optional[int]:
"""Validate the S3 key format and extract workflow_run_id if present.
Args:
key: S3 object key
allow_special_paths: If True, allows looptalk/voicemail paths
Returns:
workflow_run_id if found, None for special paths (when allowed)
Raises:
HTTPException: If key format is invalid
"""
if key.startswith("transcripts/") and key.endswith(".txt"):
run_id_str = key[len("transcripts/") : -4] # strip prefix & suffix
elif key.startswith("recordings/") and key.endswith(".wav"):
run_id_str = key[len("recordings/") : -4]
elif allow_special_paths and (
key.startswith("looptalk/") or key.startswith("voicemail_detections/")
):
# Allow looptalk and voicemail paths for debugging (only if explicitly allowed)
return None # Skip validation for these paths
else:
raise HTTPException(status_code=400, detail="Invalid key format")
if not run_id_str.isdigit():
raise HTTPException(status_code=400, detail="Invalid workflow_run_id in key")
return int(run_id_str)
async def _authorize_and_get_workflow_run(
run_id: Optional[int], user, require_workflow_run: bool = True
) -> Optional[Any]:
"""Authorize access to workflow run and retrieve it.
Args:
run_id: Workflow run ID (can be None for special paths)
user: Current user from auth
require_workflow_run: If True, raises exception when run not found
Returns:
WorkflowRunModel or None
Raises:
HTTPException: If access is denied
"""
if run_id is None:
return None
workflow_run = None
if not user.is_superuser:
# Regular users: Use organization_id to check access (security constraint)
workflow_run = await db_client.get_workflow_run(
run_id, organization_id=user.selected_organization_id
)
if not workflow_run and require_workflow_run:
raise HTTPException(
status_code=403, detail="Access denied for this workflow run"
)
else:
# Superusers: Use get_workflow_run_by_id (no user/org constraint needed)
workflow_run = await db_client.get_workflow_run_by_id(run_id)
return workflow_run
@router.get(
"/signed-url",
response_model=S3SignedUrlResponse,
summary="Generate a signed S3 URL",
)
async def get_signed_url(
key: Annotated[str, Query(description="S3 object key")],
expires_in: int = 3600,
inline: bool = False,
user=Depends(get_user),
):
"""Return a short-lived signed URL for a transcript or recording file stored on S3.
Access Control:
* Superusers can request any key.
* Regular users can only request resources belonging to **their** workflow runs.
"""
# Validate key and extract workflow_run_id (don't allow special paths for signed URLs)
run_id = await _validate_and_extract_workflow_run_id(key, allow_special_paths=False)
if run_id is None:
raise HTTPException(status_code=400, detail="Invalid key format")
# Authorize and get workflow run
workflow_run = await _authorize_and_get_workflow_run(run_id, user)
# ------------------------------------------------------------------
# 3. Generate the signed URL using the correct storage backend
# ------------------------------------------------------------------
try:
# Use the storage backend recorded when the file was uploaded
if (
workflow_run
and hasattr(workflow_run, "storage_backend")
and workflow_run.storage_backend
):
backend = workflow_run.storage_backend
storage = get_storage_for_backend(backend)
logger.info(
f"DOWNLOAD: Using stored {backend} (value: {backend}) for signed URL generation - workflow_run_id: {run_id}, key: {key}"
)
else:
# Fallback to current storage for legacy records without storage_backend
storage = storage_fs
current_backend = StorageBackend.get_current_backend()
logger.warning(
f"DOWNLOAD: No storage_backend found for workflow run {run_id}, falling back to current {current_backend.name} - key: {key}"
)
url = await storage.aget_signed_url(
key, expiration=expires_in, force_inline=inline
)
if not url:
raise HTTPException(status_code=500, detail="Failed to generate signed URL")
# Log successful URL generation
backend_info = (
f"stored {backend}"
if workflow_run
and hasattr(workflow_run, "storage_backend")
and workflow_run.storage_backend
else f"current {StorageBackend.get_current_backend().name}"
)
logger.info(
f"Successfully generated signed URL using {backend_info} - expires in {expires_in}s"
)
return {"url": url, "expires_in": expires_in}
except ClientError as exc:
logger.error(f"Error generating signed URL: {exc}")
raise HTTPException(status_code=500, detail="Failed to generate signed URL")
@router.get(
"/file-metadata",
response_model=FileMetadataResponse,
summary="Get file metadata for debugging",
)
async def get_file_metadata(
key: Annotated[str, Query(description="S3 object key")],
user=Depends(get_user),
):
"""Get file metadata including creation timestamp for debugging.
Access Control:
* Superusers can request any key.
* Regular users can only request resources belonging to **their** workflow runs.
"""
# Validate key and extract workflow_run_id (allow special paths for metadata)
run_id = await _validate_and_extract_workflow_run_id(key, allow_special_paths=True)
# Authorize and get workflow run (for special paths, run_id might be None)
workflow_run = await _authorize_and_get_workflow_run(
run_id, user, require_workflow_run=False
)
# ------------------------------------------------------------------
# 3. Get file metadata using the correct storage backend
# ------------------------------------------------------------------
try:
# Use the storage backend recorded when the file was uploaded
if (
workflow_run
and hasattr(workflow_run, "storage_backend")
and workflow_run.storage_backend
):
backend = workflow_run.storage_backend
storage = get_storage_for_backend(backend)
logger.info(
f"METADATA: Using stored {backend} for metadata request - key: {key}"
)
else:
# Fallback to current storage for legacy records or looptalk/voicemail files
storage = storage_fs
current_backend = StorageBackend.get_current_backend()
logger.warning(
f"METADATA: No storage_backend found, using current {current_backend.name} for metadata request - key: {key}"
)
metadata = await storage.aget_file_metadata(key)
return {"key": key, "metadata": metadata}
except Exception as exc:
logger.error(f"Error getting file metadata: {exc}")
raise HTTPException(status_code=500, detail="Failed to get file metadata")

141
api/routes/service_keys.py Normal file
View file

@ -0,0 +1,141 @@
from typing import List
from fastapi import APIRouter, Depends, HTTPException
from loguru import logger
from api.constants import DEPLOYMENT_MODE
from api.db.models import UserModel
from api.schemas.service_key import (
CreateServiceKeyRequest,
CreateServiceKeyResponse,
ServiceKeyResponse,
)
from api.services.auth.depends import get_user
from api.services.mps_service_key_client import mps_service_key_client
router = APIRouter()
@router.get("/user/service-keys", response_model=List[ServiceKeyResponse])
async def get_service_keys(
include_archived: bool = False,
user: UserModel = Depends(get_user),
):
"""Get all service keys for the user's organization."""
try:
# For OSS mode, use provider_id as created_by
# For authenticated mode, use organization_id
if DEPLOYMENT_MODE == "oss":
service_keys = await mps_service_key_client.get_service_keys(
created_by=str(user.provider_id),
include_archived=include_archived,
)
else:
if not user.selected_organization_id:
raise HTTPException(status_code=400, detail="No organization selected")
service_keys = await mps_service_key_client.get_service_keys(
organization_id=user.selected_organization_id,
include_archived=include_archived,
)
return [ServiceKeyResponse.model_validate(key) for key in service_keys]
except Exception as e:
logger.error(f"Failed to get service keys: {e}")
raise HTTPException(status_code=500, detail="Failed to retrieve service keys")
@router.post("/user/service-keys", response_model=CreateServiceKeyResponse)
async def create_service_key(
request: CreateServiceKeyRequest,
user: UserModel = Depends(get_user),
):
"""Create a new service key for the user's organization."""
try:
# For OSS mode, don't pass organization_id
# For authenticated mode, pass organization_id
if DEPLOYMENT_MODE == "oss":
result = await mps_service_key_client.create_service_key(
name=request.name,
created_by=str(user.provider_id),
expires_in_days=request.expires_in_days or 90,
description=f"Service key: {request.name}",
)
else:
if not user.selected_organization_id:
raise HTTPException(status_code=400, detail="No organization selected")
result = await mps_service_key_client.create_service_key(
name=request.name,
organization_id=user.selected_organization_id,
created_by=str(user.provider_id),
expires_in_days=request.expires_in_days or 90,
description=f"Service key for organization {user.selected_organization_id}",
)
return CreateServiceKeyResponse.model_validate(result)
except Exception as e:
logger.error(f"Failed to create service key: {e}")
raise HTTPException(
status_code=500,
detail=f"Failed to create service key: {str(e)}",
)
@router.delete("/user/service-keys/{service_key_id}")
async def archive_service_key(
service_key_id: str, # Changed from int to str since MPS uses string IDs
user: UserModel = Depends(get_user),
):
"""Archive a service key."""
try:
# For OSS mode, use provider_id as created_by for validation
# For authenticated mode, use organization_id for validation
if DEPLOYMENT_MODE == "oss":
success = await mps_service_key_client.archive_service_key(
key_id=service_key_id,
created_by=str(user.provider_id),
)
else:
if not user.selected_organization_id:
raise HTTPException(status_code=400, detail="No organization selected")
success = await mps_service_key_client.archive_service_key(
key_id=service_key_id,
organization_id=user.selected_organization_id,
)
if not success:
raise HTTPException(
status_code=404,
detail="Service key not found, already archived, or access denied",
)
return {"message": "Service key archived successfully"}
except Exception as e:
logger.error(f"Failed to archive service key: {e}")
raise HTTPException(
status_code=500,
detail=f"Failed to archive service key: {str(e)}",
)
@router.put("/user/service-keys/{service_key_id}/reactivate")
async def reactivate_service_key(
service_key_id: str, # Changed from int to str since MPS uses string IDs
user: UserModel = Depends(get_user), # Kept for consistency but not used
):
"""
Reactivate an archived service key.
Note: This endpoint is provided for API compatibility but service key
reactivation is not supported by MPS. Once archived, a service key
cannot be reactivated and a new key must be created instead.
"""
# MPS does not support reactivation of archived service keys
raise HTTPException(
status_code=501, # Not Implemented
detail="Service key reactivation is not supported. Once a service key is archived, it cannot be reactivated. Please create a new service key instead.",
)

45
api/routes/stasis_rtp.py Normal file
View file

@ -0,0 +1,45 @@
import random
from loguru import logger
from pipecat.utils.context import set_current_run_id
from api.db import db_client
from api.enums import WorkflowRunMode
from api.services.pipecat.run_pipeline import run_pipeline_ari_stasis
from api.services.telephony.stasis_rtp_connection import StasisRTPConnection
async def on_stasis_call(call: StasisRTPConnection, call_context_vars: dict):
workflow_id = call_context_vars.get("workflow_id") or call_context_vars.get(
"campaign_id"
)
user_id = call_context_vars.get("user_id")
assert workflow_id is not None
assert user_id is not None
try:
workflow_id = int(workflow_id)
user_id = int(user_id)
except ValueError:
logger.error(f"Invalid workflow ID or user ID: {workflow_id} or {user_id}")
return
workflow_run_name = f"WR-ARI-{random.randint(1000, 9999)}"
workflow_run = await db_client.create_workflow_run(
workflow_run_name, workflow_id, WorkflowRunMode.STASIS.value, user_id
)
set_current_run_id(workflow_run.id)
# Store the workflow_run_id in the connection for later use
call.workflow_run_id = workflow_run.id
# Connect channelID with Workflow run ID in logs
logger.info(
f"channelID: {call.caller_channel_id} run_id: {workflow_run.id} "
f"Received call for workflow ID {workflow_id}, user ID {user_id}"
)
await run_pipeline_ari_stasis(
call, workflow_id, workflow_run.id, user_id, call_context_vars
)

185
api/routes/superuser.py Normal file
View file

@ -0,0 +1,185 @@
import json
from datetime import datetime
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel
from api.db import db_client
from api.db.models import UserModel
from api.services.auth.depends import get_superuser
from api.services.auth.stack_auth import stackauth
router = APIRouter(prefix="/superuser", tags=["superuser"])
class ImpersonateRequest(BaseModel):
"""Request payload for superadmin impersonation.
Either ``provider_user_id`` **or** ``user_id`` must be supplied. If both are
provided, ``provider_user_id`` takes precedence.
"""
provider_user_id: str | None = None
user_id: int | None = None
class ImpersonateResponse(BaseModel):
refresh_token: str
access_token: str
class SuperuserWorkflowRunResponse(BaseModel):
id: int
name: str
workflow_id: int
workflow_name: Optional[str]
user_id: Optional[int]
organization_id: Optional[int]
organization_name: Optional[str]
mode: str
is_completed: bool
recording_url: Optional[str]
transcript_url: Optional[str]
usage_info: Optional[dict]
cost_info: Optional[dict]
initial_context: Optional[dict]
gathered_context: Optional[dict]
admin_comment: Optional[str]
admin_comment_ts: Optional[datetime]
created_at: datetime
class SuperuserWorkflowRunsListResponse(BaseModel):
workflow_runs: List[SuperuserWorkflowRunResponse]
total_count: int
page: int
limit: int
total_pages: int
@router.post("/impersonate")
async def impersonate(
request: ImpersonateRequest, user: UserModel = Depends(get_superuser)
) -> ImpersonateResponse:
"""Impersonate a user as a super-admin.
Internally, Stack Auth requires the **provider user ID** (a UUID-ish string)
to create an impersonation session.
"""
provider_user_id: str | None = request.provider_user_id
# ------------------------------------------------------------------
# Fallback: resolve provider_user_id from internal ``user_id``
# ------------------------------------------------------------------
if provider_user_id is None:
if request.user_id is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Either 'provider_user_id' or 'user_id' must be provided.",
)
db_user = await db_client.get_user_by_id(request.user_id)
if db_user is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"User with ID {request.user_id} not found.",
)
provider_user_id = db_user.provider_id
# ------------------------------------------------------------------
# Call Stack Auth to create the impersonation session
# ------------------------------------------------------------------
session = await stackauth.impersonate(provider_user_id)
return ImpersonateResponse(
refresh_token=session["refresh_token"],
access_token=session["access_token"],
)
@router.get("/workflow-runs")
async def get_workflow_runs(
page: int = Query(1, ge=1, description="Page number (starts from 1)"),
limit: int = Query(50, ge=1, le=100, description="Number of items per page"),
filters: Optional[str] = Query(None, description="JSON-encoded filter criteria"),
user: UserModel = Depends(get_superuser),
) -> SuperuserWorkflowRunsListResponse:
"""
Get paginated list of all workflow runs with organization information.
Requires superuser privileges.
Filters should be provided as a JSON-encoded array of filter criteria.
Example: [{"field": "id", "type": "number", "value": {"value": 680}}]
"""
offset = (page - 1) * limit
# Parse filters if provided
filter_criteria = None
if filters:
try:
filter_criteria = json.loads(filters)
except json.JSONDecodeError:
raise HTTPException(status_code=400, detail="Invalid filter format")
workflow_runs, total_count = await db_client.get_workflow_runs_for_superadmin(
limit=limit, offset=offset, filters=filter_criteria
)
total_pages = (total_count + limit - 1) // limit # Ceiling division
return SuperuserWorkflowRunsListResponse(
workflow_runs=[SuperuserWorkflowRunResponse(**run) for run in workflow_runs],
total_count=total_count,
page=page,
limit=limit,
total_pages=total_pages,
)
# ------------------ Admin Comment ------------------
class AdminCommentRequest(BaseModel):
admin_comment: str
class AdminCommentResponse(BaseModel):
success: bool
admin_comment: str
admin_comment_ts: datetime
# ------------------ Routes ------------------
@router.post("/workflow-runs/{run_id}/comment", response_model=AdminCommentResponse)
async def set_admin_comment(
run_id: int,
request: AdminCommentRequest,
user: UserModel = Depends(get_superuser),
):
"""Add or update an *admin-only* comment for a workflow run.
The comment is stored inside the ``annotations`` JSON column under the
``admin_comment`` key so that it does not interfere with any other
annotations recorded by the system.
"""
await db_client.update_admin_comment(
run_id=run_id, admin_comment=request.admin_comment
)
# Fetch the updated run to get the timestamp from annotations
updated_run = await db_client.get_workflow_run_by_id(run_id)
admin_comment_ts = None
if updated_run and updated_run.annotations:
admin_comment_ts = updated_run.annotations.get("admin_comment_ts")
return AdminCommentResponse(
success=True,
admin_comment=request.admin_comment,
admin_comment_ts=admin_comment_ts,
)

258
api/routes/twilio.py Normal file
View file

@ -0,0 +1,258 @@
import json
import random
from datetime import UTC, datetime
from typing import Annotated, Optional
from fastapi import APIRouter, Depends, Form, Header, HTTPException, Request, WebSocket
from loguru import logger
from pipecat.utils.context import set_current_run_id
from pydantic import BaseModel
from starlette.responses import HTMLResponse
from api.db import db_client
from api.db.models import UserModel
from api.enums import OrganizationConfigurationKey, WorkflowRunMode
from api.services.auth.depends import get_user
from api.services.campaign.call_dispatcher import campaign_call_dispatcher
from api.services.campaign.campaign_event_publisher import (
get_campaign_event_publisher,
)
from api.services.pipecat.run_pipeline import run_pipeline_twilio
from api.services.telephony.twilio import TwilioService
router = APIRouter(prefix="/twilio")
class InitiateCallRequest(BaseModel):
workflow_id: int
workflow_run_id: int | None = None
class TwilioStatusCallbackRequest(BaseModel):
CallSid: str
CallStatus: str
From: Optional[str] = None
To: Optional[str] = None
Direction: Optional[str] = None
Duration: Optional[str] = None
CallDuration: Optional[str] = None
RecordingUrl: Optional[str] = None
RecordingSid: Optional[str] = None
Timestamp: Optional[str] = None
@router.post("/initiate-call")
async def initiate_call(
request: InitiateCallRequest, user: UserModel = Depends(get_user)
):
# Check if organization has TWILIO_PHONE_NUMBERS configured
twilio_config = await db_client.get_configuration(
user.selected_organization_id,
OrganizationConfigurationKey.TWILIO_PHONE_NUMBERS.value,
)
if (
not twilio_config
or not twilio_config.value
or not twilio_config.value.get("value")
):
raise HTTPException(
status_code=401,
detail="Your organisation is not allowed to make phone call. Contact founders@dograh.com for further support.",
)
user_configuration = await db_client.get_user_configurations(user.id)
workflow_run_id = request.workflow_run_id
if not workflow_run_id:
workflow_run_name = f"WR-TEL-{random.randint(1000, 9999)}"
workflow_run = await db_client.create_workflow_run(
workflow_run_name,
request.workflow_id,
WorkflowRunMode.TWILIO.value,
initial_context={
"phone_number": user_configuration.test_phone_number,
},
user_id=user.id,
)
workflow_run_id = workflow_run.id
else:
workflow_run = await db_client.get_workflow_run(workflow_run_id, user.id)
if not workflow_run:
raise HTTPException(status_code=400, detail="Workflow run not found")
workflow_run_name = workflow_run.name
if user_configuration.test_phone_number:
await TwilioService().initiate_call(
to_number=user_configuration.test_phone_number,
url_args={
"workflow_id": request.workflow_id,
"user_id": user.id,
"workflow_run_id": workflow_run_id,
},
workflow_run_id=workflow_run_id,
organization_id=user.selected_organization_id,
)
return {
"message": f"Call initiated successfully with run name {workflow_run_name}"
}
else:
raise HTTPException(status_code=400, detail="Test phone number not set")
@router.post("/twiml", include_in_schema=False)
async def start_call(workflow_id: int, user_id: int, workflow_run_id: int):
twiml_content = await TwilioService().get_start_call_twiml(
workflow_id, user_id, workflow_run_id
)
return HTMLResponse(content=twiml_content, media_type="application/xml")
@router.websocket("/ws/{workflow_id}/{user_id}/{workflow_run_id}")
async def websocket_endpoint(
websocket: WebSocket, workflow_id: int, user_id: int, workflow_run_id: int
):
await websocket.accept()
try:
# "connected" (ignore)
msg = json.loads(await websocket.receive_text())
if msg.get("event") != "connected":
raise RuntimeError("Expected connected message first")
# "start" this has everything we need
start_msg = await websocket.receive_text()
# set the run context
set_current_run_id(workflow_run_id)
logger.debug(f"Received start message: {start_msg}")
start_msg = json.loads(start_msg)
if start_msg.get("event") != "start":
raise RuntimeError("Expected start message second")
try:
stream_sid = start_msg["start"]["streamSid"]
call_sid = start_msg["start"]["callSid"]
except KeyError:
logger.error(
"Missing callSID and streamSID in start message. Closing connection."
)
await websocket.close(code=4400, reason="Missing or bad start message")
return
# Run your Pipecat bot
await run_pipeline_twilio(
websocket, stream_sid, call_sid, workflow_id, workflow_run_id, user_id
)
except Exception as e:
logger.error(f"Error in Twilio WebSocket connection: {e}")
await websocket.close(1011, "Internal server error")
@router.post("/status-callback/{workflow_run_id}", include_in_schema=False)
async def status_callback(
request: Request,
workflow_run_id: int,
x_twilio_signature: Annotated[
Optional[str], Header(alias="X-Twilio-Signature")
] = None,
CallSid: str = Form(...),
CallStatus: str = Form(...),
From: Optional[str] = Form(None),
To: Optional[str] = Form(None),
Direction: Optional[str] = Form(None),
Duration: Optional[str] = Form(None),
CallDuration: Optional[str] = Form(None),
RecordingUrl: Optional[str] = Form(None),
RecordingSid: Optional[str] = Form(None),
Timestamp: Optional[str] = Form(None),
):
"""Handle Twilio status callbacks for call lifecycle events."""
try:
# TODO: Implement Twilio signature verification
# Create callback data object
callback_data = {
"CallSid": CallSid,
"CallStatus": CallStatus,
"From": From,
"To": To,
"Direction": Direction,
"Duration": Duration,
"CallDuration": CallDuration,
"RecordingUrl": RecordingUrl,
"RecordingSid": RecordingSid,
"Timestamp": Timestamp,
}
# Remove None values for cleaner logging
callback_data = {k: v for k, v in callback_data.items() if v is not None}
logger.info(
f"Received Twilio status callback for workflow_run_id {workflow_run_id}: {CallStatus}"
)
# Get the current workflow run
workflow_run = await db_client.get_workflow_run_by_id(workflow_run_id)
if not workflow_run:
logger.error(f"Workflow run {workflow_run_id} not found for callback")
return {"status": "error", "message": "Workflow run not found"}
callback_logs = workflow_run.logs.get("twilio_status_callbacks", [])
# Add new callback log entry to logs
callback_log = {
"status": CallStatus,
"timestamp": datetime.now(UTC).isoformat(),
"data": callback_data,
}
callback_logs.append(callback_log)
# Update the workflow run with the new logs
await db_client.update_workflow_run(
run_id=workflow_run_id, logs={"twilio_status_callbacks": callback_logs}
)
# Release concurrent slot when call ends (for any terminal status)
terminal_statuses = ["completed", "busy", "no-answer", "failed", "canceled"]
if CallStatus.lower() in terminal_statuses and workflow_run.campaign_id:
# Release the concurrent slot for this call
await campaign_call_dispatcher.release_call_slot(workflow_run_id)
# Check if retry is needed for campaign calls
if (
CallStatus.lower() in ["busy", "no-answer", "failed"]
and workflow_run.campaign_id
):
# Lets retry for busy and no-answer
if CallStatus.lower() in ["busy", "no-answer"]:
publisher = await get_campaign_event_publisher()
await publisher.publish_retry_needed(
workflow_run_id=workflow_run_id,
reason=CallStatus.lower().replace(
"-", "_"
), # Convert no-answer to no_answer
campaign_id=workflow_run.campaign_id,
queued_run_id=workflow_run.queued_run_id,
)
# Update workflow run with appropriate tags
call_tags = workflow_run.gathered_context.get("call_tags", [])
call_tags.extend(["not_connected", f"twilio_{CallStatus.lower()}"])
await db_client.update_workflow_run(
run_id=workflow_run_id,
is_completed=True,
gathered_context={
"call_tags": call_tags,
},
)
return {"status": "success", "message": "Callback processed"}
except Exception as e:
logger.error(f"Error processing Twilio status callback: {e}")
return {"status": "error", "message": str(e)}

276
api/routes/user.py Normal file
View file

@ -0,0 +1,276 @@
from datetime import datetime, timedelta
from typing import List, Optional, TypedDict, Union
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from api.db import db_client
from api.db.models import (
UserModel,
)
from api.services.auth.depends import get_user
from api.services.configuration.check_validity import (
APIKeyStatusResponse,
UserConfigurationValidator,
)
from api.services.configuration.defaults import DEFAULT_SERVICE_PROVIDERS
from api.services.configuration.masking import mask_user_config
from api.services.configuration.merge import merge_user_configurations
from api.services.configuration.registry import REGISTRY, ServiceType
router = APIRouter(prefix="/user")
class AuthUserResponse(TypedDict):
id: int
is_superuser: bool
class DefaultConfigurationsResponse(TypedDict):
llm: dict[str, dict]
tts: dict[str, dict]
stt: dict[str, dict]
default_providers: dict[str, str]
@router.get("/configurations/defaults")
async def get_default_configurations() -> DefaultConfigurationsResponse:
configurations = {
"llm": {
provider: model_cls.model_json_schema()
for provider, model_cls in REGISTRY[ServiceType.LLM].items()
},
"tts": {
provider: model_cls.model_json_schema()
for provider, model_cls in REGISTRY[ServiceType.TTS].items()
},
"stt": {
provider: model_cls.model_json_schema()
for provider, model_cls in REGISTRY[ServiceType.STT].items()
},
"default_providers": DEFAULT_SERVICE_PROVIDERS,
}
return configurations
@router.get("/auth/user")
async def get_auth_user(
user: UserModel = Depends(get_user),
) -> AuthUserResponse:
return {
"id": user.id,
"is_superuser": user.is_superuser,
}
class UserConfigurationRequestResponseSchema(BaseModel):
llm: dict[str, Union[str, float]] | None = None
tts: dict[str, Union[str, float]] | None = None
stt: dict[str, Union[str, float]] | None = None
test_phone_number: str | None = None
timezone: str | None = None
organization_pricing: dict[str, Union[float, str, bool]] | None = None
@router.get("/configurations/user")
async def get_user_configurations(
user: UserModel = Depends(get_user),
) -> UserConfigurationRequestResponseSchema:
user_configurations = await db_client.get_user_configurations(user.id)
masked_config = mask_user_config(user_configurations)
# Add organization pricing info if available
if user.selected_organization_id:
org = await db_client.get_organization_by_id(user.selected_organization_id)
if org and org.price_per_second_usd is not None:
masked_config["organization_pricing"] = {
"price_per_second_usd": org.price_per_second_usd,
"currency": "USD",
"billing_enabled": True,
}
return masked_config
@router.put("/configurations/user")
async def update_user_configurations(
request: UserConfigurationRequestResponseSchema,
user: UserModel = Depends(get_user),
) -> UserConfigurationRequestResponseSchema:
existing_config = await db_client.get_user_configurations(user.id)
incoming_dict = request.model_dump(exclude_none=True)
# Remove organization_pricing from incoming dict as it's read-only
incoming_dict.pop("organization_pricing", None)
# Merge via helper
user_configurations = merge_user_configurations(existing_config, incoming_dict)
try:
validator = UserConfigurationValidator()
await validator.validate(user_configurations)
except ValueError as e:
raise HTTPException(status_code=422, detail=e.args[0])
user_configurations = await db_client.update_user_configuration(
user.id, user_configurations
)
# Return masked version of updated config
masked_config = mask_user_config(user_configurations)
# Add organization pricing info if available
if user.selected_organization_id:
org = await db_client.get_organization_by_id(user.selected_organization_id)
if org and org.price_per_second_usd is not None:
masked_config["organization_pricing"] = {
"price_per_second_usd": org.price_per_second_usd,
"currency": "USD",
"billing_enabled": True,
}
return masked_config
@router.get("/configurations/user/validate")
async def validate_user_configurations(
validity_ttl_seconds: int = Query(default=60, ge=0, le=86400),
user: UserModel = Depends(get_user),
) -> APIKeyStatusResponse:
configurations = await db_client.get_user_configurations(user.id)
if (
configurations.last_validated_at
and configurations.last_validated_at
< datetime.now() - timedelta(seconds=validity_ttl_seconds)
):
validator = UserConfigurationValidator()
try:
status = await validator.validate(configurations)
await db_client.update_user_configuration_last_validated_at(user.id)
return status
except ValueError as e:
raise HTTPException(status_code=422, detail=e.args[0])
else:
return {"status": []}
# API Key Management Endpoints
class APIKeyResponse(BaseModel):
id: int
name: str
key_prefix: str
is_active: bool
created_at: datetime
last_used_at: Optional[datetime] = None
archived_at: Optional[datetime] = None
class CreateAPIKeyRequest(BaseModel):
name: str
class CreateAPIKeyResponse(BaseModel):
id: int
name: str
key_prefix: str
api_key: str # Only returned when creating a new key
created_at: datetime
@router.get("/api-keys")
async def get_api_keys(
include_archived: bool = Query(default=False),
user: UserModel = Depends(get_user),
) -> List[APIKeyResponse]:
"""Get all API keys for the user's selected organization."""
if not user.selected_organization_id:
raise HTTPException(status_code=400, detail="No organization selected")
api_keys = await db_client.get_api_keys_by_organization(
user.selected_organization_id, include_archived=include_archived
)
return [
APIKeyResponse(
id=key.id,
name=key.name,
key_prefix=key.key_prefix,
is_active=key.is_active,
created_at=key.created_at,
last_used_at=key.last_used_at,
archived_at=key.archived_at,
)
for key in api_keys
]
@router.post("/api-keys")
async def create_api_key(
request: CreateAPIKeyRequest,
user: UserModel = Depends(get_user),
) -> CreateAPIKeyResponse:
"""Create a new API key for the user's selected organization."""
if not user.selected_organization_id:
raise HTTPException(status_code=400, detail="No organization selected")
api_key, raw_key = await db_client.create_api_key(
organization_id=user.selected_organization_id,
name=request.name,
created_by=user.id,
)
return CreateAPIKeyResponse(
id=api_key.id,
name=api_key.name,
key_prefix=api_key.key_prefix,
api_key=raw_key,
created_at=api_key.created_at,
)
@router.delete("/api-keys/{api_key_id}")
async def archive_api_key(
api_key_id: int,
user: UserModel = Depends(get_user),
) -> dict:
"""Archive an API key (soft delete)."""
if not user.selected_organization_id:
raise HTTPException(status_code=400, detail="No organization selected")
# Verify the API key belongs to the user's organization
api_keys = await db_client.get_api_keys_by_organization(
user.selected_organization_id, include_archived=True
)
if not any(key.id == api_key_id for key in api_keys):
raise HTTPException(status_code=404, detail="API key not found")
success = await db_client.archive_api_key(api_key_id)
if not success:
raise HTTPException(status_code=500, detail="Failed to archive API key")
return {"success": True, "message": "API key archived successfully"}
@router.put("/api-keys/{api_key_id}/reactivate")
async def reactivate_api_key(
api_key_id: int,
user: UserModel = Depends(get_user),
) -> dict:
"""Reactivate an archived API key."""
if not user.selected_organization_id:
raise HTTPException(status_code=400, detail="No organization selected")
# Verify the API key belongs to the user's organization
api_keys = await db_client.get_api_keys_by_organization(
user.selected_organization_id, include_archived=True
)
if not any(key.id == api_key_id for key in api_keys):
raise HTTPException(status_code=404, detail="API key not found")
success = await db_client.reactivate_api_key(api_key_id)
if not success:
raise HTTPException(status_code=500, detail="Failed to reactivate API key")
return {"success": True, "message": "API key reactivated successfully"}

665
api/routes/workflow.py Normal file
View file

@ -0,0 +1,665 @@
import json
from datetime import datetime
from typing import List, Literal, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from httpx import HTTPStatusError
from loguru import logger
from pydantic import BaseModel, ValidationError
from api.constants import DEPLOYMENT_MODE
from api.db import db_client
from api.db.models import UserModel
from api.db.workflow_template_client import WorkflowTemplateClient
from api.schemas.workflow import WorkflowRunResponseSchema
from api.services.auth.depends import get_user
from api.services.mps_service_key_client import mps_service_key_client
from api.services.workflow.dto import ReactFlowDTO
from api.services.workflow.errors import ItemKind, WorkflowError
from api.services.workflow.workflow import WorkflowGraph
router = APIRouter(prefix="/workflow")
class ValidateWorkflowResponse(BaseModel):
is_valid: bool
errors: list[WorkflowError]
class WorkflowResponse(BaseModel):
id: int
name: str
status: str
created_at: datetime
workflow_definition: dict
current_definition_id: int | None
template_context_variables: dict | None = None
call_disposition_codes: dict | None = None
total_runs: int | None = None
workflow_configurations: dict | None = None
class WorkflowTemplateResponse(BaseModel):
id: int
template_name: str
template_description: str
template_json: dict
created_at: datetime
class CreateWorkflowRequest(BaseModel):
name: str
workflow_definition: dict
class DuplicateTemplateRequest(BaseModel):
template_id: int
workflow_name: str
class UpdateWorkflowRequest(BaseModel):
name: str
workflow_definition: dict | None = None
template_context_variables: dict | None = None
workflow_configurations: dict | None = None
class UpdateWorkflowStatusRequest(BaseModel):
status: str # "active" or "archived"
class CreateWorkflowRunRequest(BaseModel):
mode: str
name: str
class CreateWorkflowRunResponse(BaseModel):
id: int
workflow_id: int
name: str
mode: str
created_at: datetime
definition_id: int
initial_context: dict | None = None
class CreateWorkflowTemplateRequest(BaseModel):
call_type: Literal["INBOUND", "OUTBOUND"]
use_case: str
activity_description: str
@router.post("/{workflow_id}/validate")
async def validate_workflow(
workflow_id: int,
user: UserModel = Depends(get_user),
) -> ValidateWorkflowResponse:
"""
Validate all nodes in a workflow to ensure they have required fields.
Args:
workflow_id: The ID of the workflow to validate
user: The authenticated user
Returns:
Object indicating if workflow is valid and any invalid nodes/edges
"""
workflow = await db_client.get_workflow(
workflow_id, organization_id=user.selected_organization_id
)
if workflow is None:
raise HTTPException(
status_code=404, detail=f"Workflow with id {workflow_id} not found"
)
errors: list[WorkflowError] = []
# Get workflow definition from WorkflowDefinition table, fallback to workflow_definition field
workflow_definition = workflow.workflow_definition_with_fallback
# ----------- DTO Validation ------------
dto: Optional[ReactFlowDTO] = None
try:
dto = ReactFlowDTO.model_validate(workflow_definition)
except ValidationError as exc:
errors.extend(_transform_schema_errors(exc, workflow_definition))
# ----------- Graph Validation if DTO is valid ------------
try:
if dto:
WorkflowGraph(dto)
except ValueError as e:
errors.extend(e.args[0])
if errors:
raise HTTPException(
status_code=422,
detail=ValidateWorkflowResponse(is_valid=False, errors=errors).model_dump(),
)
return ValidateWorkflowResponse(is_valid=True, errors=[])
def _transform_schema_errors(
exc: ValidationError, workflow_definition: dict
) -> list[WorkflowError]:
out: list[WorkflowError] = []
for err in exc.errors():
loc = err["loc"]
idx = workflow_definition[loc[0]][loc[1]]["id"]
kind: ItemKind = ItemKind.node if loc[0] == "nodes" else ItemKind.edge
out.append(
WorkflowError(
kind=kind,
id=idx,
field=".".join(str(p) for p in err["loc"][2:]) or None,
message=err["msg"].capitalize(),
)
)
return out
@router.post("/create/definition")
async def create_workflow(
request: CreateWorkflowRequest, user: UserModel = Depends(get_user)
) -> WorkflowResponse:
"""
Create a new workflow from the client
Args:
request: The create workflow request
user: The user to create the workflow for
"""
workflow = await db_client.create_workflow(
request.name,
request.workflow_definition,
user.id,
user.selected_organization_id,
)
return {
"id": workflow.id,
"name": workflow.name,
"status": workflow.status,
"created_at": workflow.created_at,
"workflow_definition": workflow.workflow_definition_with_fallback,
"current_definition_id": workflow.current_definition_id,
"template_context_variables": workflow.template_context_variables,
"call_disposition_codes": workflow.call_disposition_codes,
"workflow_configurations": workflow.workflow_configurations,
}
@router.post("/create/template")
async def create_workflow_from_template(
request: CreateWorkflowTemplateRequest,
user: UserModel = Depends(get_user),
) -> WorkflowResponse:
"""
Create a new workflow from a natural language template request.
This endpoint:
1. Uses mps_service_key_client to call MPS workflow API
2. Passes organization ID (authenticated mode) or created_by (OSS mode)
3. Creates the workflow in the database
Args:
request: The template creation request with call_type, use_case, and activity_description
user: The authenticated user
Returns:
The created workflow
Raises:
HTTPException: If MPS API call fails
"""
try:
# Call MPS API to generate workflow using the client
if DEPLOYMENT_MODE == "oss":
workflow_data = await mps_service_key_client.call_workflow_api(
call_type=request.call_type,
use_case=request.use_case,
activity_description=request.activity_description,
created_by=str(user.provider_id),
)
else:
if not user.selected_organization_id:
raise HTTPException(status_code=400, detail="No organization selected")
workflow_data = await mps_service_key_client.call_workflow_api(
call_type=request.call_type,
use_case=request.use_case,
activity_description=request.activity_description,
organization_id=user.selected_organization_id,
)
# Create the workflow in our database
workflow = await db_client.create_workflow(
name=workflow_data.get("name", f"{request.use_case} - {request.call_type}"),
workflow_definition=workflow_data.get("workflow_definition", {}),
user_id=user.id,
organization_id=user.selected_organization_id,
)
return {
"id": workflow.id,
"name": workflow.name,
"status": workflow.status,
"created_at": workflow.created_at,
"workflow_definition": workflow.workflow_definition_with_fallback,
"current_definition_id": workflow.current_definition_id,
"template_context_variables": workflow.template_context_variables,
"call_disposition_codes": workflow.call_disposition_codes,
"workflow_configurations": workflow.workflow_configurations,
}
except HTTPStatusError as e:
logger.error(f"MPS API error: {e}")
raise HTTPException(
status_code=e.response.status_code if hasattr(e, "response") else 500,
detail=str(e),
)
except Exception as e:
logger.error(f"Unexpected error creating workflow from template: {e}")
raise HTTPException(
status_code=500,
detail=f"An unexpected error occurred: {str(e)}",
)
class WorkflowSummaryResponse(BaseModel):
id: int
name: str
@router.get("/fetch")
async def get_workflows(
user: UserModel = Depends(get_user),
status: Optional[str] = Query(
None,
description="Filter by status - can be single value (active/archived) or comma-separated (active,archived)",
),
) -> List[WorkflowResponse]:
"""Get all workflows for the authenticated user's organization"""
# Handle comma-separated status values
if status and "," in status:
# Split comma-separated values and fetch workflows for each status
status_list = [s.strip() for s in status.split(",")]
all_workflows = []
for status_value in status_list:
workflows = await db_client.get_all_workflows(
organization_id=user.selected_organization_id, status=status_value
)
all_workflows.extend(workflows)
workflows = all_workflows
else:
# Single status or no status filter
workflows = await db_client.get_all_workflows(
organization_id=user.selected_organization_id, status=status
)
# Get run counts for each workflow
workflow_responses = []
for workflow in workflows:
run_count = await db_client.get_workflow_run_count(workflow.id)
workflow_responses.append(
{
"id": workflow.id,
"name": workflow.name,
"status": workflow.status,
"created_at": workflow.created_at,
"workflow_definition": workflow.workflow_definition_with_fallback,
"current_definition_id": workflow.current_definition_id,
"template_context_variables": workflow.template_context_variables,
"call_disposition_codes": workflow.call_disposition_codes,
"workflow_configurations": workflow.workflow_configurations,
"total_runs": run_count,
}
)
return workflow_responses
@router.get("/fetch/{workflow_id}")
async def get_workflow(
workflow_id: int,
user: UserModel = Depends(get_user),
) -> WorkflowResponse:
"""Get a single workflow by ID"""
workflow = await db_client.get_workflow(
workflow_id, organization_id=user.selected_organization_id
)
if workflow is None:
raise HTTPException(
status_code=404, detail=f"Workflow with id {workflow_id} not found"
)
return {
"id": workflow.id,
"name": workflow.name,
"status": workflow.status,
"created_at": workflow.created_at,
"workflow_definition": workflow.workflow_definition_with_fallback,
"current_definition_id": workflow.current_definition_id,
"template_context_variables": workflow.template_context_variables,
"call_disposition_codes": workflow.call_disposition_codes,
"workflow_configurations": workflow.workflow_configurations,
}
@router.get("/summary")
async def get_workflows_summary(
user: UserModel = Depends(get_user),
) -> List[WorkflowSummaryResponse]:
"""Get minimal workflow information (id and name only) for all workflows"""
workflows = await db_client.get_all_workflows(
organization_id=user.selected_organization_id
)
return [
WorkflowSummaryResponse(id=workflow.id, name=workflow.name)
for workflow in workflows
]
@router.put("/{workflow_id}/status")
async def update_workflow_status(
workflow_id: int,
request: UpdateWorkflowStatusRequest,
user: UserModel = Depends(get_user),
) -> WorkflowResponse:
"""
Update the status of a workflow (e.g., archive/unarchive).
Args:
workflow_id: The ID of the workflow to update
request: The status update request
Returns:
The updated workflow
"""
try:
workflow = await db_client.update_workflow_status(
workflow_id=workflow_id,
status=request.status,
organization_id=user.selected_organization_id,
)
run_count = await db_client.get_workflow_run_count(workflow.id)
return {
"id": workflow.id,
"name": workflow.name,
"status": workflow.status,
"created_at": workflow.created_at,
"workflow_definition": workflow.workflow_definition_with_fallback,
"current_definition_id": workflow.current_definition_id,
"template_context_variables": workflow.template_context_variables,
"call_disposition_codes": workflow.call_disposition_codes,
"workflow_configurations": workflow.workflow_configurations,
"total_runs": run_count,
}
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.put("/{workflow_id}")
async def update_workflow(
workflow_id: int,
request: UpdateWorkflowRequest,
user: UserModel = Depends(get_user),
) -> WorkflowResponse:
"""
Update an existing workflow.
Args:
workflow_id: The ID of the workflow to update
request: The update request containing the new name and workflow definition
Returns:
The updated workflow
Raises:
HTTPException: If the workflow is not found or if there's a database error
"""
try:
workflow = await db_client.update_workflow(
workflow_id=workflow_id,
name=request.name,
workflow_definition=request.workflow_definition,
template_context_variables=request.template_context_variables,
workflow_configurations=request.workflow_configurations,
organization_id=user.selected_organization_id,
)
return {
"id": workflow.id,
"name": workflow.name,
"status": workflow.status,
"created_at": workflow.created_at,
"workflow_definition": workflow.workflow_definition_with_fallback,
"current_definition_id": workflow.current_definition_id,
"template_context_variables": workflow.template_context_variables,
"call_disposition_codes": workflow.call_disposition_codes,
"workflow_configurations": workflow.workflow_configurations,
}
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/{workflow_id}/runs")
async def create_workflow_run(
workflow_id: int,
request: CreateWorkflowRunRequest,
user: UserModel = Depends(get_user),
) -> CreateWorkflowRunResponse:
"""
Create a new workflow run when the user decides to execute the workflow via chat or voice
Args:
workflow_id: The ID of the workflow to run
request: The create workflow run request
user: The user to create the workflow run for
"""
run = await db_client.create_workflow_run(
request.name, workflow_id, request.mode, user.id
)
return {
"id": run.id,
"workflow_id": run.workflow_id,
"name": run.name,
"mode": run.mode,
"created_at": run.created_at,
"definition_id": run.definition_id,
"initial_context": run.initial_context,
"gathered_context": run.gathered_context,
}
@router.get("/{workflow_id}/runs/{run_id}")
async def get_workflow_run(
workflow_id: int, run_id: int, user: UserModel = Depends(get_user)
) -> WorkflowRunResponseSchema:
run = await db_client.get_workflow_run(
run_id, organization_id=user.selected_organization_id
)
if not run:
raise HTTPException(status_code=404, detail="Workflow run not found")
return {
"id": run.id,
"workflow_id": run.workflow_id,
"name": run.name,
"mode": run.mode,
"is_completed": run.is_completed,
"transcript_url": run.transcript_url,
"recording_url": run.recording_url,
"cost_info": {
"dograh_token_usage": (
run.cost_info.get("dograh_token_usage")
if run.cost_info and "dograh_token_usage" in run.cost_info
else round(float(run.cost_info.get("total_cost_usd", 0)) * 100, 2)
if run.cost_info and "total_cost_usd" in run.cost_info
else 0
),
"call_duration_seconds": int(
round(run.cost_info.get("call_duration_seconds"))
)
if run.cost_info
else None,
}
if run.cost_info
else None,
"created_at": run.created_at,
"definition_id": run.definition_id,
"initial_context": run.initial_context,
"gathered_context": run.gathered_context,
}
class WorkflowRunsResponse(BaseModel):
runs: List[WorkflowRunResponseSchema]
total_count: int
page: int
limit: int
total_pages: int
applied_filters: Optional[List[dict]] = None
@router.get("/{workflow_id}/runs")
async def get_workflow_runs(
workflow_id: int,
page: int = 1,
limit: int = 50,
filters: Optional[str] = Query(None, description="JSON-encoded filter criteria"),
user: UserModel = Depends(get_user),
) -> WorkflowRunsResponse:
"""
Get workflow runs with optional filtering.
Filters should be provided as a JSON-encoded array of filter criteria.
Example: [{"attribute": "dateRange", "value": {"from": "2024-01-01", "to": "2024-01-31"}}]
"""
offset = (page - 1) * limit
# Parse filters if provided
filter_criteria = []
if filters:
try:
filter_criteria = json.loads(filters)
except json.JSONDecodeError:
raise HTTPException(status_code=400, detail="Invalid filter format")
# Restrict allowed filter attributes for regular users
allowed_attributes = {
"dateRange",
"dispositionCode",
"duration",
"status",
"tokenUsage",
}
for filter_item in filter_criteria:
attribute = filter_item.get("attribute")
if attribute and attribute not in allowed_attributes:
raise HTTPException(
status_code=403, detail=f"Invalid attribute '{attribute}'"
)
# Apply filters if any
if filter_criteria:
runs, total_count = await db_client.get_workflow_runs_by_workflow_id(
workflow_id,
organization_id=user.selected_organization_id,
limit=limit,
offset=offset,
filters=filter_criteria,
)
else:
# Use existing logic for unfiltered results
runs, total_count = await db_client.get_workflow_runs_by_workflow_id(
workflow_id,
organization_id=user.selected_organization_id,
limit=limit,
offset=offset,
)
total_pages = (total_count + limit - 1) // limit
return WorkflowRunsResponse(
runs=runs,
total_count=total_count,
page=page,
limit=limit,
total_pages=total_pages,
applied_filters=filter_criteria if filter_criteria else None,
)
@router.get("/templates")
async def get_workflow_templates() -> List[WorkflowTemplateResponse]:
"""
Get all available workflow templates.
Returns:
List of workflow templates
"""
template_client = WorkflowTemplateClient()
templates = await template_client.get_all_workflow_templates()
return [
{
"id": template.id,
"template_name": template.template_name,
"template_description": template.template_description,
"template_json": template.template_json,
"created_at": template.created_at,
}
for template in templates
]
@router.post("/templates/duplicate")
async def duplicate_workflow_template(
request: DuplicateTemplateRequest, user: UserModel = Depends(get_user)
) -> WorkflowResponse:
"""
Duplicate a workflow template to create a new workflow for the user.
Args:
request: The duplicate template request
user: The authenticated user
Returns:
The newly created workflow
"""
template_client = WorkflowTemplateClient()
template = await template_client.get_workflow_template(request.template_id)
if not template:
raise HTTPException(
status_code=404,
detail=f"Workflow template with id {request.template_id} not found",
)
# Create a new workflow from the template
workflow = await db_client.create_workflow(
request.workflow_name,
template.template_json,
user.id,
user.selected_organization_id,
)
return {
"id": workflow.id,
"name": workflow.name,
"status": workflow.status,
"created_at": workflow.created_at,
"workflow_definition": workflow.workflow_definition_with_fallback,
"current_definition_id": workflow.current_definition_id,
"template_context_variables": workflow.template_context_variables,
"call_disposition_codes": workflow.call_disposition_codes,
"workflow_configurations": workflow.workflow_configurations,
}