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

29
.env.example Normal file
View file

@ -0,0 +1,29 @@
# 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"
# Langfuse
ENABLE_TRACING: "false"
# LANGFUSE_SECRET_KEY: ""
# LANGFUSE_PUBLIC_KEY: ""
# LANGFUSE_HOST: "https://langfuse.dograh.com"
# Sentry
ENABLE_SETRY: "false"
SENTRY_DSN: ""

11
.gitignore vendored Normal file
View file

@ -0,0 +1,11 @@
__pycache__
.DS_Store
.env
# logs and run directory on production
/logs/
/run/
docs/
infrastructure/
nginx/
scripts/

15
api/.cursorignore Normal file
View file

@ -0,0 +1,15 @@
# root
.git
.pytest_cache
.ruff_cache
.DS_Store
__pycache__
.pytest_cache
# ui
ui/.next
ui/node_modules
ui/dist
ui/build
ui/coverage
ui/public

94
api/.dockerignore Normal file
View file

@ -0,0 +1,94 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
*.egg
*.egg-info/
dist/
build/
.Python
pip-log.txt
pip-delete-this-directory.txt
.tox/
.coverage
.coverage.*
.cache
coverage.xml
*.cover
.hypothesis/
.pytest_cache/
*.log
# Virtual Environments
venv/
env/
ENV/
.venv/
.ENV/
virtualenv/
# IDEs
.vscode/
.idea/
*.swp
*.swo
*~
.DS_Store
# Git
.git/
.gitignore
.gitattributes
# Environment files (we'll copy only what's needed)
.env
.env.*
# Documentation
*.md
docs/
README*
# Tests
tests/
test/
*_test.py
test_*.py
# CI/CD
.github/
.gitlab-ci.yml
.travis.yml
# Temporary files
*.tmp
tmp/
temp/
# Database
*.db
*.sqlite
*.sqlite3
# Notebooks
*.ipynb
.ipynb_checkpoints/
notebooks/
# Docker
Dockerfile
docker-compose*.yml
docker-compose*.yaml
.dockerignore
# Other unnecessary files for production
*.bak
*.orig
.editorconfig
.pre-commit-config.yaml
pyproject.toml
setup.cfg
Makefile
scripts/
migrations/alembic.ini

84
api/.env.example Normal file
View file

@ -0,0 +1,84 @@
# Environment Configuration
ENVIRONMENT="local"
LOG_LEVEL="DEBUG"
# Backend API Configuration
BACKEND_API_ENDPOINT="your-ngrok-url.ngrok-free.app"
# Database Configuration
DATABASE_URL="postgresql+asyncpg://postgres:postgres@localhost:5432/postgres"
REDIS_URL="redis://:redissecret@localhost:6379"
# Superuser Configuration
SUPERUSER_PASSWORD="your-secure-password"
# AI Service API Keys (commented out by default)
# CARTESIA_API_KEY="sk_car_xxxxxxxxxxxxxxxxx"
# DEEPGRAM_API_KEY="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
# LiveKit Configuration (optional)
# LIVEKIT_API_KEY="APIxxxxxxxxxx"
# LIVEKIT_API_SECRET="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
# LIVEKIT_URL="wss://your-livekit-instance.livekit.cloud"
# AWS S3 Configuration (required for SaaS mode)
AWS_ACCESS_KEY_ID="AKIAXXXXXXXXXXXXXXXXX"
AWS_SECRET_ACCESS_KEY="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
S3_BUCKET="your-s3-bucket-name"
S3_REGION="us-east-1"
# Stack Auth Configuration
STACK_AUTH_API_URL="https://api.stack-auth.com"
STACK_AUTH_PROJECT_ID="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
STACK_SECRET_SERVER_KEY="ssk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
STACK_PUBLISHABLE_CLIENT_KEY="pck_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
# Twilio Configuration
TWILIO_ACCOUNT_SID="SKxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
TWILIO_AUTH_TOKEN="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
TWILIO_FROM_NUMBER="+1234567890"
# Tracing and Analytics
ENABLE_TRACING=true
LANGFUSE_SECRET_KEY="sk-lf-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
LANGFUSE_PUBLIC_KEY="pk-lf-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
LANGFUSE_HOST="https://langfuse.your-domain.com"
# AI Service API Keys
GROQ_API_KEY="gsk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
NEUPHONIC_API_KEY="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
NANGO_API_KEY="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
# Default API Keys for new user signups
OPENAI_API_KEY="sk-proj-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
ELEVENLABS_API_KEY="sk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
DEEPGRAM_API_KEY="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
GOOGLE_API_KEY="AIzaSyXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
# Smart Turn Configuration
ENABLE_SMART_TURN=true
SMART_TURN_WS_SERVICE_ENDPOINT="wss://your-gpu-server.domain.com/ws"
SMART_TURN_HTTP_SERVICE_KEY="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx="
# SSL Key Logging (for debugging)
# SSLKEYLOGFILE=/path/to/ssl-keys.log
# Turn Logging
ENABLE_TURN_LOGGING=true
# OpenTelemetry Configuration
OTEL_PYTHON_LOGGING_AUTO_INSTRUMENTATION_ENABLED=true
SIGNOZ_EXPORTER_ENDPOINT="ingest.us.signoz.cloud:443"
SIGNOZ_EXPORTER_HEADERS="signoz-ingestion-key=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
# Storage Configuration
# User mode: 'saas' (default) uses AWS S3; 'oss' uses local MinIO
USER_MODE=saas
# OSS (MinIO) Configuration - only required when USER_MODE=oss
# These values are optional and will use defaults if not provided
MINIO_ENDPOINT=localhost:9000
MINIO_ACCESS_KEY=minioadmin
MINIO_SECRET_KEY=minioadmin
MINIO_BUCKET=voice-audio
MINIO_SECURE=false

2
api/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
services/admin_utils
services/gender/names

24
api/Dockerfile Normal file
View file

@ -0,0 +1,24 @@
# Dockerfile
FROM python:3.12-slim
WORKDIR /app
RUN apt-get update && apt-get install -y \
git \
ffmpeg \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# Copy and install requirements
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . ./api
ENV PYTHONPATH=/app
# Expose the port FastAPI will run on
EXPOSE 8000
# Run the FastAPI app with uvicorn
CMD ["uvicorn", "api.app:app", "--host", "0.0.0.0", "--port", "8000"]

0
api/__init__.py Normal file
View file

121
api/alembic.ini Normal file
View file

@ -0,0 +1,121 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts
# Use forward slashes (/) also on windows to provide an os agnostic path
script_location = %(here)s/alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the python>=3.9 or backports.zoneinfo library and tzdata library.
# Any required deps can installed by adding `alembic[tz]` to the pip requirements
# string value is passed to ZoneInfo()
# leave blank for localtime
# timezone =
# max length of characters to apply to the "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to alembic/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "version_path_separator" below.
# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions
# version path separator; As mentioned above, this is the character used to split
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
# Valid values for version_path_separator are:
#
# version_path_separator = :
# version_path_separator = ;
# version_path_separator = space
# version_path_separator = newline
#
# Use os.pathsep. Default configuration used for new projects.
version_path_separator = os
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
# This is only a fallback. The actual value is determined in
# alembic/env.py get_url() method
sqlalchemy.url = ""
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
# hooks = ruff
# ruff.type = exec
# ruff.executable = %(here)s/.venv/bin/ruff
# ruff.options = --fix REVISION_SCRIPT_FILENAME
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARNING
handlers = console
qualname =
[logger_sqlalchemy]
level = WARNING
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

111
api/alembic/env.py Normal file
View file

@ -0,0 +1,111 @@
import asyncio
import sys
from logging.config import fileConfig
from pathlib import Path
import alembic_postgresql_enum # noqa: F401 - registers enum handling hooks
from alembic import context
from sqlalchemy import pool
from sqlalchemy.ext.asyncio import create_async_engine
from api.constants import DATABASE_URL
# Add parent directory to path
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
# Interpret the config file for Python logging.
config = context.config
fileConfig(config.config_file_name)
# Import your model's MetaData object for 'autogenerate' support.
from api.db.models import Base # noqa: E402 ensure this points to your models.py
target_metadata = Base.metadata
def get_url():
"""Get database URL from environment variable or config file."""
return DATABASE_URL or config.get_main_option("sqlalchemy.url")
def run_migrations_offline():
"""Run migrations in 'offline' mode."""
url = get_url()
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
include_object=include_object,
render_item=render_item,
compare_type=True,
compare_server_default=True,
)
with context.begin_transaction():
context.run_migrations()
def include_object(object, name, type_, reflected, compare_to):
"""
Custom function to exclude redundant indexes on primary keys.
"""
# Unused parameters are required by Alembic's API
_ = (name, reflected, compare_to)
if type_ == "index":
# Skip indexes on primary key columns that aren't unique
# Primary keys already have implicit unique indexes
if hasattr(object, "columns") and len(object.columns) == 1:
col = list(object.columns)[0]
if col.primary_key and not object.unique:
return False
return True
def render_item(type_, obj, autogen_context):
"""
Custom render function to fix index generation.
"""
# Unused parameter is required by Alembic's API
_ = autogen_context
if type_ == "index":
# For indexes on columns marked as unique in the model,
# ensure the index is also unique
if hasattr(obj, "columns"):
for col in obj.columns:
if hasattr(col, "unique") and col.unique and not obj.unique:
obj.unique = True
break
return False # Let Alembic handle the rendering
def do_run_migrations(connection):
context.configure(
connection=connection,
target_metadata=target_metadata,
include_object=include_object,
render_item=render_item,
compare_type=True,
compare_server_default=True,
)
with context.begin_transaction():
context.run_migrations()
async def run_migrations_online():
"""Run migrations in 'online' mode."""
connectable = create_async_engine(
get_url(),
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
# Run migrations using the synchronous 'do_run_migrations' function
await connection.run_sync(do_run_migrations)
await connectable.dispose()
if context.is_offline_mode():
run_migrations_offline()
else:
asyncio.run(run_migrations_online())

View file

@ -0,0 +1,26 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View file

@ -0,0 +1,91 @@
"""added campaign table
Revision ID: 08bb6e7f1397
Revises: 0c1223cc266f
Create Date: 2025-07-23 18:46:38.955381
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "08bb6e7f1397"
down_revision: Union[str, None] = "0c1223cc266f"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"campaigns",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("name", sa.String(), nullable=False),
sa.Column("organization_id", sa.Integer(), nullable=False),
sa.Column("workflow_id", sa.Integer(), nullable=False),
sa.Column("created_by", sa.Integer(), nullable=False),
sa.Column("source_type", sa.String(), nullable=False),
sa.Column("source_id", sa.String(), nullable=False),
sa.Column(
"state",
sa.Enum(
"created",
"running",
"paused",
"completed",
"failed",
name="campaign_state",
),
nullable=False,
),
sa.Column("total_rows", sa.Integer(), nullable=True),
sa.Column("processed_rows", sa.Integer(), nullable=False),
sa.Column("failed_rows", sa.Integer(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("started_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(
["created_by"],
["users.id"],
),
sa.ForeignKeyConstraint(
["organization_id"],
["organizations.id"],
),
sa.ForeignKeyConstraint(
["workflow_id"],
["workflows.id"],
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_campaigns_id"), "campaigns", ["id"], unique=False)
op.create_index(op.f("ix_campaigns_name"), "campaigns", ["name"], unique=False)
op.create_index(
"ix_campaigns_org_id", "campaigns", ["organization_id"], unique=False
)
op.create_index("ix_campaigns_state", "campaigns", ["state"], unique=False)
op.create_index(
"ix_campaigns_workflow_id", "campaigns", ["workflow_id"], unique=False
)
op.add_column(
"workflow_runs", sa.Column("campaign_id", sa.Integer(), nullable=True)
)
op.create_foreign_key(None, "workflow_runs", "campaigns", ["campaign_id"], ["id"])
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(None, "workflow_runs", type_="foreignkey")
op.drop_column("workflow_runs", "campaign_id")
op.drop_index("ix_campaigns_workflow_id", table_name="campaigns")
op.drop_index("ix_campaigns_state", table_name="campaigns")
op.drop_index("ix_campaigns_org_id", table_name="campaigns")
op.drop_index(op.f("ix_campaigns_name"), table_name="campaigns")
op.drop_index(op.f("ix_campaigns_id"), table_name="campaigns")
op.drop_table("campaigns")
# ### end Alembic commands ###

View file

@ -0,0 +1,207 @@
"""make json not nullable
Revision ID: 0c1223cc266f
Revises: 0fe708f2acb9
Create Date: 2025-07-21 14:36:31.182969
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = "0c1223cc266f"
down_revision: Union[str, None] = "0fe708f2acb9"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Update existing NULL values to empty dict for workflows table
op.execute("""
UPDATE workflows
SET template_context_variables = '{}'::jsonb
WHERE template_context_variables IS NULL
""")
op.execute("""
UPDATE workflows
SET call_disposition_codes = '{}'::jsonb
WHERE call_disposition_codes IS NULL
""")
# Update existing NULL values to empty dict for workflow_runs table
op.execute("""
UPDATE workflow_runs
SET usage_info = '{}'::jsonb
WHERE usage_info IS NULL
""")
op.execute("""
UPDATE workflow_runs
SET cost_info = '{}'::jsonb
WHERE cost_info IS NULL
""")
op.execute("""
UPDATE workflow_runs
SET initial_context = '{}'::jsonb
WHERE initial_context IS NULL
""")
op.execute("""
UPDATE workflow_runs
SET gathered_context = '{}'::jsonb
WHERE gathered_context IS NULL
""")
op.execute("""
UPDATE workflow_runs
SET annotations = '{}'::jsonb
WHERE annotations IS NULL
""")
# Update existing NULL values to empty dict for looptalk_test_sessions table
op.execute("""
UPDATE looptalk_test_sessions
SET results = '{}'::jsonb
WHERE results IS NULL
""")
# Update existing NULL values to empty dict for looptalk_conversations table
op.execute("""
UPDATE looptalk_conversations
SET transcript = '{}'::jsonb
WHERE transcript IS NULL
""")
op.execute("""
UPDATE looptalk_conversations
SET metrics = '{}'::jsonb
WHERE metrics IS NULL
""")
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column(
"looptalk_conversations",
"transcript",
existing_type=postgresql.JSON(astext_type=sa.Text()),
nullable=False,
)
op.alter_column(
"looptalk_conversations",
"metrics",
existing_type=postgresql.JSON(astext_type=sa.Text()),
nullable=False,
)
op.alter_column(
"looptalk_test_sessions",
"results",
existing_type=postgresql.JSON(astext_type=sa.Text()),
nullable=False,
)
op.alter_column(
"workflow_runs",
"usage_info",
existing_type=postgresql.JSON(astext_type=sa.Text()),
nullable=False,
)
op.alter_column(
"workflow_runs",
"cost_info",
existing_type=postgresql.JSON(astext_type=sa.Text()),
nullable=False,
)
op.alter_column(
"workflow_runs",
"initial_context",
existing_type=postgresql.JSON(astext_type=sa.Text()),
nullable=False,
)
op.alter_column(
"workflow_runs",
"gathered_context",
existing_type=postgresql.JSON(astext_type=sa.Text()),
nullable=False,
)
op.alter_column(
"workflow_runs",
"annotations",
existing_type=postgresql.JSON(astext_type=sa.Text()),
nullable=False,
)
op.alter_column(
"workflows",
"template_context_variables",
existing_type=postgresql.JSON(astext_type=sa.Text()),
nullable=False,
)
op.alter_column(
"workflows",
"call_disposition_codes",
existing_type=postgresql.JSON(astext_type=sa.Text()),
nullable=False,
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column(
"workflows",
"call_disposition_codes",
existing_type=postgresql.JSON(astext_type=sa.Text()),
nullable=True,
)
op.alter_column(
"workflows",
"template_context_variables",
existing_type=postgresql.JSON(astext_type=sa.Text()),
nullable=True,
)
op.alter_column(
"workflow_runs",
"annotations",
existing_type=postgresql.JSON(astext_type=sa.Text()),
nullable=True,
)
op.alter_column(
"workflow_runs",
"gathered_context",
existing_type=postgresql.JSON(astext_type=sa.Text()),
nullable=True,
)
op.alter_column(
"workflow_runs",
"initial_context",
existing_type=postgresql.JSON(astext_type=sa.Text()),
nullable=True,
)
op.alter_column(
"workflow_runs",
"cost_info",
existing_type=postgresql.JSON(astext_type=sa.Text()),
nullable=True,
)
op.alter_column(
"workflow_runs",
"usage_info",
existing_type=postgresql.JSON(astext_type=sa.Text()),
nullable=True,
)
op.alter_column(
"looptalk_test_sessions",
"results",
existing_type=postgresql.JSON(astext_type=sa.Text()),
nullable=True,
)
op.alter_column(
"looptalk_conversations",
"metrics",
existing_type=postgresql.JSON(astext_type=sa.Text()),
nullable=True,
)
op.alter_column(
"looptalk_conversations",
"transcript",
existing_type=postgresql.JSON(astext_type=sa.Text()),
nullable=True,
)
# ### end Alembic commands ###

View file

@ -0,0 +1,51 @@
"""fix unique constraint on workflow definition
Revision ID: 0c1bbc83fe9e
Revises: 9f5f2d35f6fb
Create Date: 2025-06-04 15:56:02.979658
"""
from typing import Sequence, Union
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "0c1bbc83fe9e"
down_revision: Union[str, None] = "9f5f2d35f6fb"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(
"ix_workflow_definitions_workflow_hash", table_name="workflow_definitions"
)
op.create_index(
"ix_workflow_hash_workflow_id",
"workflow_definitions",
["workflow_hash", "workflow_id"],
unique=False,
)
op.create_unique_constraint(
"uq_workflow_hash_workflow_id",
"workflow_definitions",
["workflow_hash", "workflow_id"],
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(
"uq_workflow_hash_workflow_id", "workflow_definitions", type_="unique"
)
op.drop_index("ix_workflow_hash_workflow_id", table_name="workflow_definitions")
op.create_index(
"ix_workflow_definitions_workflow_hash",
"workflow_definitions",
["workflow_hash"],
unique=True,
)
# ### end Alembic commands ###

View file

@ -0,0 +1,32 @@
"""add call disposition codes
Revision ID: 0fe708f2acb9
Revises: 1225ac786848
Create Date: 2025-07-21 14:27:42.942689
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "0fe708f2acb9"
down_revision: Union[str, None] = "1225ac786848"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column(
"workflows", sa.Column("call_disposition_codes", sa.JSON(), nullable=True)
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column("workflows", "call_disposition_codes")
# ### end Alembic commands ###

View file

@ -0,0 +1,63 @@
"""add organization configurations
Revision ID: 1225ac786848
Revises: 1da1d650c0e4
Create Date: 2025-07-12 16:32:04.725072
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "1225ac786848"
down_revision: Union[str, None] = "1da1d650c0e4"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"organization_configurations",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("organization_id", sa.Integer(), nullable=False),
sa.Column("key", sa.String(), nullable=False),
sa.Column("value", sa.JSON(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(
["organization_id"], ["organizations.id"], ondelete="CASCADE"
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("organization_id", "key", name="_organization_key_uc"),
)
op.create_index(
op.f("ix_organization_configurations_id"),
"organization_configurations",
["id"],
unique=False,
)
op.create_index(
"ix_organization_configurations_organization_id",
"organization_configurations",
["organization_id"],
unique=False,
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(
"ix_organization_configurations_organization_id",
table_name="organization_configurations",
)
op.drop_index(
op.f("ix_organization_configurations_id"),
table_name="organization_configurations",
)
op.drop_table("organization_configurations")
# ### end Alembic commands ###

View file

@ -0,0 +1,39 @@
"""add workflow run mode
Revision ID: 13ccd6e1f5ad
Revises: 4735a1f0cdb3
Create Date: 2025-07-28 17:44:10.827671
"""
from typing import Sequence, Union
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "13ccd6e1f5ad"
down_revision: Union[str, None] = "4735a1f0cdb3"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.execute(
"ALTER TYPE workflow_run_mode ADD VALUE IF NOT EXISTS 'webrtc' AFTER 'CHAT';"
)
op.execute(
"ALTER TYPE workflow_run_mode ADD VALUE IF NOT EXISTS 'smallwebrtc' AFTER 'webrtc';"
)
op.execute(
"ALTER TYPE workflow_run_mode ADD VALUE IF NOT EXISTS 'twilio' AFTER 'smallwebrtc';"
)
op.execute(
"ALTER TYPE workflow_run_mode ADD VALUE IF NOT EXISTS 'stasis' AFTER 'twilio';"
)
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

View file

@ -0,0 +1,48 @@
"""Add user model
Revision ID: 1d441e79db94
Revises: 2d6e2f41caa2
Create Date: 2025-04-16 17:40:04.699129
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "1d441e79db94"
down_revision: Union[str, None] = "2d6e2f41caa2"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"users",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("provider_id", sa.String(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_users_id"), "users", ["id"], unique=False)
op.create_index(op.f("ix_users_provider_id"), "users", ["provider_id"], unique=True)
op.add_column("livekit_rooms", sa.Column("user_id", sa.Integer(), nullable=True))
op.create_foreign_key(None, "livekit_rooms", "users", ["user_id"], ["id"])
op.add_column("workflows", sa.Column("user_id", sa.Integer(), nullable=True))
op.create_foreign_key(None, "workflows", "users", ["user_id"], ["id"])
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(None, "workflows", type_="foreignkey")
op.drop_column("workflows", "user_id")
op.drop_constraint(None, "livekit_rooms", type_="foreignkey")
op.drop_column("livekit_rooms", "user_id")
op.drop_index(op.f("ix_users_provider_id"), table_name="users")
op.drop_index(op.f("ix_users_id"), table_name="users")
op.drop_table("users")
# ### end Alembic commands ###

View file

@ -0,0 +1,38 @@
"""add total call duration to usage
Revision ID: 1da1d650c0e4
Revises: ec010596a0b4
Create Date: 2025-07-08 15:41:15.039999
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "1da1d650c0e4"
down_revision: Union[str, None] = "ec010596a0b4"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column(
"organization_usage_cycles",
sa.Column(
"total_duration_seconds",
sa.Integer(),
nullable=False,
server_default=sa.text("0"),
),
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column("organization_usage_cycles", "total_duration_seconds")
# ### end Alembic commands ###

View file

@ -0,0 +1,30 @@
"""add provider
Revision ID: 20c780c2a218
Revises: ac6da37c5034
Create Date: 2025-06-09 19:23:00.111377
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "20c780c2a218"
down_revision: Union[str, None] = "ac6da37c5034"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column("integrations", sa.Column("provider", sa.String(), nullable=False))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column("integrations", "provider")
# ### end Alembic commands ###

View file

@ -0,0 +1,118 @@
"""added quota tables
Revision ID: 2159d4ac431a
Revises: e0d1a9b9f6c4
Create Date: 2025-07-08 14:38:38.542486
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "2159d4ac431a"
down_revision: Union[str, None] = "e0d1a9b9f6c4"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
# 1) Create the `quota_type` enum *before* we add the column that references it.
# Alembic does **not** automatically emit a CREATE TYPE statement for Enum
# objects when they are used inside `op.add_column()`. If we skip this step
# Postgres raises `type "quota_type" does not exist` when applying the
# migration.
quota_type_enum = sa.Enum("monthly", "annual", name="quota_type")
quota_type_enum.create(op.get_bind(), checkfirst=True)
op.create_table(
"organization_usage_cycles",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("organization_id", sa.Integer(), nullable=False),
sa.Column("period_start", sa.DateTime(), nullable=False),
sa.Column("period_end", sa.DateTime(), nullable=False),
sa.Column("quota_dograh_tokens", sa.Integer(), nullable=False),
sa.Column("used_dograh_tokens", sa.Integer(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(
["organization_id"],
["organizations.id"],
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"organization_id", "period_start", "period_end", name="unique_org_period"
),
)
op.create_index(
"idx_usage_cycles_org_period",
"organization_usage_cycles",
["organization_id", "period_end"],
unique=False,
)
op.create_index(
op.f("ix_organization_usage_cycles_id"),
"organization_usage_cycles",
["id"],
unique=False,
)
# Add the column now that the enum type exists.
op.add_column(
"organizations",
sa.Column(
"quota_type", quota_type_enum, nullable=False, server_default="monthly"
),
)
op.add_column(
"organizations",
sa.Column(
"quota_dograh_tokens",
sa.Integer(),
nullable=False,
server_default=sa.text("0"),
),
)
op.add_column(
"organizations",
sa.Column(
"quota_reset_day",
sa.Integer(),
nullable=False,
server_default=sa.text("LEAST(EXTRACT(DAY FROM CURRENT_DATE)::int, 28)"),
),
)
op.add_column(
"organizations", sa.Column("quota_start_date", sa.DateTime(), nullable=True)
)
op.add_column(
"organizations",
sa.Column(
"quota_enabled",
sa.Boolean(),
nullable=False,
server_default=sa.text("false"),
),
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column("organizations", "quota_enabled")
op.drop_column("organizations", "quota_start_date")
op.drop_column("organizations", "quota_reset_day")
op.drop_column("organizations", "quota_dograh_tokens")
op.drop_column("organizations", "quota_type")
op.drop_index(
op.f("ix_organization_usage_cycles_id"), table_name="organization_usage_cycles"
)
op.drop_index("idx_usage_cycles_org_period", table_name="organization_usage_cycles")
op.drop_table("organization_usage_cycles")
# Drop the enum type now that no column depends on it.
quota_type_enum = sa.Enum(name="quota_type")
quota_type_enum.drop(op.get_bind(), checkfirst=True)
# ### end Alembic commands ###

View file

@ -0,0 +1,46 @@
"""Add workflow run
Revision ID: 2d6e2f41caa2
Revises: bee2a9fcc6a6
Create Date: 2025-04-11 17:02:58.461460
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "2d6e2f41caa2"
down_revision: Union[str, None] = "bee2a9fcc6a6"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"workflow_runs",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("name", sa.String(), nullable=False),
sa.Column("workflow_id", sa.Integer(), nullable=False),
sa.Column(
"mode", sa.Enum("VOICE", "CHAT", name="workflow_run_mode"), nullable=False
),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(
["workflow_id"],
["workflows.id"],
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_workflow_runs_id"), "workflow_runs", ["id"], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f("ix_workflow_runs_id"), table_name="workflow_runs")
op.drop_table("workflow_runs")
# ### end Alembic commands ###

View file

@ -0,0 +1,27 @@
"""fix defaults
Revision ID: 2dfee251117b
Revises: 2159d4ac431a
Create Date: 2025-07-08 14:47:40.747689
"""
from typing import Sequence, Union
# revision identifiers, used by Alembic.
revision: str = "2dfee251117b"
down_revision: Union[str, None] = "2159d4ac431a"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

View file

@ -0,0 +1,30 @@
"""add annotations in workflow run model
Revision ID: 2ed4baa89f15
Revises: cdc80a4fd2dd
Create Date: 2025-06-17 13:30:43.784644
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "2ed4baa89f15"
down_revision: Union[str, None] = "cdc80a4fd2dd"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column("workflow_runs", sa.Column("annotations", sa.JSON(), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column("workflow_runs", "annotations")
# ### end Alembic commands ###

View file

@ -0,0 +1,38 @@
"""add workflow configurations
Revision ID: 3717ae6146e2
Revises: f952c9c1105a
Create Date: 2025-08-19 14:41:44.852794
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "3717ae6146e2"
down_revision: Union[str, None] = "f952c9c1105a"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column(
"workflows",
sa.Column(
"workflow_configurations",
sa.JSON(),
nullable=False,
server_default=sa.text("'{}'"),
),
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column("workflows", "workflow_configurations")
# ### end Alembic commands ###

View file

@ -0,0 +1,60 @@
"""Add API Keys model
Revision ID: 37d0a90fccba
Revises: c425d3445750
Create Date: 2025-09-06 14:19:35.300761
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "37d0a90fccba"
down_revision: Union[str, None] = "c425d3445750"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"api_keys",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("organization_id", sa.Integer(), nullable=False),
sa.Column("name", sa.String(), nullable=False),
sa.Column("key_hash", sa.String(), nullable=False),
sa.Column("key_prefix", sa.String(), nullable=False),
sa.Column("is_active", sa.Boolean(), nullable=False),
sa.Column("created_by", sa.Integer(), nullable=True),
sa.Column("last_used_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("archived_at", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(
["created_by"],
["users.id"],
),
sa.ForeignKeyConstraint(
["organization_id"], ["organizations.id"], ondelete="CASCADE"
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_api_keys_active", "api_keys", ["is_active"], unique=False)
op.create_index(op.f("ix_api_keys_id"), "api_keys", ["id"], unique=False)
op.create_index("ix_api_keys_key_hash", "api_keys", ["key_hash"], unique=True)
op.create_index(
"ix_api_keys_organization_id", "api_keys", ["organization_id"], unique=False
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index("ix_api_keys_organization_id", table_name="api_keys")
op.drop_index("ix_api_keys_key_hash", table_name="api_keys")
op.drop_index(op.f("ix_api_keys_id"), table_name="api_keys")
op.drop_index("ix_api_keys_active", table_name="api_keys")
op.drop_table("api_keys")
# ### end Alembic commands ###

View file

@ -0,0 +1,44 @@
"""add user configuration
Revision ID: 3a0384c5ab2e
Revises: 1d441e79db94
Create Date: 2025-04-24 12:26:33.635090
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "3a0384c5ab2e"
down_revision: Union[str, None] = "1d441e79db94"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"user_configurations",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("user_id", sa.Integer(), nullable=True),
sa.Column("configuration", sa.JSON(), nullable=False),
sa.ForeignKeyConstraint(
["user_id"],
["users.id"],
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
op.f("ix_user_configurations_id"), "user_configurations", ["id"], unique=False
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f("ix_user_configurations_id"), table_name="user_configurations")
op.drop_table("user_configurations")
# ### end Alembic commands ###

View file

@ -0,0 +1,64 @@
"""Added livekit room
Revision ID: 3a30110d7cd7
Revises: 93a1ddbb6ffd
Create Date: 2025-04-05 15:03:00.749510
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "3a30110d7cd7"
down_revision: Union[str, None] = "93a1ddbb6ffd"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"livekit_rooms",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column(
"type", sa.Enum("VOICE", "TEXT", "VIDEO", name="room_type"), nullable=False
),
sa.Column(
"state",
sa.Enum(
"CONNECTED",
"DISCONNECTED",
"RECONNECTING",
"RECONNECTED",
name="room_state",
),
nullable=False,
),
sa.Column("name", sa.String(), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_livekit_rooms_id"), "livekit_rooms", ["id"], unique=False)
op.create_index(
op.f("ix_livekit_rooms_name"), "livekit_rooms", ["name"], unique=False
)
op.create_index(
op.f("ix_livekit_rooms_state"), "livekit_rooms", ["state"], unique=False
)
op.create_index(
op.f("ix_livekit_rooms_type"), "livekit_rooms", ["type"], unique=False
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f("ix_livekit_rooms_type"), table_name="livekit_rooms")
op.drop_index(op.f("ix_livekit_rooms_state"), table_name="livekit_rooms")
op.drop_index(op.f("ix_livekit_rooms_name"), table_name="livekit_rooms")
op.drop_index(op.f("ix_livekit_rooms_id"), table_name="livekit_rooms")
op.drop_table("livekit_rooms")
# ### end Alembic commands ###

View file

@ -0,0 +1,36 @@
"""add selected organisation id field in user model
Revision ID: 45fa7fec2993
Revises: f6f19156bcb7
Create Date: 2025-06-09 16:00:52.209779
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "45fa7fec2993"
down_revision: Union[str, None] = "f6f19156bcb7"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column(
"users", sa.Column("selected_organization_id", sa.Integer(), nullable=True)
)
op.create_foreign_key(
None, "users", "organizations", ["selected_organization_id"], ["id"]
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(None, "users", type_="foreignkey")
op.drop_column("users", "selected_organization_id")
# ### end Alembic commands ###

View file

@ -0,0 +1,94 @@
"""add queued runs table
Revision ID: 4735a1f0cdb3
Revises: 08bb6e7f1397
Create Date: 2025-07-24 16:04:07.899092
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "4735a1f0cdb3"
down_revision: Union[str, None] = "08bb6e7f1397"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"queued_runs",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("campaign_id", sa.Integer(), nullable=False),
sa.Column("source_uuid", sa.String(), nullable=False),
sa.Column("context_variables", sa.JSON(), nullable=False),
sa.Column(
"state",
sa.Enum("queued", "processed", name="queued_run_state"),
nullable=False,
),
sa.Column("workflow_run_id", sa.Integer(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("processed_at", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(["campaign_id"], ["campaigns.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(
["workflow_run_id"],
["workflow_runs.id"],
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"campaign_id", "source_uuid", name="unique_campaign_source_uuid"
),
)
op.create_index(
"idx_queued_runs_campaign_state",
"queued_runs",
["campaign_id", "state"],
unique=False,
)
op.create_index(
"idx_queued_runs_created", "queued_runs", ["created_at"], unique=False
)
op.create_index(
"idx_queued_runs_source_uuid", "queued_runs", ["source_uuid"], unique=False
)
op.create_index(op.f("ix_queued_runs_id"), "queued_runs", ["id"], unique=False)
op.add_column(
"campaigns", sa.Column("rate_limit_per_second", sa.Integer(), nullable=False)
)
op.add_column("campaigns", sa.Column("max_retries", sa.Integer(), nullable=False))
op.add_column(
"campaigns", sa.Column("source_sync_status", sa.String(), nullable=False)
)
op.add_column(
"campaigns",
sa.Column("source_last_synced_at", sa.DateTime(timezone=True), nullable=True),
)
op.add_column(
"campaigns", sa.Column("source_sync_error", sa.String(), nullable=True)
)
# Add syncing in the campaign_state
op.execute(
"ALTER TYPE campaign_state ADD VALUE IF NOT EXISTS 'syncing' AFTER 'created';"
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column("campaigns", "source_sync_error")
op.drop_column("campaigns", "source_last_synced_at")
op.drop_column("campaigns", "source_sync_status")
op.drop_column("campaigns", "max_retries")
op.drop_column("campaigns", "rate_limit_per_second")
op.drop_index(op.f("ix_queued_runs_id"), table_name="queued_runs")
op.drop_index("idx_queued_runs_source_uuid", table_name="queued_runs")
op.drop_index("idx_queued_runs_created", table_name="queued_runs")
op.drop_index("idx_queued_runs_campaign_state", table_name="queued_runs")
op.drop_table("queued_runs")
# ### end Alembic commands ###

View file

@ -0,0 +1,36 @@
"""add organisation_id in workflow
Revision ID: 477b47ce346b
Revises: fec0fb9a8db7
Create Date: 2025-08-15 14:11:28.088841
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "477b47ce346b"
down_revision: Union[str, None] = "fec0fb9a8db7"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column(
"workflows", sa.Column("organization_id", sa.Integer(), nullable=True)
)
op.create_foreign_key(
None, "workflows", "organizations", ["organization_id"], ["id"]
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(None, "workflows", type_="foreignkey")
op.drop_column("workflows", "organization_id")
# ### end Alembic commands ###

View file

@ -0,0 +1,32 @@
"""add teplate context variables in workflow
Revision ID: 5253971e3f03
Revises: 0c1bbc83fe9e
Create Date: 2025-06-05 14:06:51.059664
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "5253971e3f03"
down_revision: Union[str, None] = "0c1bbc83fe9e"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column(
"workflows", sa.Column("template_context_variables", sa.JSON(), nullable=True)
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column("workflows", "template_context_variables")
# ### end Alembic commands ###

View file

@ -0,0 +1,66 @@
"""add workflow definition
Revision ID: 58f17b468b3c
Revises: a2b092ff7282
Create Date: 2025-06-03 13:08:23.709070
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "58f17b468b3c"
down_revision: Union[str, None] = "a2b092ff7282"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"workflow_definitions",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("workflow_hash", sa.String(), nullable=False),
sa.Column("workflow_json", sa.JSON(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
op.f("ix_workflow_definitions_id"), "workflow_definitions", ["id"], unique=False
)
op.create_index(
op.f("ix_workflow_definitions_workflow_hash"),
"workflow_definitions",
["workflow_hash"],
unique=True,
)
op.add_column(
"workflow_runs", sa.Column("definition_id", sa.Integer(), nullable=True)
)
op.create_foreign_key(
None, "workflow_runs", "workflow_definitions", ["definition_id"], ["id"]
)
op.add_column(
"workflows", sa.Column("current_definition_id", sa.Integer(), nullable=True)
)
op.create_foreign_key(
None, "workflows", "workflow_definitions", ["current_definition_id"], ["id"]
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(None, "workflows", type_="foreignkey")
op.drop_column("workflows", "current_definition_id")
op.drop_constraint(None, "workflow_runs", type_="foreignkey")
op.drop_column("workflow_runs", "definition_id")
op.drop_index(
op.f("ix_workflow_definitions_workflow_hash"), table_name="workflow_definitions"
)
op.drop_index(op.f("ix_workflow_definitions_id"), table_name="workflow_definitions")
op.drop_table("workflow_definitions")
# ### end Alembic commands ###

View file

@ -0,0 +1,71 @@
"""add timezone info on tables
Revision ID: 594f16adf97c
Revises: 2dfee251117b
Create Date: 2025-07-08 15:01:26.642043
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = "594f16adf97c"
down_revision: Union[str, None] = "2dfee251117b"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column(
"organization_usage_cycles",
"period_start",
existing_type=postgresql.TIMESTAMP(),
type_=sa.DateTime(timezone=True),
existing_nullable=False,
)
op.alter_column(
"organization_usage_cycles",
"period_end",
existing_type=postgresql.TIMESTAMP(),
type_=sa.DateTime(timezone=True),
existing_nullable=False,
)
op.alter_column(
"organizations",
"quota_start_date",
existing_type=postgresql.TIMESTAMP(),
type_=sa.DateTime(timezone=True),
existing_nullable=True,
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column(
"organizations",
"quota_start_date",
existing_type=sa.DateTime(timezone=True),
type_=postgresql.TIMESTAMP(),
existing_nullable=True,
)
op.alter_column(
"organization_usage_cycles",
"period_end",
existing_type=sa.DateTime(timezone=True),
type_=postgresql.TIMESTAMP(),
existing_nullable=False,
)
op.alter_column(
"organization_usage_cycles",
"period_start",
existing_type=sa.DateTime(timezone=True),
type_=postgresql.TIMESTAMP(),
existing_nullable=False,
)
# ### end Alembic commands ###

View file

@ -0,0 +1,54 @@
"""add status in workflow
Revision ID: 693a865c011f
Revises: 477b47ce346b
Create Date: 2025-08-15 14:27:46.748114
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "693a865c011f"
down_revision: Union[str, None] = "477b47ce346b"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
# First create the enum type
workflow_status_enum = sa.Enum("active", "archived", name="workflow_status")
workflow_status_enum.create(op.get_bind(), checkfirst=True)
# Then add the column with the enum type
op.add_column(
"workflows",
sa.Column(
"status",
workflow_status_enum,
nullable=False,
server_default=sa.text("'active'"),
),
)
# Create index for better query performance
op.create_index("ix_workflows_status", "workflows", ["status"])
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
# Drop index first
op.drop_index("ix_workflows_status", table_name="workflows")
# Drop the column
op.drop_column("workflows", "status")
# Drop the enum type
workflow_status_enum = sa.Enum("active", "archived", name="workflow_status")
workflow_status_enum.drop(op.get_bind(), checkfirst=True)
# ### end Alembic commands ###

View file

@ -0,0 +1,50 @@
"""add workflow template
Revision ID: 7e90cc8d025b
Revises: 9f25ff8f3cbd
Create Date: 2025-06-10 18:17:55.080973
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "7e90cc8d025b"
down_revision: Union[str, None] = "9f25ff8f3cbd"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"workflow_templates",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("template_name", sa.String(), nullable=False),
sa.Column("template_json", sa.JSON(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
op.f("ix_workflow_templates_id"), "workflow_templates", ["id"], unique=False
)
op.create_index(
op.f("ix_workflow_templates_template_name"),
"workflow_templates",
["template_name"],
unique=False,
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(
op.f("ix_workflow_templates_template_name"), table_name="workflow_templates"
)
op.drop_index(op.f("ix_workflow_templates_id"), table_name="workflow_templates")
op.drop_table("workflow_templates")
# ### end Alembic commands ###

View file

@ -0,0 +1,32 @@
"""add price_per_second_usd
Revision ID: 7feef09d7cc6
Revises: 3717ae6146e2
Create Date: 2025-08-25 13:04:13.436156
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "7feef09d7cc6"
down_revision: Union[str, None] = "3717ae6146e2"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column(
"organizations", sa.Column("price_per_second_usd", sa.Float(), nullable=True)
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column("organizations", "price_per_second_usd")
# ### end Alembic commands ###

View file

@ -0,0 +1,41 @@
"""Add workflow model
Revision ID: 93a1ddbb6ffd
Revises:
Create Date: 2025-04-03 20:06:47.970117
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "93a1ddbb6ffd"
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"workflows",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("name", sa.String(), nullable=False),
sa.Column("workflow_definition", sa.JSON(), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_workflows_id"), "workflows", ["id"], unique=False)
op.create_index(op.f("ix_workflows_name"), "workflows", ["name"], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f("ix_workflows_name"), table_name="workflows")
op.drop_index(op.f("ix_workflows_id"), table_name="workflows")
op.drop_table("workflows")
# ### end Alembic commands ###

View file

@ -0,0 +1,30 @@
"""add is_superuser to user model
Revision ID: 9641b4f306cd
Revises: 3a0384c5ab2e
Create Date: 2025-05-07 11:37:44.295774
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "9641b4f306cd"
down_revision: Union[str, None] = "3a0384c5ab2e"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column("users", sa.Column("is_superuser", sa.Boolean(), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column("users", "is_superuser")
# ### end Alembic commands ###

View file

@ -0,0 +1,87 @@
"""add storage backend in workflow
Revision ID: 982ec8e434be
Revises: d0060de90c18
Create Date: 2025-09-09 10:39:12.894626
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from alembic_postgresql_enum import TableReference
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = "982ec8e434be"
down_revision: Union[str, None] = "d0060de90c18"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
sa.Enum("s3", "minio", name="storage_backend").create(op.get_bind())
op.drop_constraint(
"queued_runs_workflow_run_id_fkey", "queued_runs", type_="foreignkey"
)
op.drop_column("queued_runs", "workflow_run_id")
op.add_column(
"workflow_runs",
sa.Column(
"storage_backend",
postgresql.ENUM("s3", "minio", name="storage_backend", create_type=False),
server_default=sa.text("'s3'::storage_backend"),
nullable=False,
),
)
sa.Enum("VOICE", "TEXT", "VIDEO", name="room_type").drop(op.get_bind())
sa.Enum(
"CONNECTED", "DISCONNECTED", "RECONNECTING", "RECONNECTED", name="room_state"
).drop(op.get_bind())
op.sync_enum_values(
enum_schema="public",
enum_name="workflow_run_mode",
new_values=["twilio", "stasis", "webrtc", "smallwebrtc", "VOICE", "CHAT"],
affected_columns=[
TableReference(
table_schema="public", table_name="workflow_runs", column_name="mode"
)
],
enum_values_to_rename=[],
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.sync_enum_values(
enum_schema="public",
enum_name="workflow_run_mode",
new_values=["VOICE", "CHAT", "webrtc", "smallwebrtc", "twilio", "stasis"],
affected_columns=[
TableReference(
table_schema="public", table_name="workflow_runs", column_name="mode"
)
],
enum_values_to_rename=[],
)
sa.Enum(
"CONNECTED", "DISCONNECTED", "RECONNECTING", "RECONNECTED", name="room_state"
).create(op.get_bind())
sa.Enum("VOICE", "TEXT", "VIDEO", name="room_type").create(op.get_bind())
op.drop_column("workflow_runs", "storage_backend")
op.add_column(
"queued_runs",
sa.Column("workflow_run_id", sa.INTEGER(), autoincrement=False, nullable=True),
)
op.create_foreign_key(
"queued_runs_workflow_run_id_fkey",
"queued_runs",
"workflow_runs",
["workflow_run_id"],
["id"],
)
sa.Enum("s3", "minio", name="storage_backend").drop(op.get_bind())
# ### end Alembic commands ###

View file

@ -0,0 +1,40 @@
"""add recording and transcript urls in workflow run
Revision ID: 9be6240baa00
Revises: 9641b4f306cd
Create Date: 2025-05-09 21:50:03.369283
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "9be6240baa00"
down_revision: Union[str, None] = "9641b4f306cd"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column(
"workflow_runs", sa.Column("is_completed", sa.Boolean(), nullable=True)
)
op.add_column(
"workflow_runs", sa.Column("recording_url", sa.String(), nullable=True)
)
op.add_column(
"workflow_runs", sa.Column("transcript_url", sa.String(), nullable=True)
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column("workflow_runs", "transcript_url")
op.drop_column("workflow_runs", "recording_url")
op.drop_column("workflow_runs", "is_completed")
# ### end Alembic commands ###

View file

@ -0,0 +1,36 @@
"""add workflow id in workflow definition
Revision ID: 9ef49df72862
Revises: 58f17b468b3c
Create Date: 2025-06-04 10:54:01.714149
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "9ef49df72862"
down_revision: Union[str, None] = "58f17b468b3c"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column(
"workflow_definitions", sa.Column("workflow_id", sa.Integer(), nullable=True)
)
op.create_foreign_key(
None, "workflow_definitions", "workflows", ["workflow_id"], ["id"]
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(None, "workflow_definitions", type_="foreignkey")
op.drop_column("workflow_definitions", "workflow_id")
# ### end Alembic commands ###

View file

@ -0,0 +1,30 @@
"""add actions
Revision ID: 9f25ff8f3cbd
Revises: d11fbd083a55
Create Date: 2025-06-09 20:16:19.418495
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "9f25ff8f3cbd"
down_revision: Union[str, None] = "d11fbd083a55"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column("integrations", sa.Column("action", sa.String(), nullable=False))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column("integrations", "action")
# ### end Alembic commands ###

View file

@ -0,0 +1,52 @@
"""remove workflow definition id from workflow
Revision ID: 9f5f2d35f6fb
Revises: 9ef49df72862
Create Date: 2025-06-04 13:27:45.776741
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "9f5f2d35f6fb"
down_revision: Union[str, None] = "9ef49df72862"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column(
"workflow_definitions",
sa.Column(
"is_current", sa.Boolean(), nullable=False, server_default=sa.false()
),
)
op.drop_constraint(
"workflows_current_definition_id_fkey", "workflows", type_="foreignkey"
)
op.drop_column("workflows", "current_definition_id")
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column(
"workflows",
sa.Column(
"current_definition_id", sa.INTEGER(), autoincrement=False, nullable=True
),
)
op.create_foreign_key(
"workflows_current_definition_id_fkey",
"workflows",
"workflow_definitions",
["current_definition_id"],
["id"],
)
op.drop_column("workflow_definitions", "is_current")
# ### end Alembic commands ###

View file

@ -0,0 +1,33 @@
"""add last validated at
Revision ID: a29b05f31ddf
Revises: 9be6240baa00
Create Date: 2025-05-21 22:59:27.008241
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "a29b05f31ddf"
down_revision: Union[str, None] = "9be6240baa00"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column(
"user_configurations",
sa.Column("last_validated_at", sa.DateTime(timezone=True), nullable=True),
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column("user_configurations", "last_validated_at")
# ### end Alembic commands ###

View file

@ -0,0 +1,40 @@
"""add usage info
Revision ID: a2b092ff7282
Revises: a29b05f31ddf
Create Date: 2025-05-29 20:25:37.110818
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "a2b092ff7282"
down_revision: Union[str, None] = "a29b05f31ddf"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column("workflow_runs", sa.Column("usage_info", sa.JSON(), nullable=True))
op.add_column("workflow_runs", sa.Column("cost_info", sa.JSON(), nullable=True))
op.add_column(
"workflow_runs", sa.Column("initial_context", sa.JSON(), nullable=True)
)
op.add_column(
"workflow_runs", sa.Column("gathered_context", sa.JSON(), nullable=True)
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column("workflow_runs", "gathered_context")
op.drop_column("workflow_runs", "initial_context")
op.drop_column("workflow_runs", "cost_info")
op.drop_column("workflow_runs", "usage_info")
# ### end Alembic commands ###

View file

@ -0,0 +1,38 @@
"""add log in workflow run model
Revision ID: a75ae71af479
Revises: 13ccd6e1f5ad
Create Date: 2025-08-05 22:37:06.248630
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "a75ae71af479"
down_revision: Union[str, None] = "13ccd6e1f5ad"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column(
"workflow_runs",
sa.Column(
"logs",
sa.JSON(),
nullable=False,
server_default=sa.text("'{}'"),
),
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column("workflow_runs", "logs")
# ### end Alembic commands ###

View file

@ -0,0 +1,32 @@
"""add created by in integration
Revision ID: ac6da37c5034
Revises: d666f3244648
Create Date: 2025-06-09 17:27:37.088950
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "ac6da37c5034"
down_revision: Union[str, None] = "d666f3244648"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column("integrations", sa.Column("created_by", sa.Integer(), nullable=True))
op.create_foreign_key(None, "integrations", "users", ["created_by"], ["id"])
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(None, "integrations", type_="foreignkey")
op.drop_column("integrations", "created_by")
# ### end Alembic commands ###

View file

@ -0,0 +1,57 @@
"""fix datetime to be in utc
Revision ID: bee2a9fcc6a6
Revises: 3a30110d7cd7
Create Date: 2025-04-05 15:40:00.807226
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = "bee2a9fcc6a6"
down_revision: Union[str, None] = "3a30110d7cd7"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column(
"livekit_rooms",
"created_at",
existing_type=postgresql.TIMESTAMP(),
type_=sa.DateTime(timezone=True),
existing_nullable=True,
)
op.alter_column(
"workflows",
"created_at",
existing_type=postgresql.TIMESTAMP(),
type_=sa.DateTime(timezone=True),
existing_nullable=True,
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column(
"workflows",
"created_at",
existing_type=sa.DateTime(timezone=True),
type_=postgresql.TIMESTAMP(),
existing_nullable=True,
)
op.alter_column(
"livekit_rooms",
"created_at",
existing_type=sa.DateTime(timezone=True),
type_=postgresql.TIMESTAMP(),
existing_nullable=True,
)
# ### end Alembic commands ###

View file

@ -0,0 +1,38 @@
"""add columns in usage table
Revision ID: c425d3445750
Revises: 7feef09d7cc6
Create Date: 2025-08-25 13:37:15.524965
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "c425d3445750"
down_revision: Union[str, None] = "7feef09d7cc6"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column(
"organization_usage_cycles",
sa.Column("used_amount_usd", sa.Float(), nullable=True),
)
op.add_column(
"organization_usage_cycles",
sa.Column("quota_amount_usd", sa.Float(), nullable=True),
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column("organization_usage_cycles", "quota_amount_usd")
op.drop_column("organization_usage_cycles", "used_amount_usd")
# ### end Alembic commands ###

View file

@ -0,0 +1,43 @@
"""add template description
Revision ID: cdc80a4fd2dd
Revises: 7e90cc8d025b
Create Date: 2025-06-10 18:31:30.952698
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "cdc80a4fd2dd"
down_revision: Union[str, None] = "7e90cc8d025b"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column(
"workflow_templates",
sa.Column("template_description", sa.String(), nullable=False),
)
op.create_index(
op.f("ix_workflow_templates_template_description"),
"workflow_templates",
["template_description"],
unique=False,
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(
op.f("ix_workflow_templates_template_description"),
table_name="workflow_templates",
)
op.drop_column("workflow_templates", "template_description")
# ### end Alembic commands ###

View file

@ -0,0 +1,63 @@
"""Fix Migrations
Revision ID: d0060de90c18
Revises: 37d0a90fccba
Create Date: 2025-09-08 13:19:03.854682
"""
from typing import Sequence, Union
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "d0060de90c18"
down_revision: Union[str, None] = "37d0a90fccba"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index("ix_api_keys_key_hash", table_name="api_keys")
op.create_index(op.f("ix_api_keys_key_hash"), "api_keys", ["key_hash"], unique=True)
# remove redundant id indexes that got created
op.drop_index("ix_api_keys_id", table_name="api_keys")
op.drop_index("ix_users_id", table_name="users")
op.drop_index("ix_organizations_id", table_name="organizations")
op.drop_index("ix_workflows_id", table_name="workflows")
op.drop_index("ix_workflow_definitions_id", table_name="workflow_definitions")
op.drop_index("ix_workflow_templates_id", table_name="workflow_templates")
op.drop_index("ix_workflow_runs_id", table_name="workflow_runs")
op.drop_index("ix_campaigns_id", table_name="campaigns")
op.drop_index("ix_integrations_id", table_name="integrations")
op.drop_index("ix_queued_runs_id", table_name="queued_runs")
op.drop_index("ix_user_configurations_id", table_name="user_configurations")
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f("ix_api_keys_key_hash"), table_name="api_keys")
op.create_index("ix_api_keys_key_hash", "api_keys", ["key_hash"], unique=False)
# Recreate all the redundant id indexes (reverting to the incorrect state)
op.create_index(
"ix_user_configurations_id", "user_configurations", ["id"], unique=False
)
op.create_index("ix_queued_runs_id", "queued_runs", ["id"], unique=False)
op.create_index("ix_integrations_id", "integrations", ["id"], unique=False)
op.create_index("ix_campaigns_id", "campaigns", ["id"], unique=False)
op.create_index("ix_workflow_runs_id", "workflow_runs", ["id"], unique=False)
op.create_index(
"ix_workflow_templates_id", "workflow_templates", ["id"], unique=False
)
op.create_index(
"ix_workflow_definitions_id", "workflow_definitions", ["id"], unique=False
)
op.create_index("ix_workflows_id", "workflows", ["id"], unique=False)
op.create_index("ix_organizations_id", "organizations", ["id"], unique=False)
op.create_index("ix_users_id", "users", ["id"], unique=False)
op.create_index("ix_api_keys_id", "api_keys", ["id"], unique=False)
# ### end Alembic commands ###

View file

@ -0,0 +1,32 @@
"""add connection details
Revision ID: d11fbd083a55
Revises: 20c780c2a218
Create Date: 2025-06-09 19:49:24.999108
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "d11fbd083a55"
down_revision: Union[str, None] = "20c780c2a218"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column(
"integrations", sa.Column("connection_details", sa.JSON(), nullable=False)
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column("integrations", "connection_details")
# ### end Alembic commands ###

View file

@ -0,0 +1,51 @@
"""add integrations
Revision ID: d666f3244648
Revises: 45fa7fec2993
Create Date: 2025-06-09 16:50:56.647020
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "d666f3244648"
down_revision: Union[str, None] = "45fa7fec2993"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"integrations",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("integration_id", sa.String(), nullable=False),
sa.Column("organisation_id", sa.Integer(), nullable=False),
sa.Column("is_active", sa.Boolean(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(
["organisation_id"],
["organizations.id"],
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_integrations_id"), "integrations", ["id"], unique=False)
op.create_index(
op.f("ix_integrations_integration_id"),
"integrations",
["integration_id"],
unique=False,
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f("ix_integrations_integration_id"), table_name="integrations")
op.drop_index(op.f("ix_integrations_id"), table_name="integrations")
op.drop_table("integrations")
# ### end Alembic commands ###

View file

@ -0,0 +1,148 @@
"""add_looptalk_testing_tables_without_turns
Revision ID: e0d1a9b9f6c4
Revises: 2ed4baa89f15
Create Date: 2025-07-01 14:29:12.081935
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "e0d1a9b9f6c4"
down_revision: Union[str, None] = "2ed4baa89f15"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"looptalk_test_sessions",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("organization_id", sa.Integer(), nullable=False),
sa.Column("name", sa.String(), nullable=False),
sa.Column(
"status",
sa.Enum(
"pending", "running", "completed", "failed", name="test_session_status"
),
nullable=False,
),
sa.Column("actor_workflow_id", sa.Integer(), nullable=False),
sa.Column("adversary_workflow_id", sa.Integer(), nullable=False),
sa.Column("load_test_group_id", sa.String(), nullable=True),
sa.Column("test_index", sa.Integer(), nullable=True),
sa.Column("config", sa.JSON(), nullable=False),
sa.Column("results", sa.JSON(), nullable=True),
sa.Column("error", sa.String(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("started_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(
["actor_workflow_id"],
["workflows.id"],
),
sa.ForeignKeyConstraint(
["adversary_workflow_id"],
["workflows.id"],
),
sa.ForeignKeyConstraint(
["organization_id"],
["organizations.id"],
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
"ix_looptalk_test_sessions_group_id",
"looptalk_test_sessions",
["load_test_group_id"],
unique=False,
)
op.create_index(
op.f("ix_looptalk_test_sessions_id"),
"looptalk_test_sessions",
["id"],
unique=False,
)
op.create_index(
op.f("ix_looptalk_test_sessions_load_test_group_id"),
"looptalk_test_sessions",
["load_test_group_id"],
unique=False,
)
op.create_index(
"ix_looptalk_test_sessions_org_id",
"looptalk_test_sessions",
["organization_id"],
unique=False,
)
op.create_index(
"ix_looptalk_test_sessions_status",
"looptalk_test_sessions",
["status"],
unique=False,
)
op.create_table(
"looptalk_conversations",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("test_session_id", sa.Integer(), nullable=False),
sa.Column("duration_seconds", sa.Integer(), nullable=True),
sa.Column("actor_recording_url", sa.String(), nullable=True),
sa.Column("adversary_recording_url", sa.String(), nullable=True),
sa.Column("combined_recording_url", sa.String(), nullable=True),
sa.Column("transcript", sa.JSON(), nullable=True),
sa.Column("metrics", sa.JSON(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("ended_at", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(
["test_session_id"],
["looptalk_test_sessions.id"],
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
op.f("ix_looptalk_conversations_id"),
"looptalk_conversations",
["id"],
unique=False,
)
op.create_index(
"ix_looptalk_conversations_session_id",
"looptalk_conversations",
["test_session_id"],
unique=False,
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(
"ix_looptalk_conversations_session_id", table_name="looptalk_conversations"
)
op.drop_index(
op.f("ix_looptalk_conversations_id"), table_name="looptalk_conversations"
)
op.drop_table("looptalk_conversations")
op.drop_index(
"ix_looptalk_test_sessions_status", table_name="looptalk_test_sessions"
)
op.drop_index(
"ix_looptalk_test_sessions_org_id", table_name="looptalk_test_sessions"
)
op.drop_index(
op.f("ix_looptalk_test_sessions_load_test_group_id"),
table_name="looptalk_test_sessions",
)
op.drop_index(
op.f("ix_looptalk_test_sessions_id"), table_name="looptalk_test_sessions"
)
op.drop_index(
"ix_looptalk_test_sessions_group_id", table_name="looptalk_test_sessions"
)
op.drop_table("looptalk_test_sessions")
# ### end Alembic commands ###

View file

@ -0,0 +1,42 @@
"""change datatype of usage to float
Revision ID: ec010596a0b4
Revises: 594f16adf97c
Create Date: 2025-07-08 15:25:49.387567
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "ec010596a0b4"
down_revision: Union[str, None] = "594f16adf97c"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column(
"organization_usage_cycles",
"used_dograh_tokens",
existing_type=sa.INTEGER(),
type_=sa.Float(),
existing_nullable=False,
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column(
"organization_usage_cycles",
"used_dograh_tokens",
existing_type=sa.Float(),
type_=sa.INTEGER(),
existing_nullable=False,
)
# ### end Alembic commands ###

View file

@ -0,0 +1,104 @@
"""add organisation table
Revision ID: f6f19156bcb7
Revises: 5253971e3f03
Create Date: 2025-06-09 15:12:14.636802
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = "f6f19156bcb7"
down_revision: Union[str, None] = "5253971e3f03"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"organizations",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("provider_id", sa.String(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_organizations_id"), "organizations", ["id"], unique=False)
op.create_index(
op.f("ix_organizations_provider_id"),
"organizations",
["provider_id"],
unique=True,
)
op.create_table(
"organization_users",
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("organization_id", sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(
["organization_id"],
["organizations.id"],
),
sa.ForeignKeyConstraint(
["user_id"],
["users.id"],
),
sa.PrimaryKeyConstraint("user_id", "organization_id"),
)
op.drop_index("ix_livekit_rooms_id", table_name="livekit_rooms")
op.drop_index("ix_livekit_rooms_name", table_name="livekit_rooms")
op.drop_index("ix_livekit_rooms_state", table_name="livekit_rooms")
op.drop_index("ix_livekit_rooms_type", table_name="livekit_rooms")
op.drop_table("livekit_rooms")
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"livekit_rooms",
sa.Column("id", sa.INTEGER(), autoincrement=True, nullable=False),
sa.Column(
"type",
postgresql.ENUM("VOICE", "TEXT", "VIDEO", name="room_type"),
autoincrement=False,
nullable=False,
),
sa.Column(
"state",
postgresql.ENUM(
"CONNECTED",
"DISCONNECTED",
"RECONNECTING",
"RECONNECTED",
name="room_state",
),
autoincrement=False,
nullable=False,
),
sa.Column("name", sa.VARCHAR(), autoincrement=False, nullable=False),
sa.Column(
"created_at",
postgresql.TIMESTAMP(timezone=True),
autoincrement=False,
nullable=True,
),
sa.Column("user_id", sa.INTEGER(), autoincrement=False, nullable=True),
sa.ForeignKeyConstraint(
["user_id"], ["users.id"], name="livekit_rooms_user_id_fkey"
),
sa.PrimaryKeyConstraint("id", name="livekit_rooms_pkey"),
)
op.create_index("ix_livekit_rooms_type", "livekit_rooms", ["type"], unique=False)
op.create_index("ix_livekit_rooms_state", "livekit_rooms", ["state"], unique=False)
op.create_index("ix_livekit_rooms_name", "livekit_rooms", ["name"], unique=False)
op.create_index("ix_livekit_rooms_id", "livekit_rooms", ["id"], unique=False)
op.drop_table("organization_users")
op.drop_index(op.f("ix_organizations_provider_id"), table_name="organizations")
op.drop_index(op.f("ix_organizations_id"), table_name="organizations")
op.drop_table("organizations")
# ### end Alembic commands ###

View file

@ -0,0 +1,30 @@
"""add failed state to queued runs
Revision ID: f952c9c1105a
Revises: 693a865c011f
Create Date: 2025-08-16 17:32:47.877340
"""
from typing import Sequence, Union
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "f952c9c1105a"
down_revision: Union[str, None] = "693a865c011f"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index("ix_workflows_status", table_name="workflows")
op.execute("ALTER TYPE queued_run_state ADD VALUE IF NOT EXISTS 'failed'")
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_index("ix_workflows_status", "workflows", ["status"], unique=False)
# ### end Alembic commands ###

View file

@ -0,0 +1,84 @@
"""add index
Revision ID: fec0fb9a8db7
Revises: fefdd1835b7d
Create Date: 2025-08-06 16:21:56.450309
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "fec0fb9a8db7"
down_revision: Union[str, None] = "fefdd1835b7d"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column(
"campaigns",
sa.Column("last_batch_scheduled_at", sa.DateTime(timezone=True), nullable=True),
)
op.add_column(
"campaigns",
sa.Column("last_activity_at", sa.DateTime(timezone=True), nullable=True),
)
op.add_column(
"campaigns",
sa.Column(
"orchestrator_metadata",
sa.JSON(),
nullable=False,
server_default=sa.text("'{}'"),
),
)
op.create_index(
"idx_campaigns_active_status",
"campaigns",
["state"],
unique=False,
postgresql_where=sa.text("state IN ('syncing', 'running', 'paused')"),
)
op.create_index(
"idx_queued_runs_campaign_state_optimized",
"queued_runs",
["campaign_id", "state"],
unique=False,
postgresql_where=sa.text("state = 'queued'"),
)
op.create_index(
"idx_queued_runs_scheduled_optimized",
"queued_runs",
["campaign_id", "scheduled_for"],
unique=False,
postgresql_where=sa.text("scheduled_for IS NOT NULL"),
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(
"idx_queued_runs_scheduled_optimized",
table_name="queued_runs",
postgresql_where=sa.text("scheduled_for IS NOT NULL"),
)
op.drop_index(
"idx_queued_runs_campaign_state_optimized",
table_name="queued_runs",
postgresql_where=sa.text("state = 'queued'"),
)
op.drop_index(
"idx_campaigns_active_status",
table_name="campaigns",
postgresql_where=sa.text("state IN ('syncing', 'running', 'paused')"),
)
op.drop_column("campaigns", "orchestrator_metadata")
op.drop_column("campaigns", "last_activity_at")
op.drop_column("campaigns", "last_batch_scheduled_at")
# ### end Alembic commands ###

View file

@ -0,0 +1,84 @@
"""retry outbound calls for campaigns
Revision ID: fefdd1835b7d
Revises: a75ae71af479
Create Date: 2025-08-06 13:37:54.151453
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "fefdd1835b7d"
down_revision: Union[str, None] = "a75ae71af479"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column(
"campaigns",
sa.Column(
"retry_config",
sa.JSON(),
nullable=False,
server_default=sa.text(
"""'{\"enabled\": true, \"max_retries\": 2, \"retry_delay_seconds\": 120, \"retry_on_busy\": true, \"retry_on_no_answer\": true, \"retry_on_voicemail\": true}'::jsonb"""
),
),
)
op.add_column(
"queued_runs",
sa.Column(
"retry_count", sa.Integer(), nullable=False, server_default=sa.text("0")
),
)
op.add_column(
"queued_runs", sa.Column("parent_queued_run_id", sa.Integer(), nullable=True)
)
op.add_column(
"queued_runs",
sa.Column("scheduled_for", sa.DateTime(timezone=True), nullable=True),
)
op.add_column("queued_runs", sa.Column("retry_reason", sa.String(), nullable=True))
op.drop_constraint("unique_campaign_source_uuid", "queued_runs", type_="unique")
op.create_index(
"idx_queued_runs_scheduled", "queued_runs", ["scheduled_for"], unique=False
)
op.create_unique_constraint(
"unique_campaign_source_retry",
"queued_runs",
["campaign_id", "source_uuid", "retry_count"],
)
op.create_foreign_key(
None, "queued_runs", "queued_runs", ["parent_queued_run_id"], ["id"]
)
op.add_column(
"workflow_runs", sa.Column("queued_run_id", sa.Integer(), nullable=True)
)
op.create_foreign_key(
None, "workflow_runs", "queued_runs", ["queued_run_id"], ["id"]
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(None, "workflow_runs", type_="foreignkey")
op.drop_column("workflow_runs", "queued_run_id")
op.drop_constraint(None, "queued_runs", type_="foreignkey")
op.drop_constraint("unique_campaign_source_retry", "queued_runs", type_="unique")
op.drop_index("idx_queued_runs_scheduled", table_name="queued_runs")
op.create_unique_constraint(
"unique_campaign_source_uuid", "queued_runs", ["campaign_id", "source_uuid"]
)
op.drop_column("queued_runs", "retry_reason")
op.drop_column("queued_runs", "scheduled_for")
op.drop_column("queued_runs", "parent_queued_run_id")
op.drop_column("queued_runs", "retry_count")
op.drop_column("campaigns", "retry_config")
# ### end Alembic commands ###

122
api/app.py Normal file
View file

@ -0,0 +1,122 @@
"""Set up logging before importing anything else"""
import sentry_sdk
from api.constants import ENABLE_SENTRY, 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()
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)")
import asyncio
from contextlib import asynccontextmanager
from typing import Optional
import redis.asyncio as aioredis
from fastapi import APIRouter, FastAPI
from fastapi.middleware.cors import CORSMiddleware
from loguru import logger
from api.routes.main import router as main_router
from api.routes.rtc_offer import pcs_map
from api.services.telephony.worker_event_subscriber import (
WorkerEventSubscriber,
setup_worker_subscriber,
)
from api.tasks.arq import get_arq_redis
API_PREFIX = "/api/v1"
# Global reference to worker subscriber for graceful shutdown
worker_subscriber_instance: Optional[WorkerEventSubscriber] = None
@asynccontextmanager
async def lifespan(app: FastAPI):
global worker_subscriber_instance
# warmup arq pool
await get_arq_redis()
# Setup Redis connection for distributed mode
redis = await aioredis.from_url(REDIS_URL, decode_responses=True)
# Setup worker subscriber (ARI Manager runs separately)
worker_subscriber = await setup_worker_subscriber(redis)
worker_subscriber_instance = worker_subscriber
# Store worker ID in app state for health check
app.state.worker_id = worker_subscriber.worker_id
app.state.worker_subscriber = worker_subscriber
yield # Run app
# Shutdown sequence - this runs when FastAPI is shutting down
logger.info("Starting graceful shutdown...")
# First, try graceful shutdown with timeout
if worker_subscriber:
try:
# Check if we should do graceful shutdown (e.g., if SIGTERM was received)
# For now, we'll attempt graceful shutdown for all shutdowns
await worker_subscriber.graceful_shutdown(max_wait_seconds=300)
except Exception as e:
logger.error(f"Error during graceful shutdown: {e}")
# Fall back to immediate stop
await worker_subscriber.stop()
# close all dangling pipecat connections
coros = [pc.close() for pc in pcs_map.values()]
await asyncio.gather(*coros)
pcs_map.clear()
await redis.aclose()
if logging_queue_listener is not None:
logging_queue_listener.stop()
app = FastAPI(
title="Dograh API",
description="API for the Dograh app",
version="1.0.0",
openapi_url=f"{API_PREFIX}/openapi.json",
lifespan=lifespan,
)
# Configure CORS
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # Allows all origins
allow_credentials=True,
allow_methods=["*"], # Allows all methods
allow_headers=["*"], # Allows all headers
)
api_router = APIRouter()
# include subrouters here
api_router.include_router(main_router)
# main router with api prefix
app.include_router(api_router, prefix=API_PREFIX)

Binary file not shown.

Binary file not shown.

182
api/conftest.py Normal file
View file

@ -0,0 +1,182 @@
"""
Shared pytest fixtures for the API tests.
This file contains database setup, test client configuration, and utility fixtures
that can be reused across all test files.
"""
import os
import subprocess
import uuid
import pytest_asyncio
from httpx import ASGITransport, AsyncClient
from loguru import logger
from sqlalchemy import text
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from api.app import app
from api.db import db_client
# Test database setup globals
TEST_DATABASE_NAME = None
TEST_DATABASE_URL = None
@pytest_asyncio.fixture
async def test_database():
"""
Set up a temporary PostgreSQL database for testing.
This fixture creates a unique test database, runs migrations, and cleans up afterward.
"""
global TEST_DATABASE_NAME, TEST_DATABASE_URL
# Generate a unique test database name
TEST_DATABASE_NAME = f"test_dograh_{uuid.uuid4().hex[:8]}"
# Get the base DATABASE_URL and parse it
base_url = os.environ.get("DATABASE_URL")
# Extract connection parts and replace database name
url_parts = base_url.split("/")
base_connection = "/".join(url_parts[:-1])
TEST_DATABASE_URL = f"{base_connection}/{TEST_DATABASE_NAME}"
# Create a connection to the default postgres database to create our test database
default_engine = create_async_engine(base_url)
try:
# Create the test database
async with default_engine.connect() as conn:
# Use autocommit mode to create database
await conn.execute(text("COMMIT"))
await conn.execute(text(f"CREATE DATABASE {TEST_DATABASE_NAME}"))
await default_engine.dispose()
# Run migrations on the test database
env = os.environ.copy()
env["DATABASE_URL"] = TEST_DATABASE_URL
# Add the parent directory to PYTHONPATH so alembic can find the api module
parent_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
env["PYTHONPATH"] = parent_dir + ":" + env.get("PYTHONPATH", "")
# Run alembic upgrade to create all tables
result = subprocess.run(
[
"conda",
"run",
"-n",
"dograh",
"python",
"-m",
"alembic",
"-c",
"alembic.ini",
"upgrade",
"head",
],
env=env,
capture_output=True,
text=True,
)
if result.returncode != 0:
logger.error(f"Alembic stderr: {result.stderr}")
logger.error(f"Alembic stdout: {result.stdout}")
raise RuntimeError(f"Alembic migration failed: {result.stderr}")
logger.info(f"Created test database: {TEST_DATABASE_NAME}")
yield TEST_DATABASE_URL
finally:
# Cleanup: Drop the test database
cleanup_engine = create_async_engine(base_url)
try:
async with cleanup_engine.connect() as conn:
# Terminate any connections to the test database
await conn.execute(text("COMMIT"))
await conn.execute(
text(f"""
SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE datname = '{TEST_DATABASE_NAME}' AND pid <> pg_backend_pid()
""")
)
await conn.execute(
text(f"DROP DATABASE IF EXISTS {TEST_DATABASE_NAME}")
)
logger.info(f"Cleaned up test database: {TEST_DATABASE_NAME}")
except Exception as e:
logger.error(
f"Warning: Could not clean up test database {TEST_DATABASE_NAME}: {e}"
)
finally:
await cleanup_engine.dispose()
@pytest_asyncio.fixture
async def db_session(test_database):
"""
Create a test database client that uses the temporary database.
This fixture replaces the global db_client with a test version.
"""
original_engine = db_client.engine
original_session = db_client.async_session
# Replace the database client's engine and session with test ones
test_engine = create_async_engine(test_database)
test_session_maker = async_sessionmaker(bind=test_engine)
db_client.engine = test_engine
db_client.async_session = test_session_maker
yield db_client
# Restore original database client
await test_engine.dispose()
db_client.engine = original_engine
db_client.async_session = original_session
@pytest_asyncio.fixture
async def test_client_factory(db_session):
"""
Factory fixture that creates test clients for specific users.
This allows tests to create custom users and test clients on demand.
Usage:
async def test_something(test_client_factory, db_session):
# Create a custom user
user = await db_session.get_or_create_user_by_provider_id("custom_user_123")
# Create a test client for this user
async with test_client_factory(user) as client:
# Use the client in your test
response = await client.get("/some/endpoint")
"""
from contextlib import asynccontextmanager
from api.services.auth.depends import get_user
@asynccontextmanager
async def _create_client_for_user(user):
# Create mock auth dependency for this user
async def mock_get_user():
return user
# Override the dependency
original_override = app.dependency_overrides.get(get_user)
app.dependency_overrides[get_user] = mock_get_user
try:
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as client:
yield client
finally:
# Clean up the override
if original_override:
app.dependency_overrides[get_user] = original_override
else:
app.dependency_overrides.pop(get_user, None)
return _create_client_for_user

47
api/constants.py Normal file
View file

@ -0,0 +1,47 @@
import os
from pathlib import Path
# Absolute path to the project root directory (i.e. the directory containing
# the top-level api/ package). Having a single canonical location helps
# when constructing file-system paths elsewhere in the codebase.
APP_ROOT_DIR: Path = Path(__file__).resolve().parent
FILLER_SOUND_PROBABILITY = 0.0
VOICEMAIL_RECORDING_DURATION = 5.0
# Configuration constants
ENABLE_SMART_TURN = os.getenv("ENABLE_SMART_TURN", "false").lower() == "true"
ENABLE_TRACING = os.getenv("ENABLE_TRACING", "false").lower() == "true"
ENABLE_RNNOISE = os.getenv("ENABLE_RNNOISE", "false").lower() == "true"
BACKEND_API_ENDPOINT = os.getenv("BACKEND_API_ENDPOINT", None)
TWILIO_ACCOUNT_SID = os.getenv("TWILIO_ACCOUNT_SID", None)
TWILIO_AUTH_TOKEN = os.getenv("TWILIO_AUTH_TOKEN", None)
TWILIO_DEFAULT_FROM_NUMBER = os.getenv("TWILIO_FROM_NUMBER", None)
DATABASE_URL = os.environ["DATABASE_URL"]
REDIS_URL = os.environ["REDIS_URL"]
DEPLOYMENT_MODE = os.getenv("DEPLOYMENT_MODE", "oss")
DOGRAH_MPS_SECRET_KEY = os.getenv("DOGRAH_MPS_SECRET_KEY", None)
MPS_API_URL = os.getenv("MPS_API_URL", "https://services.dograh.com")
# Storage Configuration
ENABLE_AWS_S3 = os.getenv("ENABLE_AWS_S3", "false").lower() == "true"
# MinIO Configuration
MINIO_ENDPOINT = os.getenv("MINIO_ENDPOINT", "localhost:9000")
MINIO_PUBLIC_ENDPOINT = os.getenv("MINIO_PUBLIC_ENDPOINT")
MINIO_ACCESS_KEY = os.getenv("MINIO_ACCESS_KEY", "minioadmin")
MINIO_SECRET_KEY = os.getenv("MINIO_SECRET_KEY", "minioadmin")
MINIO_BUCKET = os.getenv("MINIO_BUCKET", "voice-audio")
MINIO_SECURE = os.getenv("MINIO_SECURE", "false").lower() == "true"
# AWS S3 Configuration
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")

3
api/db/__init__.py Normal file
View file

@ -0,0 +1,3 @@
from api.db.db_client import DBClient
db_client = DBClient()

108
api/db/api_key_client.py Normal file
View file

@ -0,0 +1,108 @@
from typing import List, Optional
from sqlalchemy import and_
from sqlalchemy.future import select
from api.db.base_client import BaseDBClient
from api.db.models import APIKeyModel
from api.utils.api_key import generate_api_key, hash_api_key
class APIKeyClient(BaseDBClient):
async def create_api_key(
self, organization_id: int, name: str, created_by: Optional[int] = None
) -> tuple[APIKeyModel, str]:
"""Create a new API key for an organization.
Returns:
Tuple of (APIKeyModel, raw_api_key)
"""
# Generate a secure random API key
raw_api_key, key_hash, key_prefix = generate_api_key()
async with self.async_session() as session:
api_key = APIKeyModel(
organization_id=organization_id,
name=name,
key_hash=key_hash,
key_prefix=key_prefix,
created_by=created_by,
is_active=True,
)
session.add(api_key)
await session.commit()
await session.refresh(api_key)
return api_key, raw_api_key
async def get_api_keys_by_organization(
self, organization_id: int, include_archived: bool = False
) -> List[APIKeyModel]:
"""Get all API keys for an organization."""
async with self.async_session() as session:
query = select(APIKeyModel).where(
APIKeyModel.organization_id == organization_id
)
if not include_archived:
query = query.where(APIKeyModel.archived_at.is_(None))
result = await session.execute(query)
return result.scalars().all()
async def get_api_key_by_hash(self, key_hash: str) -> Optional[APIKeyModel]:
"""Get an API key by its hash."""
async with self.async_session() as session:
result = await session.execute(
select(APIKeyModel).where(
and_(
APIKeyModel.key_hash == key_hash,
APIKeyModel.is_active == True,
APIKeyModel.archived_at.is_(None),
)
)
)
return result.scalars().first()
async def validate_api_key(self, raw_api_key: str) -> Optional[APIKeyModel]:
"""Validate an API key and return the associated model if valid."""
key_hash = hash_api_key(raw_api_key)
api_key = await self.get_api_key_by_hash(key_hash)
if api_key:
# Update last_used_at
from datetime import datetime, timezone
async with self.async_session() as session:
await session.execute(
APIKeyModel.__table__.update()
.where(APIKeyModel.id == api_key.id)
.values(last_used_at=datetime.now(timezone.utc))
)
await session.commit()
return api_key
async def archive_api_key(self, api_key_id: int) -> bool:
"""Archive an API key (soft delete)."""
from datetime import datetime, timezone
async with self.async_session() as session:
result = await session.execute(
APIKeyModel.__table__.update()
.where(APIKeyModel.id == api_key_id)
.values(is_active=False, archived_at=datetime.now(timezone.utc))
)
await session.commit()
return result.rowcount > 0
async def reactivate_api_key(self, api_key_id: int) -> bool:
"""Reactivate an archived API key."""
async with self.async_session() as session:
result = await session.execute(
APIKeyModel.__table__.update()
.where(APIKeyModel.id == api_key_id)
.values(is_active=True, archived_at=None)
)
await session.commit()
return result.rowcount > 0

34
api/db/base_client.py Normal file
View file

@ -0,0 +1,34 @@
from typing import Any, Dict, List
from sqlalchemy import text
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from api.constants import DATABASE_URL
class BaseDBClient:
def __init__(self):
self.engine = create_async_engine(DATABASE_URL)
self.async_session = async_sessionmaker(bind=self.engine)
async def execute_raw_query(
self, query: str, params: Dict[str, Any] = None
) -> List[Dict[str, Any]]:
"""
Execute a raw SQL query and return results as a list of dictionaries.
Args:
query: The SQL query to execute
params: Optional dictionary of query parameters
Returns:
List of dictionaries containing the query results
"""
async with self.async_session() as session:
result = await session.execute(text(query), params or {})
rows = result.fetchall()
if rows:
# Convert rows to dictionaries
columns = result.keys()
return [dict(zip(columns, row)) for row in rows]
return []

379
api/db/campaign_client.py Normal file
View file

@ -0,0 +1,379 @@
from datetime import UTC, datetime
from typing import Optional
from sqlalchemy import func
from sqlalchemy.future import select
from api.db.base_client import BaseDBClient
from api.db.models import CampaignModel, QueuedRunModel, WorkflowRunModel
class CampaignClient(BaseDBClient):
async def create_campaign(
self,
name: str,
workflow_id: int,
source_type: str,
source_id: str,
user_id: int,
organization_id: int,
) -> CampaignModel:
"""Create a new campaign"""
async with self.async_session() as session:
campaign = CampaignModel(
name=name,
workflow_id=workflow_id,
source_type=source_type,
source_id=source_id,
created_by=user_id,
organization_id=organization_id,
)
session.add(campaign)
try:
await session.commit()
except Exception as e:
await session.rollback()
raise e
await session.refresh(campaign)
return campaign
async def get_campaigns(
self,
organization_id: int,
) -> list[CampaignModel]:
"""Get all campaigns for organization"""
async with self.async_session() as session:
query = (
select(CampaignModel)
.where(CampaignModel.organization_id == organization_id)
.order_by(CampaignModel.created_at.desc())
)
result = await session.execute(query)
return list(result.scalars().all())
async def get_campaign(
self,
campaign_id: int,
organization_id: int,
) -> Optional[CampaignModel]:
"""Get single campaign by ID, ensuring organization access"""
async with self.async_session() as session:
query = select(CampaignModel).where(
CampaignModel.id == campaign_id,
CampaignModel.organization_id == organization_id,
)
result = await session.execute(query)
return result.scalar_one_or_none()
async def update_campaign_state(
self,
campaign_id: int,
state: str,
organization_id: int,
) -> CampaignModel:
"""Update campaign state (start/pause/resume)"""
async with self.async_session() as session:
query = select(CampaignModel).where(
CampaignModel.id == campaign_id,
CampaignModel.organization_id == organization_id,
)
result = await session.execute(query)
campaign = result.scalar_one_or_none()
if not campaign:
raise ValueError(f"Campaign {campaign_id} not found")
campaign.state = state
if state == "running" and not campaign.started_at:
campaign.started_at = datetime.now(UTC)
elif state in ["completed", "failed"]:
campaign.completed_at = datetime.now(UTC)
try:
await session.commit()
except Exception as e:
await session.rollback()
raise e
await session.refresh(campaign)
return campaign
async def update_campaign_progress(
self,
campaign_id: int,
processed_rows: int,
failed_rows: int,
organization_id: int,
) -> None:
"""Update campaign progress counters"""
async with self.async_session() as session:
query = select(CampaignModel).where(
CampaignModel.id == campaign_id,
CampaignModel.organization_id == organization_id,
)
result = await session.execute(query)
campaign = result.scalar_one_or_none()
if not campaign:
raise ValueError(f"Campaign {campaign_id} not found")
campaign.processed_rows = processed_rows
campaign.failed_rows = failed_rows
campaign.updated_at = datetime.now(UTC)
try:
await session.commit()
except Exception as e:
await session.rollback()
raise e
async def get_campaign_runs(
self,
campaign_id: int,
organization_id: int,
) -> list[WorkflowRunModel]:
"""Get workflow runs for a campaign"""
async with self.async_session() as session:
# First verify campaign belongs to organization
campaign_query = select(CampaignModel).where(
CampaignModel.id == campaign_id,
CampaignModel.organization_id == organization_id,
)
campaign_result = await session.execute(campaign_query)
campaign = campaign_result.scalar_one_or_none()
if not campaign:
raise ValueError(f"Campaign {campaign_id} not found")
query = (
select(WorkflowRunModel)
.where(WorkflowRunModel.campaign_id == campaign_id)
.order_by(WorkflowRunModel.created_at.desc())
)
result = await session.execute(query)
return list(result.scalars().all())
async def get_campaign_by_id(self, campaign_id: int) -> Optional[CampaignModel]:
"""Get campaign by ID without organization check (for internal use)"""
async with self.async_session() as session:
query = select(CampaignModel).where(CampaignModel.id == campaign_id)
result = await session.execute(query)
return result.scalar_one_or_none()
async def update_campaign(self, campaign_id: int, **kwargs) -> CampaignModel:
"""Update campaign with arbitrary fields"""
async with self.async_session() as session:
query = select(CampaignModel).where(CampaignModel.id == campaign_id)
result = await session.execute(query)
campaign = result.scalar_one_or_none()
if not campaign:
raise ValueError(f"Campaign {campaign_id} not found")
# Update fields
for key, value in kwargs.items():
if hasattr(campaign, key):
setattr(campaign, key, value)
campaign.updated_at = datetime.now(UTC)
try:
await session.commit()
except Exception as e:
await session.rollback()
raise e
await session.refresh(campaign)
return campaign
# QueuedRun methods
async def bulk_create_queued_runs(self, queued_runs_data: list[dict]) -> None:
"""Bulk create queued runs"""
async with self.async_session() as session:
queued_runs = [QueuedRunModel(**data) for data in queued_runs_data]
session.add_all(queued_runs)
try:
await session.commit()
except Exception as e:
await session.rollback()
raise e
async def get_queued_runs(
self,
campaign_id: int,
state: str = "queued",
limit: int = 10,
scheduled_for: Optional[bool] = None,
) -> list[QueuedRunModel]:
"""Get queued runs for processing, optionally filtering by scheduled status"""
async with self.async_session() as session:
query = select(QueuedRunModel).where(
QueuedRunModel.campaign_id == campaign_id,
QueuedRunModel.state == state,
)
# Filter by scheduled status if specified
if scheduled_for is True:
query = query.where(QueuedRunModel.scheduled_for.isnot(None))
elif scheduled_for is False:
query = query.where(QueuedRunModel.scheduled_for.is_(None))
query = query.order_by(QueuedRunModel.created_at).limit(limit)
result = await session.execute(query)
return list(result.scalars().all())
async def update_queued_run(self, queued_run_id: int, **kwargs) -> QueuedRunModel:
"""Update queued run"""
async with self.async_session() as session:
query = select(QueuedRunModel).where(QueuedRunModel.id == queued_run_id)
result = await session.execute(query)
queued_run = result.scalar_one_or_none()
if not queued_run:
raise ValueError(f"QueuedRun {queued_run_id} not found")
# Update fields
for key, value in kwargs.items():
if hasattr(queued_run, key):
setattr(queued_run, key, value)
try:
await session.commit()
except Exception as e:
await session.rollback()
raise e
await session.refresh(queued_run)
return queued_run
async def count_queued_runs(
self, campaign_id: int, state: Optional[str] = None
) -> int:
"""Count queued runs, optionally filtered by state"""
async with self.async_session() as session:
query = select(func.count(QueuedRunModel.id)).where(
QueuedRunModel.campaign_id == campaign_id
)
if state:
query = query.where(QueuedRunModel.state == state)
result = await session.execute(query)
return result.scalar() or 0
async def get_workflow_runs_by_campaign(
self, campaign_id: int
) -> list[WorkflowRunModel]:
"""Get all workflow runs for a campaign (internal use)"""
async with self.async_session() as session:
query = (
select(WorkflowRunModel)
.where(WorkflowRunModel.campaign_id == campaign_id)
.order_by(WorkflowRunModel.created_at)
)
result = await session.execute(query)
return list(result.scalars().all())
# New methods for retry support
async def get_scheduled_queued_runs(
self, campaign_id: int, scheduled_before: datetime, limit: int = 10
) -> list[QueuedRunModel]:
"""Get scheduled queued runs that are due for processing"""
async with self.async_session() as session:
query = (
select(QueuedRunModel)
.where(
QueuedRunModel.campaign_id == campaign_id,
QueuedRunModel.state == "queued",
QueuedRunModel.scheduled_for.isnot(None),
QueuedRunModel.scheduled_for <= scheduled_before,
)
.order_by(QueuedRunModel.scheduled_for)
.limit(limit)
)
result = await session.execute(query)
return list(result.scalars().all())
async def create_queued_run(
self,
campaign_id: int,
source_uuid: str,
context_variables: dict,
state: str = "queued",
retry_count: int = 0,
parent_queued_run_id: Optional[int] = None,
scheduled_for: Optional[datetime] = None,
retry_reason: Optional[str] = None,
) -> QueuedRunModel:
"""Create a single queued run with retry support"""
async with self.async_session() as session:
queued_run = QueuedRunModel(
campaign_id=campaign_id,
source_uuid=source_uuid,
context_variables=context_variables,
state=state,
retry_count=retry_count,
parent_queued_run_id=parent_queued_run_id,
scheduled_for=scheduled_for,
retry_reason=retry_reason,
)
session.add(queued_run)
try:
await session.commit()
except Exception as e:
await session.rollback()
raise e
await session.refresh(queued_run)
return queued_run
async def get_queued_run_by_id(
self, queued_run_id: int
) -> Optional[QueuedRunModel]:
"""Get a queued run by ID"""
async with self.async_session() as session:
query = select(QueuedRunModel).where(QueuedRunModel.id == queued_run_id)
result = await session.execute(query)
return result.scalar_one_or_none()
async def get_campaigns_by_status(self, statuses: list[str]) -> list[CampaignModel]:
"""Get campaigns by status"""
async with self.async_session() as session:
query = (
select(CampaignModel)
.where(CampaignModel.state.in_(statuses))
.order_by(CampaignModel.created_at.desc())
)
result = await session.execute(query)
return list(result.scalars().all())
async def get_queued_runs_count(self, campaign_id: int, states: list[str]) -> int:
"""Get count of queued runs for a campaign in specified states"""
async with self.async_session() as session:
query = select(func.count(QueuedRunModel.id)).where(
QueuedRunModel.campaign_id == campaign_id,
QueuedRunModel.state.in_(states),
)
result = await session.execute(query)
return result.scalar() or 0
async def get_scheduled_runs_count(
self,
campaign_id: int,
scheduled_before: Optional[datetime] = None,
scheduled_after: Optional[datetime] = None,
) -> int:
"""Get count of scheduled runs for a campaign"""
async with self.async_session() as session:
conditions = [
QueuedRunModel.campaign_id == campaign_id,
QueuedRunModel.scheduled_for.isnot(None),
QueuedRunModel.state == "queued",
]
if scheduled_before:
conditions.append(QueuedRunModel.scheduled_for <= scheduled_before)
if scheduled_after:
conditions.append(QueuedRunModel.scheduled_for > scheduled_after)
query = select(func.count(QueuedRunModel.id)).where(*conditions)
result = await session.execute(query)
return result.scalar() or 0

9
api/db/database.py Normal file
View file

@ -0,0 +1,9 @@
import os
from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy.orm import sessionmaker
DATABASE_URL = os.environ["DATABASE_URL"]
engine = create_async_engine(DATABASE_URL, echo=True)
async_session = sessionmaker(engine)

47
api/db/db_client.py Normal file
View file

@ -0,0 +1,47 @@
from api.db.api_key_client import APIKeyClient
from api.db.campaign_client import CampaignClient
from api.db.integration_client import IntegrationClient
from api.db.looptalk_client import LoopTalkClient
from api.db.organization_client import OrganizationClient
from api.db.organization_configuration_client import OrganizationConfigurationClient
from api.db.organization_usage_client import OrganizationUsageClient
from api.db.reports_client import ReportsClient
from api.db.user_client import UserClient
from api.db.workflow_client import WorkflowClient
from api.db.workflow_run_client import WorkflowRunClient
from api.db.workflow_template_client import WorkflowTemplateClient
class DBClient(
WorkflowClient,
WorkflowRunClient,
UserClient,
OrganizationClient,
OrganizationConfigurationClient,
OrganizationUsageClient,
IntegrationClient,
WorkflowTemplateClient,
LoopTalkClient,
CampaignClient,
ReportsClient,
APIKeyClient,
):
"""
Unified database client that combines all specialized database operations.
This client inherits from:
- WorkflowClient: handles workflow and workflow definition operations
- WorkflowRunClient: handles workflow run operations
- UserClient: handles user and user configuration operations
- OrganizationClient: handles organization operations
- OrganizationConfigurationClient: handles organization configuration operations
- OrganizationUsageClient: handles organization usage and quota operations
- IntegrationClient: handles integration operations
- WorkflowTemplateClient: handles workflow template operations
- LoopTalkClient: handles LoopTalk testing operations
- CampaignClient: handles campaign operations
- ReportsClient: handles reports and analytics operations
- APIKeyClient: handles API key operations
"""
pass

198
api/db/filters.py Normal file
View file

@ -0,0 +1,198 @@
"""Common filter utilities for database queries."""
from datetime import datetime
from typing import Any, Dict, List, Optional
from sqlalchemy import Integer, and_, cast
from sqlalchemy.dialects.postgresql import JSONB
from api.db.models import WorkflowRunModel
# Mapping of attribute names to database fields
ATTRIBUTE_FIELD_MAPPING = {
"dateRange": "created_at",
"dispositionCode": "gathered_context.mapped_call_disposition",
"duration": "usage_info.call_duration_seconds",
"status": "is_completed",
"tokenUsage": "cost_info.total_cost_usd",
"runId": "id",
"workflowId": "workflow_id",
"callTags": "gathered_context.call_tags",
"phoneNumber": "initial_context.phone",
}
def apply_workflow_run_filters(
base_query,
filters: Optional[List[Dict[str, Any]]] = None,
):
"""
Apply filters to a workflow run query.
Supports filtering by:
- dateRange: Filter by created_at date range
- dispositionCode: Filter by gathered_context.mapped_call_disposition
- duration: Filter by usage_info.call_duration_seconds range
- status: Filter by is_completed status
- tokenUsage: Filter by cost_info.total_cost_usd range
- runId: Filter by workflow run ID (exact match)
- workflowId: Filter by workflow ID (exact match)
- callTags: Filter by gathered_context.call_tags (array of strings)
- phoneNumber: Filter by initial_context.phone (text search)
Args:
base_query: The base SQLAlchemy query to apply filters to
filters: List of filter dictionaries with structure:
{"attribute": "filterName", "type": "filterType", "value": {...}}
Where type is one of:
- "dateRange": Date range filter with {"from": ..., "to": ...}
- "multiSelect": Multi-select filter with {"codes": [...]}
- "numberRange": Number range filter with {"min": ..., "max": ...}
- "number": Exact number filter with {"value": number}
- "text": Text search filter with {"value": string}
- "radio": Radio/status filter with {"status": ...}
- "tags": Tags filter with {"codes": [...]}
Returns:
The query with filters applied
"""
if not filters:
return base_query
filter_conditions = []
for filter_item in filters:
attribute = filter_item.get("attribute")
filter_type = filter_item.get("type")
value = filter_item.get("value", {})
# Resolve field from attribute mapping
field = ATTRIBUTE_FIELD_MAPPING.get(attribute)
if not field:
# Skip unknown attributes
continue
# Apply the filter based on provided type
if field and filter_type:
if filter_type == "number" and field == "id":
# Filter by exact workflow run ID
if value.get("value") is not None:
filter_conditions.append(WorkflowRunModel.id == value["value"])
elif filter_type == "number" and field == "workflow_id":
# Filter by exact workflow ID
if value.get("value") is not None:
filter_conditions.append(
WorkflowRunModel.workflow_id == value["value"]
)
elif filter_type == "dateRange" and field == "created_at":
# Same as attribute-based dateRange
if value.get("from"):
filter_conditions.append(
WorkflowRunModel.created_at
>= datetime.fromisoformat(value["from"])
)
if value.get("to"):
filter_conditions.append(
WorkflowRunModel.created_at
<= datetime.fromisoformat(value["to"])
)
elif (
filter_type == "multiSelect"
and field == "gathered_context.mapped_call_disposition"
):
codes = value.get("codes", [])
if codes:
filter_conditions.append(
cast(WorkflowRunModel.gathered_context, JSONB)[
"mapped_call_disposition"
]
.as_string()
.in_(codes)
)
elif filter_type == "radio" and field == "is_completed":
status = value.get("status")
if status == "completed":
filter_conditions.append(WorkflowRunModel.is_completed == True)
elif status == "in_progress":
filter_conditions.append(WorkflowRunModel.is_completed == False)
elif (
filter_type in ("tags", "multiSelect")
and field == "gathered_context.call_tags"
):
tags = value.get("codes", [])
if tags:
filter_conditions.append(
cast(WorkflowRunModel.gathered_context, JSONB)[
"call_tags"
].contains(tags)
)
elif filter_type == "text" and field == "initial_context.phone":
# Filter by phone number (contains search)
phone = value.get("value", "").strip()
if phone:
filter_conditions.append(
cast(WorkflowRunModel.initial_context, JSONB)["phone"]
.as_string()
.contains(phone)
)
elif filter_type == "numberRange":
min_val = value.get("min")
max_val = value.get("max")
if field == "usage_info.call_duration_seconds":
if min_val is not None:
filter_conditions.append(
cast(
cast(WorkflowRunModel.usage_info, JSONB)[
"call_duration_seconds"
],
Integer,
)
>= min_val
)
if max_val is not None:
filter_conditions.append(
cast(
cast(WorkflowRunModel.usage_info, JSONB)[
"call_duration_seconds"
],
Integer,
)
<= max_val
)
elif field == "cost_info.total_cost_usd":
if min_val is not None:
filter_conditions.append(
cast(
cast(WorkflowRunModel.cost_info, JSONB)[
"total_cost_usd"
],
Integer,
)
>= min_val
)
if max_val is not None:
filter_conditions.append(
cast(
cast(WorkflowRunModel.cost_info, JSONB)[
"total_cost_usd"
],
Integer,
)
<= max_val
)
if filter_conditions:
base_query = base_query.where(and_(*filter_conditions))
return base_query

View file

@ -0,0 +1,103 @@
from typing import List
from sqlalchemy.future import select
from api.db.base_client import BaseDBClient
from api.db.models import IntegrationModel
class IntegrationClient(BaseDBClient):
async def get_integrations_by_organization_id(
self, organization_id: int
) -> list[IntegrationModel]:
"""Get all integrations for a specific organization."""
async with self.async_session() as session:
result = await session.execute(
select(IntegrationModel).where(
IntegrationModel.organisation_id == organization_id
)
)
return result.scalars().all()
async def create_integration(
self,
integration_id: str,
provider: str,
organisation_id: int,
connection_details: dict,
created_by: int = None,
is_active: bool = True,
) -> IntegrationModel:
"""Create a new integration for an organization."""
async with self.async_session() as session:
new_integration = IntegrationModel(
integration_id=integration_id,
organisation_id=organisation_id,
created_by=created_by,
is_active=is_active,
provider=provider,
connection_details=connection_details,
)
session.add(new_integration)
try:
await session.commit()
except Exception as e:
await session.rollback()
raise e
await session.refresh(new_integration)
return new_integration
async def update_integration_status(
self, integration_id: int, is_active: bool
) -> IntegrationModel | None:
"""Update the active status of an integration."""
async with self.async_session() as session:
result = await session.execute(
select(IntegrationModel).where(IntegrationModel.id == integration_id)
)
integration = result.scalars().first()
if not integration:
return None
integration.is_active = is_active
try:
await session.commit()
except Exception as e:
await session.rollback()
raise e
await session.refresh(integration)
return integration
async def update_integration_connection_details(
self, integration_id: int, connection_details: dict
) -> IntegrationModel | None:
"""Update the connection details of an integration."""
async with self.async_session() as session:
result = await session.execute(
select(IntegrationModel).where(IntegrationModel.id == integration_id)
)
integration = result.scalars().first()
if not integration:
return None
integration.connection_details = connection_details
try:
await session.commit()
except Exception as e:
await session.rollback()
raise e
await session.refresh(integration)
return integration
async def get_active_integrations_by_organization(
self, organization_id: int
) -> List[IntegrationModel]:
"""Get all active integrations for a specific organization."""
async with self.async_session() as session:
result = await session.execute(
select(IntegrationModel).where(
IntegrationModel.organisation_id == organization_id,
IntegrationModel.is_active == True,
)
)
return result.scalars().all()

259
api/db/looptalk_client.py Normal file
View file

@ -0,0 +1,259 @@
from datetime import UTC, datetime
from typing import Any, Dict, List, Optional
from uuid import uuid4
from sqlalchemy.future import select
from sqlalchemy.orm import selectinload
from api.db.base_client import BaseDBClient
from api.db.models import (
LoopTalkConversation,
LoopTalkTestSession,
)
class LoopTalkClient(BaseDBClient):
"""Database client for LoopTalk testing operations."""
async def create_test_session(
self,
organization_id: int,
name: str,
actor_workflow_id: int,
adversary_workflow_id: int,
config: Dict[str, Any],
load_test_group_id: Optional[str] = None,
test_index: Optional[int] = None,
) -> LoopTalkTestSession:
"""Create a new LoopTalk test session."""
async with self.async_session() as session:
test_session = LoopTalkTestSession(
organization_id=organization_id,
name=name,
actor_workflow_id=actor_workflow_id,
adversary_workflow_id=adversary_workflow_id,
config=config,
load_test_group_id=load_test_group_id,
test_index=test_index,
status="pending",
)
session.add(test_session)
await session.commit()
await session.refresh(test_session)
return test_session
async def get_test_session(
self, test_session_id: int, organization_id: int
) -> Optional[LoopTalkTestSession]:
"""Get a test session by ID."""
async with self.async_session() as session:
result = await session.execute(
select(LoopTalkTestSession)
.options(
selectinload(LoopTalkTestSession.actor_workflow),
selectinload(LoopTalkTestSession.adversary_workflow),
selectinload(LoopTalkTestSession.conversations),
)
.where(
LoopTalkTestSession.id == test_session_id,
LoopTalkTestSession.organization_id == organization_id,
)
)
return result.scalar_one_or_none()
async def list_test_sessions(
self,
organization_id: int,
status: Optional[str] = None,
load_test_group_id: Optional[str] = None,
limit: int = 20,
offset: int = 0,
) -> List[LoopTalkTestSession]:
"""List test sessions with optional filtering."""
async with self.async_session() as session:
query = select(LoopTalkTestSession).where(
LoopTalkTestSession.organization_id == organization_id
)
if status:
# "active" is a virtual status used by the UI to represent
# both "pending" and "running" sessions. Translate it into
# the real enum values stored in the database to avoid
# invalid enum casting errors (e.g. asyncpg InvalidTextRepresentationError).
if status == "active":
query = query.where(
LoopTalkTestSession.status.in_(["pending", "running"])
)
else:
query = query.where(LoopTalkTestSession.status == status)
if load_test_group_id:
query = query.where(
LoopTalkTestSession.load_test_group_id == load_test_group_id
)
query = (
query.order_by(LoopTalkTestSession.created_at.desc())
.limit(limit)
.offset(offset)
)
result = await session.execute(query)
return result.scalars().all()
async def update_test_session_status(
self,
test_session_id: int,
status: str,
error: Optional[str] = None,
results: Optional[Dict[str, Any]] = None,
) -> LoopTalkTestSession:
"""Update test session status and related fields."""
async with self.async_session() as session:
result = await session.execute(
select(LoopTalkTestSession).where(
LoopTalkTestSession.id == test_session_id
)
)
test_session = result.scalar_one()
test_session.status = status
if status == "running":
test_session.started_at = datetime.now(UTC)
elif status in ["completed", "failed"]:
test_session.completed_at = datetime.now(UTC)
if error:
test_session.error = error
if results:
test_session.results = results
await session.commit()
await session.refresh(test_session)
return test_session
async def create_conversation(self, test_session_id: int) -> LoopTalkConversation:
"""Create a new conversation for a test session."""
async with self.async_session() as session:
conversation = LoopTalkConversation(test_session_id=test_session_id)
session.add(conversation)
await session.commit()
await session.refresh(conversation)
return conversation
async def update_conversation(
self,
conversation_id: int,
duration_seconds: Optional[int] = None,
actor_recording_url: Optional[str] = None,
adversary_recording_url: Optional[str] = None,
combined_recording_url: Optional[str] = None,
transcript: Optional[Dict[str, Any]] = None,
metrics: Optional[Dict[str, Any]] = None,
ended_at: Optional[datetime] = None,
) -> LoopTalkConversation:
"""Update conversation details."""
async with self.async_session() as session:
result = await session.execute(
select(LoopTalkConversation).where(
LoopTalkConversation.id == conversation_id
)
)
conversation = result.scalar_one()
if duration_seconds is not None:
conversation.duration_seconds = duration_seconds
if actor_recording_url:
conversation.actor_recording_url = actor_recording_url
if adversary_recording_url:
conversation.adversary_recording_url = adversary_recording_url
if combined_recording_url:
conversation.combined_recording_url = combined_recording_url
if transcript:
conversation.transcript = transcript
if metrics:
conversation.metrics = metrics
if ended_at:
conversation.ended_at = ended_at
await session.commit()
await session.refresh(conversation)
return conversation
# Note: Turn tracking is handled by Langfuse, not stored in our database
async def create_load_test_group(
self,
organization_id: int,
name_prefix: str,
actor_workflow_id: int,
adversary_workflow_id: int,
config: Dict[str, Any],
test_count: int,
) -> List[LoopTalkTestSession]:
"""Create multiple test sessions for load testing."""
load_test_group_id = str(uuid4())
test_sessions = []
async with self.async_session() as session:
for i in range(test_count):
test_session = LoopTalkTestSession(
organization_id=organization_id,
name=f"{name_prefix} - Test {i + 1}",
actor_workflow_id=actor_workflow_id,
adversary_workflow_id=adversary_workflow_id,
config=config,
load_test_group_id=load_test_group_id,
test_index=i,
status="pending",
)
session.add(test_session)
test_sessions.append(test_session)
await session.commit()
# Refresh all sessions
for test_session in test_sessions:
await session.refresh(test_session)
return test_sessions
async def get_load_test_group_stats(
self, load_test_group_id: str, organization_id: int
) -> Dict[str, Any]:
"""Get statistics for a load test group."""
async with self.async_session() as session:
# Get all sessions in the group
result = await session.execute(
select(LoopTalkTestSession).where(
LoopTalkTestSession.load_test_group_id == load_test_group_id,
LoopTalkTestSession.organization_id == organization_id,
)
)
sessions = result.scalars().all()
# Calculate stats
stats = {
"total": len(sessions),
"pending": sum(1 for s in sessions if s.status == "pending"),
"running": sum(1 for s in sessions if s.status == "running"),
"completed": sum(1 for s in sessions if s.status == "completed"),
"failed": sum(1 for s in sessions if s.status == "failed"),
"sessions": [
{
"id": s.id,
"name": s.name,
"status": s.status,
"test_index": s.test_index,
"created_at": s.created_at,
"started_at": s.started_at,
"completed_at": s.completed_at,
"error": s.error,
}
for s in sessions
],
}
return stats

608
api/db/models.py Normal file
View file

@ -0,0 +1,608 @@
from datetime import UTC, datetime
from loguru import logger
from sqlalchemy import (
JSON,
Boolean,
Column,
DateTime,
Enum,
Float,
ForeignKey,
Index,
Integer,
String,
Table,
UniqueConstraint,
and_,
text,
)
from sqlalchemy.orm import declarative_base, relationship
from ..enums import IntegrationAction, WorkflowRunMode, WorkflowStatus
Base = declarative_base()
# TODO: remove workflow_defintion after migration, remove nullable workflow_defintion_id from Workflow and Workflowrun
# Association table for many-to-many relationship between users and organizations
organization_users_association = Table(
"organization_users",
Base.metadata,
Column("user_id", Integer, ForeignKey("users.id"), primary_key=True),
Column(
"organization_id", Integer, ForeignKey("organizations.id"), primary_key=True
),
)
class UserModel(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
provider_id = Column(String, unique=True, index=True, nullable=False)
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
workflows = relationship("WorkflowModel", back_populates="user")
selected_organization_id = Column(
Integer, ForeignKey("organizations.id"), nullable=True
)
selected_organization = relationship("OrganizationModel", back_populates="users")
organizations = relationship(
"OrganizationModel",
secondary=organization_users_association,
back_populates="users",
)
is_superuser = Column(Boolean, default=False)
class UserConfigurationModel(Base):
__tablename__ = "user_configurations"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
configuration = Column(JSON, nullable=False, default=dict)
last_validated_at = Column(DateTime(timezone=True), nullable=True)
# New Organization model
class OrganizationModel(Base):
__tablename__ = "organizations"
id = Column(Integer, primary_key=True, index=True)
provider_id = Column(String, unique=True, index=True, nullable=False)
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
# Quota fields
quota_type = Column(
Enum("monthly", "annual", name="quota_type"),
nullable=False,
default="monthly",
server_default=text("'monthly'::quota_type"),
)
quota_dograh_tokens = Column(
Integer, nullable=False, default=0, server_default=text("0")
)
quota_reset_day = Column(
Integer, nullable=False, default=1, server_default=text("1")
) # 1-28, only for monthly
quota_start_date = Column(DateTime(timezone=True), nullable=True) # Only for annual
quota_enabled = Column(
Boolean, nullable=False, default=False, server_default=text("false")
)
price_per_second_usd = Column(Float, nullable=True)
# Relationships
users = relationship(
"UserModel",
secondary=organization_users_association,
back_populates="organizations",
)
integrations = relationship("IntegrationModel", back_populates="organization")
usage_cycles = relationship(
"OrganizationUsageCycleModel", back_populates="organization"
)
configurations = relationship(
"OrganizationConfigurationModel", back_populates="organization"
)
api_keys = relationship("APIKeyModel", back_populates="organization")
class APIKeyModel(Base):
__tablename__ = "api_keys"
id = Column(Integer, primary_key=True, index=True)
organization_id = Column(
Integer, ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False
)
name = Column(String, nullable=False)
key_hash = Column(String, nullable=False, unique=True, index=True)
key_prefix = Column(String, nullable=False) # Store first 8 chars for display
is_active = Column(Boolean, default=True, nullable=False)
created_by = Column(Integer, ForeignKey("users.id"), nullable=True)
last_used_at = Column(DateTime(timezone=True), nullable=True)
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
archived_at = Column(DateTime(timezone=True), nullable=True)
# Relationships
organization = relationship("OrganizationModel", back_populates="api_keys")
created_by_user = relationship("UserModel")
# Indexes for performance
__table_args__ = (
Index("ix_api_keys_organization_id", "organization_id"),
Index("ix_api_keys_key_hash", "key_hash"),
Index("ix_api_keys_active", "is_active"),
)
class OrganizationConfigurationModel(Base):
__tablename__ = "organization_configurations"
id = Column(Integer, primary_key=True, index=True)
organization_id = Column(
Integer, ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False
)
key = Column(String, nullable=False)
value = Column(JSON, nullable=False, default=dict)
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
updated_at = Column(
DateTime(timezone=True),
default=lambda: datetime.now(UTC),
onupdate=lambda: datetime.now(UTC),
)
# Relationships
organization = relationship("OrganizationModel", back_populates="configurations")
# Constraints and indexes
__table_args__ = (
UniqueConstraint("organization_id", "key", name="_organization_key_uc"),
Index("ix_organization_configurations_organization_id", "organization_id"),
)
class IntegrationModel(Base):
__tablename__ = "integrations"
id = Column(Integer, primary_key=True, index=True)
integration_id = Column(String, nullable=False, index=True) # Nango Connection ID
organisation_id = Column(Integer, ForeignKey("organizations.id"), nullable=False)
provider = Column(String, nullable=False)
created_by = Column(Integer, ForeignKey("users.id"))
is_active = Column(Boolean, default=True, nullable=False)
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
connection_details = Column(JSON, nullable=False, default=dict)
action = Column(String, nullable=False, default=IntegrationAction.ALL_CALLS.value)
# Relationships
organization = relationship("OrganizationModel", back_populates="integrations")
class WorkflowDefinitionModel(Base):
__tablename__ = "workflow_definitions"
id = Column(Integer, primary_key=True, index=True)
workflow_hash = Column(String, nullable=False)
workflow_json = Column(JSON, nullable=False, default=dict)
workflow_id = Column(Integer, ForeignKey("workflows.id"), nullable=True)
is_current = Column(
Boolean, default=False, nullable=False, server_default=text("false")
)
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
# Table constraints and indexes
__table_args__ = (
UniqueConstraint(
"workflow_hash", "workflow_id", name="uq_workflow_hash_workflow_id"
),
Index("ix_workflow_hash_workflow_id", "workflow_hash", "workflow_id"),
)
# Relationships
workflow = relationship(
"WorkflowModel",
back_populates="definitions",
foreign_keys=[workflow_id],
)
workflow_runs = relationship("WorkflowRunModel", back_populates="definition")
class WorkflowModel(Base):
__tablename__ = "workflows"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
user = relationship("UserModel", back_populates="workflows")
organization_id = Column(Integer, ForeignKey("organizations.id"), nullable=True)
organization = relationship("OrganizationModel")
name = Column(String, index=True, nullable=False)
status = Column(
Enum(*[status.value for status in WorkflowStatus], name="workflow_status"),
nullable=False,
default=WorkflowStatus.ACTIVE.value,
server_default=text("'active'::workflow_status"),
)
workflow_definition = Column(JSON, nullable=False, default=dict)
template_context_variables = Column(JSON, nullable=False, default=dict)
call_disposition_codes = Column(JSON, nullable=False, default=dict)
workflow_configurations = Column(
JSON, nullable=False, default=dict, server_default=text("'{}'::json")
)
runs = relationship("WorkflowRunModel", back_populates="workflow")
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
# All versions / historical definitions of this workflow
definitions = relationship(
"WorkflowDefinitionModel",
back_populates="workflow",
foreign_keys="WorkflowDefinitionModel.workflow_id",
)
# Relationship to fetch the current (is_current=True) definition
current_definition = relationship(
"WorkflowDefinitionModel",
primaryjoin=lambda: and_(
WorkflowDefinitionModel.workflow_id == WorkflowModel.id,
WorkflowDefinitionModel.is_current.is_(True),
),
uselist=False,
viewonly=True,
)
@property
def current_definition_id(self):
"""Return ID of the current workflow definition (helper for backwards-compat)."""
current_def = self.__dict__.get("current_definition")
if current_def is not None:
return current_def.id
# If relationship is not loaded, we cannot safely access definitions without
# risking an implicit lazy load on a detached instance. Return ``None`` in
# that scenario so callers can handle the absence explicitly.
return None
@property
def workflow_definition_with_fallback(self):
"""
Get workflow definition with fallback to legacy workflow_definition field.
Returns:
dict: The workflow definition JSON
"""
# Access the relationship only if it has ALREADY been eagerly loaded on this
# instance to avoid triggering an implicit lazy load once the SQLAlchemy
# Session has been closed (which would raise a DetachedInstanceError).
# ``__dict__`` will contain "current_definition" **only** when the attribute
# has been populated (e.g. via `selectinload` or an explicit access while
# the session was still open). Using ``__dict__.get`` guarantees that we
# do not accidentally issue a lazy load query on a detached instance.
current_definition = self.__dict__.get("current_definition")
if current_definition is not None:
return current_definition.workflow_json
# Fallback for backwards-compatibility when the relationship is not (yet)
# loaded. In this case we fall back to the legacy ``workflow_definition``
# column that always contains the most recent definition JSON.
logger.warning(
f"Workflow {self.id} has no loaded current definition, using workflow_definition as fallback",
)
return self.workflow_definition
class WorkflowTemplates(Base):
__tablename__ = "workflow_templates"
id = Column(Integer, primary_key=True, index=True)
template_name = Column(String, nullable=False, index=True)
template_description = Column(String, nullable=False, index=True)
template_json = Column(JSON, nullable=False, default=dict)
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
class WorkflowRunModel(Base):
__tablename__ = "workflow_runs"
id = Column(Integer, primary_key=True, index=True)
name = Column(String, nullable=False)
workflow_id = Column(Integer, ForeignKey("workflows.id"), nullable=False)
workflow = relationship("WorkflowModel", back_populates="runs")
definition_id = Column(
Integer, ForeignKey("workflow_definitions.id"), nullable=True
)
definition = relationship("WorkflowDefinitionModel", back_populates="workflow_runs")
mode = Column(
Enum(*[mode.value for mode in WorkflowRunMode], name="workflow_run_mode"),
nullable=False,
)
is_completed = Column(Boolean, default=False)
recording_url = Column(String, nullable=True)
transcript_url = Column(String, nullable=True)
# Store storage backend as string enum (s3, minio)
storage_backend = Column(
Enum("s3", "minio", name="storage_backend"),
nullable=False,
default="s3",
server_default=text("'s3'::storage_backend"),
)
usage_info = Column(JSON, nullable=False, default=dict)
cost_info = Column(JSON, nullable=False, default=dict)
initial_context = Column(JSON, nullable=False, default=dict)
gathered_context = Column(JSON, nullable=False, default=dict)
logs = Column(JSON, nullable=False, default=dict, server_default=text("'{}'::json"))
annotations = Column(JSON, nullable=False, default=dict)
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
campaign_id = Column(Integer, ForeignKey("campaigns.id"), nullable=True)
campaign = relationship("CampaignModel")
queued_run_id = Column(Integer, ForeignKey("queued_runs.id"), nullable=True)
queued_run = relationship("QueuedRunModel", foreign_keys=[queued_run_id])
# LoopTalk Testing Models
class LoopTalkTestSession(Base):
__tablename__ = "looptalk_test_sessions"
id = Column(Integer, primary_key=True, index=True)
organization_id = Column(Integer, ForeignKey("organizations.id"), nullable=False)
name = Column(String, nullable=False)
status = Column(
Enum("pending", "running", "completed", "failed", name="test_session_status"),
nullable=False,
default="pending",
)
# Workflow configuration
actor_workflow_id = Column(Integer, ForeignKey("workflows.id"), nullable=False)
adversary_workflow_id = Column(Integer, ForeignKey("workflows.id"), nullable=False)
# Load testing configuration
load_test_group_id = Column(String, nullable=True, index=True)
test_index = Column(Integer, nullable=True)
# Test metadata
config = Column(JSON, nullable=False, default=dict)
results = Column(JSON, nullable=False, default=dict)
error = Column(String, nullable=True)
# Timestamps
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
started_at = Column(DateTime(timezone=True), nullable=True)
completed_at = Column(DateTime(timezone=True), nullable=True)
# Relationships
organization = relationship("OrganizationModel")
actor_workflow = relationship("WorkflowModel", foreign_keys=[actor_workflow_id])
adversary_workflow = relationship(
"WorkflowModel", foreign_keys=[adversary_workflow_id]
)
conversations = relationship("LoopTalkConversation", back_populates="test_session")
# Indexes for performance
__table_args__ = (
Index("ix_looptalk_test_sessions_org_id", "organization_id"),
Index("ix_looptalk_test_sessions_group_id", "load_test_group_id"),
Index("ix_looptalk_test_sessions_status", "status"),
)
class LoopTalkConversation(Base):
__tablename__ = "looptalk_conversations"
id = Column(Integer, primary_key=True, index=True)
test_session_id = Column(
Integer, ForeignKey("looptalk_test_sessions.id"), nullable=False
)
# Conversation metadata
duration_seconds = Column(Integer, nullable=True)
# Note: Turn tracking is handled by Langfuse, not stored here
# Audio recording URLs
actor_recording_url = Column(String, nullable=True)
adversary_recording_url = Column(String, nullable=True)
combined_recording_url = Column(String, nullable=True)
# Transcripts (if needed for quick access)
transcript = Column(JSON, nullable=False, default=dict)
# Metrics
metrics = Column(JSON, nullable=False, default=dict)
# Timestamps
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
ended_at = Column(DateTime(timezone=True), nullable=True)
# Relationships
test_session = relationship("LoopTalkTestSession", back_populates="conversations")
# Indexes
__table_args__ = (Index("ix_looptalk_conversations_session_id", "test_session_id"),)
class OrganizationUsageCycleModel(Base):
"""
This model is used to track the usage of Dograh tokens for an organization for a given usage
cycle.
"""
__tablename__ = "organization_usage_cycles"
id = Column(Integer, primary_key=True, index=True)
organization_id = Column(Integer, ForeignKey("organizations.id"), nullable=False)
period_start = Column(DateTime(timezone=True), nullable=False)
period_end = Column(DateTime(timezone=True), nullable=False)
quota_dograh_tokens = Column(Integer, nullable=False)
used_dograh_tokens = Column(Float, nullable=False, default=0)
total_duration_seconds = Column(
Integer, nullable=False, default=0, server_default=text("0")
)
# New USD tracking fields
used_amount_usd = Column(Float, nullable=True, default=0)
quota_amount_usd = Column(Float, nullable=True)
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
updated_at = Column(
DateTime(timezone=True),
default=lambda: datetime.now(UTC),
onupdate=lambda: datetime.now(UTC),
)
# Relationships
organization = relationship("OrganizationModel", back_populates="usage_cycles")
# Constraints and indexes
__table_args__ = (
UniqueConstraint(
"organization_id", "period_start", "period_end", name="unique_org_period"
),
Index("idx_usage_cycles_org_period", "organization_id", "period_end"),
)
class CampaignModel(Base):
__tablename__ = "campaigns"
id = Column(Integer, primary_key=True, index=True)
name = Column(String, nullable=False, index=True)
organization_id = Column(Integer, ForeignKey("organizations.id"), nullable=False)
workflow_id = Column(Integer, ForeignKey("workflows.id"), nullable=False)
created_by = Column(Integer, ForeignKey("users.id"), nullable=False)
# Source configuration
source_type = Column(String, nullable=False, default="google-sheet")
source_id = Column(String, nullable=False) # Sheet URL
# State management
state = Column(
Enum(
"created",
"syncing",
"running",
"paused",
"completed",
"failed",
name="campaign_state",
),
nullable=False,
default="created",
)
# Progress tracking
total_rows = Column(Integer, nullable=True)
processed_rows = Column(Integer, nullable=False, default=0)
failed_rows = Column(Integer, nullable=False, default=0)
# Rate limiting and sync configuration
rate_limit_per_second = Column(Integer, nullable=False, default=1)
max_retries = Column(Integer, nullable=False, default=0)
source_sync_status = Column(String, nullable=False, default="pending")
source_last_synced_at = Column(DateTime(timezone=True), nullable=True)
source_sync_error = Column(String, nullable=True)
# Retry configuration for call failures
retry_config = Column(
JSON,
nullable=False,
default={
"enabled": True,
"max_retries": 2,
"retry_delay_seconds": 120,
"retry_on_busy": True,
"retry_on_no_answer": True,
"retry_on_voicemail": True,
},
server_default=text(
'\'{"enabled": true, "max_retries": 2, "retry_on_busy": true, "retry_on_no_answer": true, "retry_on_voicemail": true, "retry_delay_seconds": 120}\'::jsonb'
),
)
# Orchestrator tracking fields
last_batch_scheduled_at = Column(DateTime(timezone=True), nullable=True)
last_activity_at = Column(DateTime(timezone=True), nullable=True)
orchestrator_metadata = Column(
JSON, nullable=False, default=dict, server_default=text("'{}'::json")
)
# Timestamps
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
started_at = Column(DateTime(timezone=True), nullable=True)
completed_at = Column(DateTime(timezone=True), nullable=True)
updated_at = Column(
DateTime(timezone=True),
default=lambda: datetime.now(UTC),
onupdate=lambda: datetime.now(UTC),
)
# Relationships
organization = relationship("OrganizationModel")
workflow = relationship("WorkflowModel")
created_by_user = relationship("UserModel")
# Indexes
__table_args__ = (
Index("ix_campaigns_org_id", "organization_id"),
Index("ix_campaigns_state", "state"),
Index("ix_campaigns_workflow_id", "workflow_id"),
# Index for efficient querying of active campaigns
Index(
"idx_campaigns_active_status",
"state",
postgresql_where=text("state IN ('syncing', 'running', 'paused')"),
),
)
class QueuedRunModel(Base):
__tablename__ = "queued_runs"
id = Column(Integer, primary_key=True, index=True)
campaign_id = Column(
Integer, ForeignKey("campaigns.id", ondelete="CASCADE"), nullable=False
)
source_uuid = Column(String, nullable=False)
context_variables = Column(JSON, nullable=False, default=dict)
state = Column(
Enum("queued", "processed", "failed", name="queued_run_state"),
nullable=False,
default="queued",
)
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
processed_at = Column(DateTime(timezone=True), nullable=True)
# New retry-related fields
retry_count = Column(Integer, default=0, nullable=False, server_default=text("0"))
parent_queued_run_id = Column(Integer, ForeignKey("queued_runs.id"), nullable=True)
scheduled_for = Column(DateTime(timezone=True), nullable=True)
retry_reason = Column(String, nullable=True) # 'busy', 'no_answer', 'voicemail'
# Relationships
campaign = relationship("CampaignModel")
parent_queued_run = relationship("QueuedRunModel", remote_side=[id])
# Indexes
__table_args__ = (
Index("idx_queued_runs_campaign_state", "campaign_id", "state"),
Index("idx_queued_runs_created", "created_at"),
Index("idx_queued_runs_source_uuid", "source_uuid"),
Index(
"idx_queued_runs_scheduled", "scheduled_for"
), # New index for scheduled retries
# Optimized index for checking queued runs efficiently
Index(
"idx_queued_runs_campaign_state_optimized",
"campaign_id",
"state",
postgresql_where=text("state = 'queued'"),
),
# Optimized index for scheduled retries
Index(
"idx_queued_runs_scheduled_optimized",
"campaign_id",
"scheduled_for",
postgresql_where=text("scheduled_for IS NOT NULL"),
),
UniqueConstraint(
"campaign_id",
"source_uuid",
"retry_count",
name="unique_campaign_source_retry",
),
)

View file

@ -0,0 +1,114 @@
from datetime import datetime, timezone
from typing import Optional
from sqlalchemy.dialects.postgresql import insert
from sqlalchemy.future import select
from api.db.base_client import BaseDBClient
from api.db.models import (
APIKeyModel,
OrganizationModel,
organization_users_association,
)
from api.utils.api_key import generate_api_key
class OrganizationClient(BaseDBClient):
async def get_organization_by_id(
self, organization_id: int
) -> Optional[OrganizationModel]:
"""Get an organization by its ID."""
async with self.async_session() as session:
result = await session.execute(
select(OrganizationModel).where(OrganizationModel.id == organization_id)
)
return result.scalars().first()
async def get_or_create_organization_by_provider_id(
self, org_provider_id: str, user_id: int
) -> tuple[OrganizationModel, bool]:
"""Get an existing organization by provider_id or create a new one.
Returns:
A tuple of (organization, was_created) where was_created is True if the organization
was created in this call, False if it already existed.
"""
async with self.async_session() as session:
# First try to get existing organization
result = await session.execute(
select(OrganizationModel).where(
OrganizationModel.provider_id == org_provider_id
)
)
organization = result.scalars().first()
if organization is None:
# Use PostgreSQL's INSERT ... ON CONFLICT DO NOTHING
# This is atomic and handles race conditions at the database level
stmt = insert(OrganizationModel.__table__).values(
provider_id=org_provider_id, created_at=datetime.now(timezone.utc)
)
# ON CONFLICT DO NOTHING - if another request already inserted, this becomes a no-op
stmt = stmt.on_conflict_do_nothing(index_elements=["provider_id"])
result = await session.execute(stmt)
await session.commit()
# Check if we actually inserted (rowcount > 0) or if there was a conflict (rowcount == 0)
was_created = result.rowcount > 0
# Now fetch the organization (either the one we just created or the one that existed)
result = await session.execute(
select(OrganizationModel).where(
OrganizationModel.provider_id == org_provider_id
)
)
organization = result.scalars().first()
if organization is None:
# This should never happen, but handle it just in case
error_msg = f"Failed to create or fetch organization with provider_id {org_provider_id}"
raise ValueError(error_msg)
# Only create API key if we actually created the organization
if was_created:
# Create a default API key for the new organization
_, key_hash, key_prefix = generate_api_key()
api_key = APIKeyModel(
organization_id=organization.id,
name="Default API Key",
key_hash=key_hash,
key_prefix=key_prefix,
is_active=True,
created_by=user_id,
)
session.add(api_key)
await session.commit()
await session.refresh(organization)
return organization, was_created
return organization, False
async def add_user_to_organization(
self, user_id: int, organization_id: int
) -> None:
"""Ensure that a user is linked to an organization (many-to-many).
The association is created only if it does not already exist.
Uses INSERT ... ON CONFLICT DO NOTHING to handle race conditions.
"""
async with self.async_session() as session:
# Use PostgreSQL's INSERT ... ON CONFLICT DO NOTHING
# This handles race conditions at the database level
stmt = insert(organization_users_association).values(
user_id=user_id, organization_id=organization_id
)
# ON CONFLICT DO NOTHING - if another request already inserted, this becomes a no-op
# The primary key constraint on (user_id, organization_id) will trigger the conflict
stmt = stmt.on_conflict_do_nothing()
await session.execute(stmt)
await session.commit()

View file

@ -0,0 +1,96 @@
from typing import Any, Optional
from sqlalchemy.future import select
from api.db.base_client import BaseDBClient
from api.db.models import OrganizationConfigurationModel
class OrganizationConfigurationClient(BaseDBClient):
async def get_configuration(
self, organization_id: int, key: str
) -> Optional[OrganizationConfigurationModel]:
"""Get a specific configuration for an organization by key."""
async with self.async_session() as session:
result = await session.execute(
select(OrganizationConfigurationModel).where(
OrganizationConfigurationModel.organization_id == organization_id,
OrganizationConfigurationModel.key == key,
)
)
return result.scalars().first()
async def get_all_configurations(
self, organization_id: int
) -> list[OrganizationConfigurationModel]:
"""Get all configurations for an organization."""
async with self.async_session() as session:
result = await session.execute(
select(OrganizationConfigurationModel).where(
OrganizationConfigurationModel.organization_id == organization_id
)
)
return result.scalars().all()
async def upsert_configuration(
self, organization_id: int, key: str, value: Any
) -> OrganizationConfigurationModel:
"""Create or update a configuration for an organization."""
async with self.async_session() as session:
# First try to get existing configuration
result = await session.execute(
select(OrganizationConfigurationModel).where(
OrganizationConfigurationModel.organization_id == organization_id,
OrganizationConfigurationModel.key == key,
)
)
config = result.scalars().first()
if config:
# Update existing configuration
config.value = value
else:
# Create new configuration
config = OrganizationConfigurationModel(
organization_id=organization_id,
key=key,
value=value,
)
session.add(config)
try:
await session.commit()
except Exception as e:
await session.rollback()
raise e
await session.refresh(config)
return config
async def delete_configuration(self, organization_id: int, key: str) -> bool:
"""Delete a configuration for an organization."""
async with self.async_session() as session:
result = await session.execute(
select(OrganizationConfigurationModel).where(
OrganizationConfigurationModel.organization_id == organization_id,
OrganizationConfigurationModel.key == key,
)
)
config = result.scalars().first()
if not config:
return False
await session.delete(config)
try:
await session.commit()
except Exception as e:
await session.rollback()
raise e
return True
async def get_configuration_value(
self, organization_id: int, key: str, default: Any = None
) -> Any:
"""Get the value of a configuration, returning default if not found."""
config = await self.get_configuration(organization_id, key)
return config.value if config else default

View file

@ -0,0 +1,524 @@
from datetime import datetime, timezone
from typing import Optional
from zoneinfo import ZoneInfo
from dateutil.relativedelta import relativedelta
from sqlalchemy import Date, and_, cast, func, select
from sqlalchemy.dialects.postgresql import insert
from sqlalchemy.orm import joinedload
from api.db.base_client import BaseDBClient
from api.db.filters import apply_workflow_run_filters
from api.db.models import (
OrganizationModel,
OrganizationUsageCycleModel,
UserConfigurationModel,
UserModel,
WorkflowModel,
WorkflowRunModel,
)
from api.schemas.user_configuration import UserConfiguration
class OrganizationUsageClient(BaseDBClient):
"""Client for managing organization usage and quota operations."""
async def get_or_create_current_cycle(
self, organization_id: int, session=None
) -> OrganizationUsageCycleModel:
"""Get or create the current usage cycle for an organization.
Args:
organization_id: The organization ID
session: Optional session to use for the operation. If provided,
the caller is responsible for committing.
"""
if session is None:
async with self.async_session() as session:
return await self._get_or_create_current_cycle_impl(
organization_id, session, commit=True
)
else:
return await self._get_or_create_current_cycle_impl(
organization_id, session, commit=False
)
async def _get_or_create_current_cycle_impl(
self, organization_id: int, session, commit: bool
) -> OrganizationUsageCycleModel:
"""Internal implementation for get_or_create_current_cycle."""
# Get organization to determine quota type
org_result = await session.execute(
select(OrganizationModel).where(OrganizationModel.id == organization_id)
)
org = org_result.scalar_one()
# Calculate current period
period_start, period_end = self._calculate_current_period(org)
# Try to get existing cycle
cycle_result = await session.execute(
select(OrganizationUsageCycleModel).where(
and_(
OrganizationUsageCycleModel.organization_id == organization_id,
OrganizationUsageCycleModel.period_start == period_start,
OrganizationUsageCycleModel.period_end == period_end,
)
)
)
cycle = cycle_result.scalar_one_or_none()
if cycle:
return cycle
# Create new cycle if it doesn't exist
stmt = insert(OrganizationUsageCycleModel).values(
organization_id=organization_id,
period_start=period_start,
period_end=period_end,
quota_dograh_tokens=org.quota_dograh_tokens,
)
# Handle concurrent inserts gracefully
stmt = stmt.on_conflict_do_nothing(
index_elements=["organization_id", "period_start", "period_end"]
)
await session.execute(stmt)
if commit:
await session.commit()
# Fetch the created cycle
cycle_result = await session.execute(
select(OrganizationUsageCycleModel).where(
and_(
OrganizationUsageCycleModel.organization_id == organization_id,
OrganizationUsageCycleModel.period_start == period_start,
OrganizationUsageCycleModel.period_end == period_end,
)
)
)
return cycle_result.scalar_one()
async def check_and_reserve_quota(
self, organization_id: int, estimated_tokens: int = 0
) -> bool:
"""
Check if organization has sufficient quota and optionally reserve tokens.
Returns True if quota is available, False otherwise.
This method is fully atomic and safe for concurrent access from multiple processes.
"""
async with self.async_session() as session:
# Get organization
org_result = await session.execute(
select(OrganizationModel).where(OrganizationModel.id == organization_id)
)
org = org_result.scalar_one_or_none()
if not org or not org.quota_enabled:
# No quota enforcement if not enabled
return True
# Get or create current cycle within the same session/transaction
cycle = await self._get_or_create_current_cycle_impl(
organization_id, session, commit=False
)
# Atomic check and update with row-level lock
result = await session.execute(
select(OrganizationUsageCycleModel)
.where(
and_(
OrganizationUsageCycleModel.id == cycle.id,
OrganizationUsageCycleModel.used_dograh_tokens
+ estimated_tokens
<= OrganizationUsageCycleModel.quota_dograh_tokens,
)
)
.with_for_update(skip_locked=False)
)
cycle_locked = result.scalar_one_or_none()
if cycle_locked:
# Update the usage atomically
cycle_locked.used_dograh_tokens += estimated_tokens
await session.commit()
return True
return False
async def update_usage_after_run(
self,
organization_id: int,
actual_tokens: int,
duration_seconds: int = 0,
charge_usd: float = None,
) -> None:
"""Update usage after a workflow run completes with actual token count and duration.
This method is fully atomic and safe for concurrent access from multiple processes.
"""
async with self.async_session() as session:
# Get or create current cycle within the same session/transaction
cycle = await self._get_or_create_current_cycle_impl(
organization_id, session, commit=False
)
# Acquire a row-level lock for atomic update
result = await session.execute(
select(OrganizationUsageCycleModel)
.where(OrganizationUsageCycleModel.id == cycle.id)
.with_for_update(skip_locked=False)
)
cycle_locked = result.scalar_one()
# Update usage atomically
cycle_locked.used_dograh_tokens += actual_tokens
cycle_locked.total_duration_seconds += int(round(duration_seconds))
# Update USD amount if provided
if charge_usd is not None:
if cycle_locked.used_amount_usd is None:
cycle_locked.used_amount_usd = 0
cycle_locked.used_amount_usd += charge_usd
await session.commit()
async def get_current_usage(self, organization_id: int) -> dict:
"""Get current period usage information."""
async with self.async_session() as session:
# Get organization
org_result = await session.execute(
select(OrganizationModel).where(OrganizationModel.id == organization_id)
)
org = org_result.scalar_one()
# Get or create current cycle within the same session
cycle = await self._get_or_create_current_cycle_impl(
organization_id, session, commit=False
)
# Calculate next refresh date
if org.quota_type == "monthly":
next_refresh = cycle.period_end + relativedelta(days=1)
else: # annual
next_refresh = cycle.period_end + relativedelta(days=1)
result = {
"period_start": cycle.period_start.isoformat(),
"period_end": cycle.period_end.isoformat(),
"used_dograh_tokens": cycle.used_dograh_tokens,
"quota_dograh_tokens": cycle.quota_dograh_tokens,
"percentage_used": (
round(
(cycle.used_dograh_tokens / cycle.quota_dograh_tokens) * 100, 2
)
if cycle.quota_dograh_tokens > 0
else 0
),
"next_refresh_date": next_refresh.date().isoformat(),
"quota_enabled": org.quota_enabled,
"total_duration_seconds": cycle.total_duration_seconds,
}
# Add USD fields if organization has pricing
if org.price_per_second_usd is not None:
result["used_amount_usd"] = cycle.used_amount_usd or 0
result["quota_amount_usd"] = cycle.quota_amount_usd
result["currency"] = "USD"
result["price_per_second_usd"] = org.price_per_second_usd
# Calculate percentage based on USD if available
if cycle.quota_amount_usd and cycle.quota_amount_usd > 0:
result["percentage_used"] = round(
((cycle.used_amount_usd or 0) / cycle.quota_amount_usd) * 100, 2
)
return result
async def get_usage_history(
self,
organization_id: int,
start_date: Optional[datetime] = None,
end_date: Optional[datetime] = None,
limit: int = 50,
offset: int = 0,
filters: Optional[list[dict]] = None,
) -> tuple[list[dict], int]:
"""Get paginated workflow runs with usage for an organization."""
async with self.async_session() as session:
query = (
select(WorkflowRunModel)
.join(WorkflowModel, WorkflowRunModel.workflow_id == WorkflowModel.id)
.join(UserModel, WorkflowModel.user_id == UserModel.id)
.where(
UserModel.selected_organization_id == organization_id,
WorkflowRunModel.cost_info.isnot(None),
)
.order_by(WorkflowRunModel.created_at.desc())
)
# Apply date filters if provided
if start_date:
query = query.where(WorkflowRunModel.created_at >= start_date)
if end_date:
query = query.where(WorkflowRunModel.created_at <= end_date)
# Only allow specific filters for usage history endpoint
# This ensures security and prevents unexpected filter attributes
allowed_filters = {"duration", "dispositionCode", "phoneNumber"}
sanitized_filters = []
if filters:
for filter_item in filters:
attribute = filter_item.get("attribute")
# Only process allowed filters
if attribute in allowed_filters:
sanitized_filters.append(filter_item)
# Apply filters using the common filter function
query = apply_workflow_run_filters(query, sanitized_filters)
# Get total count
count_result = await session.execute(
select(func.count()).select_from(query.subquery())
)
total_count = count_result.scalar()
results = await session.execute(
query.options(joinedload(WorkflowRunModel.workflow))
.limit(limit)
.offset(offset)
)
runs = results.scalars().all()
# Format runs
formatted_runs = []
total_tokens = 0
total_duration_seconds = 0
for run in runs:
if run.cost_info:
# Try to get dograh_token_usage first (new format)
dograh_tokens = run.cost_info.get("dograh_token_usage", 0)
# If not present, calculate from total_cost_usd (old format)
if dograh_tokens == 0 and "total_cost_usd" in run.cost_info:
dograh_tokens = round(
float(run.cost_info["total_cost_usd"]) * 100, 2
)
# Get call duration
call_duration = run.cost_info.get("call_duration_seconds", 0)
else:
dograh_tokens = 0
call_duration = 0
total_tokens += dograh_tokens
total_duration_seconds += int(round(call_duration))
# Extract phone number from initial_context
phone_number = None
if run.initial_context:
phone_number = run.initial_context.get("phone_number")
# Extract disposition from gathered_context
disposition = None
if run.gathered_context:
disposition = run.gathered_context.get("mapped_call_disposition")
run_data = {
"id": run.id,
"workflow_id": run.workflow_id,
"workflow_name": run.workflow.name if run.workflow else None,
"name": run.name,
"created_at": run.created_at.isoformat(),
"dograh_token_usage": dograh_tokens,
"call_duration_seconds": int(round(call_duration)),
"recording_url": run.recording_url,
"transcript_url": run.transcript_url,
"phone_number": phone_number,
"disposition": disposition,
"initial_context": run.initial_context,
"gathered_context": run.gathered_context,
}
# Add USD cost if available in cost_info
if run.cost_info and "charge_usd" in run.cost_info:
run_data["charge_usd"] = run.cost_info["charge_usd"]
formatted_runs.append(run_data)
return formatted_runs, total_count, total_tokens, total_duration_seconds
async def get_daily_usage_breakdown(
self,
organization_id: int,
start_date: datetime,
end_date: datetime,
price_per_second_usd: float,
user_id: Optional[int] = None,
) -> dict:
"""Get daily usage breakdown for an organization with pricing."""
async with self.async_session() as session:
# Get user timezone if user_id is provided
user_timezone = "UTC" # Default timezone
if user_id:
config_result = await session.execute(
select(UserConfigurationModel).where(
UserConfigurationModel.user_id == user_id
)
)
config_obj = config_result.scalar_one_or_none()
if config_obj and config_obj.configuration:
user_config = UserConfiguration.model_validate(
config_obj.configuration
)
if user_config.timezone:
user_timezone = user_config.timezone
# Validate timezone string
try:
# Test if timezone is valid
ZoneInfo(user_timezone)
except Exception:
# Fallback to UTC if timezone is invalid
user_timezone = "UTC"
# Query to get daily aggregates
# Use AT TIME ZONE to convert to user's timezone before grouping by date
date_expr = cast(
func.timezone(user_timezone, WorkflowRunModel.created_at), Date
)
daily_usage = await session.execute(
select(
date_expr.label("date"),
func.sum(
WorkflowRunModel.cost_info["call_duration_seconds"].as_float()
).label("total_seconds"),
func.count(WorkflowRunModel.id).label("call_count"),
)
.join(WorkflowModel, WorkflowModel.id == WorkflowRunModel.workflow_id)
.join(UserModel, UserModel.id == WorkflowModel.user_id)
.where(
UserModel.selected_organization_id == organization_id,
WorkflowRunModel.created_at >= start_date,
WorkflowRunModel.created_at <= end_date,
WorkflowRunModel.is_completed == True,
)
.group_by(date_expr)
.order_by(date_expr.desc())
)
breakdown = []
total_minutes = 0
total_cost_usd = 0
total_dograh_tokens = 0
for row in daily_usage:
seconds = row.total_seconds or 0
minutes = seconds / 60
cost_usd = seconds * price_per_second_usd
dograh_tokens = cost_usd * 100 # 1 cent = 1 token
total_minutes += minutes
total_cost_usd += cost_usd
total_dograh_tokens += dograh_tokens
breakdown.append(
{
"date": row.date.isoformat(),
"minutes": round(minutes, 1),
"cost_usd": round(cost_usd, 2),
"dograh_tokens": round(dograh_tokens, 0),
"call_count": row.call_count,
}
)
return {
"breakdown": breakdown,
"total_minutes": round(total_minutes, 1),
"total_cost_usd": round(total_cost_usd, 2),
"total_dograh_tokens": round(total_dograh_tokens, 0),
"currency": "USD",
}
async def update_organization_quota(
self,
organization_id: int,
quota_type: str,
quota_dograh_tokens: int,
quota_reset_day: Optional[int] = None,
quota_start_date: Optional[datetime] = None,
) -> OrganizationModel:
"""Update organization quota settings."""
async with self.async_session() as session:
result = await session.execute(
select(OrganizationModel).where(OrganizationModel.id == organization_id)
)
org = result.scalar_one()
org.quota_type = quota_type
org.quota_dograh_tokens = quota_dograh_tokens
org.quota_enabled = True
if quota_type == "monthly" and quota_reset_day:
org.quota_reset_day = quota_reset_day
elif quota_type == "annual" and quota_start_date:
org.quota_start_date = quota_start_date
await session.commit()
await session.refresh(org)
return org
def _calculate_current_period(
self, org: OrganizationModel
) -> tuple[datetime, datetime]:
"""Calculate the current billing period based on organization settings."""
now = datetime.now(timezone.utc)
if org.quota_type == "monthly":
# Find the start of the current billing month
reset_day = org.quota_reset_day
# Handle month boundaries
if now.day >= reset_day:
period_start = now.replace(
day=reset_day, hour=0, minute=0, second=0, microsecond=0
)
else:
# Previous month
period_start = (now - relativedelta(months=1)).replace(
day=reset_day, hour=0, minute=0, second=0, microsecond=0
)
# End is one month later minus 1 second
period_end = (
period_start + relativedelta(months=1) - relativedelta(seconds=1)
)
else: # annual
if not org.quota_start_date:
# Default to calendar year
period_start = now.replace(
month=1, day=1, hour=0, minute=0, second=0, microsecond=0
)
period_end = (
period_start + relativedelta(years=1) - relativedelta(seconds=1)
)
else:
# Find current annual period
start_date = org.quota_start_date.replace(tzinfo=timezone.utc)
years_diff = now.year - start_date.year
# Adjust for whether we've passed the anniversary
if now.month < start_date.month or (
now.month == start_date.month and now.day < start_date.day
):
years_diff -= 1
period_start = start_date + relativedelta(years=years_diff)
period_end = (
period_start + relativedelta(years=1) - relativedelta(seconds=1)
)
return period_start, period_end

156
api/db/reports_client.py Normal file
View file

@ -0,0 +1,156 @@
from datetime import datetime
from typing import Any, Dict, List, Optional
from sqlalchemy import String, and_, func, select
from api.db.base_client import BaseDBClient
from api.db.models import WorkflowModel, WorkflowRunModel
class ReportsClient(BaseDBClient):
async def get_workflow_runs_for_daily_report(
self,
organization_id: int,
start_utc: datetime,
end_utc: datetime,
workflow_id: Optional[int] = None,
) -> List[Dict[str, Any]]:
"""
Optimized method for daily reports - fetches only required JSON fields.
Uses PostgreSQL JSON operators to extract only needed fields from JSON columns.
Args:
organization_id: The organization ID to filter by
start_utc: Start datetime in UTC
end_utc: End datetime in UTC
workflow_id: Optional workflow ID to filter by
Returns:
List of dictionaries with report-specific fields
"""
async with self.async_session() as session:
# Select only the specific JSON fields needed for daily reports
# Using PostgreSQL's JSON operators to extract specific fields
query = (
select(
WorkflowRunModel.id,
WorkflowRunModel.workflow_id,
WorkflowRunModel.created_at,
# Extract only specific fields from JSON columns
# Use TRIM and REPLACE to remove any quotes from JSON values
func.coalesce(
func.replace(
func.replace(
func.cast(
WorkflowRunModel.gathered_context[
"mapped_call_disposition"
],
String,
),
'"',
"",
),
"'",
"",
),
"UNKNOWN",
).label("disposition"),
func.coalesce(
func.replace(
func.replace(
func.cast(
WorkflowRunModel.gathered_context[
"customer_phone_number"
],
String,
),
'"',
"",
),
"'",
"",
),
func.replace(
func.replace(
func.cast(
WorkflowRunModel.initial_context["phone_number"],
String,
),
'"',
"",
),
"'",
"",
),
"",
).label("phone_number"),
func.coalesce(
func.replace(
func.replace(
func.cast(
WorkflowRunModel.usage_info[
"call_duration_seconds"
],
String,
),
'"',
"",
),
"'",
"",
),
"0",
).label("call_duration_seconds"),
WorkflowModel.name.label("workflow_name"),
)
.select_from(WorkflowRunModel)
.join(WorkflowModel, WorkflowRunModel.workflow_id == WorkflowModel.id)
.where(
and_(
WorkflowModel.organization_id == organization_id,
WorkflowRunModel.created_at >= start_utc,
WorkflowRunModel.created_at <= end_utc,
)
)
)
if workflow_id is not None:
query = query.where(WorkflowRunModel.workflow_id == workflow_id)
result = await session.execute(query)
rows = result.all()
return [
{
"id": row.id,
"workflow_id": row.workflow_id,
"workflow_name": row.workflow_name,
"created_at": row.created_at,
"gathered_context": {
"mapped_call_disposition": row.disposition,
"customer_phone_number": row.phone_number, # Also provide it here for compatibility
},
"usage_info": {"call_duration_seconds": row.call_duration_seconds},
"initial_context": {"phone_number": row.phone_number},
}
for row in rows
]
async def get_workflows_for_organization(
self, organization_id: int
) -> List[WorkflowModel]:
"""
Get all workflows for an organization.
Args:
organization_id: The organization ID
"""
async with self.async_session() as session:
query = (
select(WorkflowModel)
.where(WorkflowModel.organization_id == organization_id)
.order_by(WorkflowModel.name)
)
result = await session.execute(query)
return result.scalars().all()

139
api/db/user_client.py Normal file
View file

@ -0,0 +1,139 @@
from datetime import datetime, timezone
from sqlalchemy.future import select
from api.db.base_client import BaseDBClient
from api.db.models import UserConfigurationModel, UserModel
from api.schemas.user_configuration import UserConfiguration
class UserClient(BaseDBClient):
async def get_or_create_user_by_provider_id(self, provider_id: str) -> UserModel:
async with self.async_session() as session:
# First try to get existing user
result = await session.execute(
select(UserModel).where(UserModel.provider_id == provider_id)
)
user = result.scalars().first()
if user is None:
# Use PostgreSQL's INSERT ... ON CONFLICT DO NOTHING
# This is atomic and handles race conditions at the database level
from sqlalchemy.dialects.postgresql import insert
stmt = insert(UserModel.__table__).values(
provider_id=provider_id,
created_at=datetime.now(timezone.utc),
selected_organization_id=None, # Will be set later
is_superuser=False, # Default value
)
# ON CONFLICT DO NOTHING - if another request already inserted, this becomes a no-op
stmt = stmt.on_conflict_do_nothing(index_elements=["provider_id"])
result = await session.execute(stmt)
await session.commit()
# Now fetch the user (either the one we just created or the one that existed)
result = await session.execute(
select(UserModel).where(UserModel.provider_id == provider_id)
)
user = result.scalars().first()
if user is None:
# This should never happen, but handle it just in case
error_msg = (
f"Failed to create or fetch user with provider_id {provider_id}"
)
raise ValueError(error_msg)
return user
async def get_user_by_id(self, user_id: int) -> UserModel | None:
"""Fetch a user by their internal ID."""
async with self.async_session() as session:
result = await session.execute(
select(UserModel).where(UserModel.id == user_id)
)
return result.scalars().first()
async def get_user_configurations(self, user_id: int) -> UserConfiguration:
async with self.async_session() as session:
result = await session.execute(
select(UserConfigurationModel).where(
UserConfigurationModel.user_id == user_id
)
)
configuration_obj = result.scalars().first()
if not configuration_obj:
return UserConfiguration()
return UserConfiguration.model_validate(
{
**configuration_obj.configuration,
"last_validated_at": configuration_obj.last_validated_at,
}
)
async def update_user_configuration(
self, user_id: int, configuration: UserConfiguration
) -> UserConfiguration:
async with self.async_session() as session:
result = await session.execute(
select(UserConfigurationModel).where(
UserConfigurationModel.user_id == user_id
)
)
configuration_obj = result.scalars().first()
if not configuration_obj:
configuration_obj = UserConfigurationModel(
user_id=user_id, configuration=configuration.model_dump()
)
session.add(configuration_obj)
else:
configuration_obj.configuration = configuration.model_dump()
try:
await session.commit()
except Exception as e:
await session.rollback()
raise e
await session.refresh(configuration_obj)
return UserConfiguration.model_validate(configuration_obj.configuration)
async def update_user_configuration_last_validated_at(self, user_id: int) -> None:
async with self.async_session() as session:
result = await session.execute(
select(UserConfigurationModel).where(
UserConfigurationModel.user_id == user_id
)
)
configuration_obj = result.scalars().first()
if not configuration_obj:
raise ValueError(f"User configuration with ID {user_id} not found")
configuration_obj.last_validated_at = datetime.now()
try:
await session.commit()
except Exception as e:
await session.rollback()
raise e
await session.refresh(configuration_obj)
async def update_user_selected_organization(
self, user_id: int, organization_id: int
) -> None:
"""Update the user's selected organization ID."""
async with self.async_session() as session:
from sqlalchemy import update
# Use a direct UPDATE statement to avoid race conditions
# This is atomic at the database level
stmt = (
update(UserModel)
.where(UserModel.id == user_id)
.values(selected_organization_id=organization_id)
)
result = await session.execute(stmt)
if result.rowcount == 0:
raise ValueError(f"User with ID {user_id} not found")
await session.commit()

312
api/db/workflow_client.py Normal file
View file

@ -0,0 +1,312 @@
import hashlib
import json
from typing import Optional
from sqlalchemy import func
from sqlalchemy.future import select
from sqlalchemy.orm import selectinload
from api.db.base_client import BaseDBClient
from api.db.models import WorkflowDefinitionModel, WorkflowModel, WorkflowRunModel
class WorkflowClient(BaseDBClient):
def _generate_workflow_hash(self, workflow_definition: dict) -> str:
"""Generate a consistent hash for workflow definition."""
# Convert to JSON with sorted keys for consistent hashing
json_str = json.dumps(
workflow_definition, sort_keys=True, separators=(",", ":")
)
return hashlib.sha256(json_str.encode()).hexdigest()
async def _get_or_create_workflow_definition(
self, workflow_definition: dict, session, workflow_id: int = None
) -> WorkflowDefinitionModel:
"""Get existing workflow definition by hash or create a new one."""
workflow_hash = self._generate_workflow_hash(workflow_definition)
# Try to find existing definition
result = await session.execute(
select(WorkflowDefinitionModel).where(
WorkflowDefinitionModel.workflow_hash == workflow_hash,
WorkflowDefinitionModel.workflow_id == workflow_id,
)
)
existing_definition = result.scalars().first()
if existing_definition:
return existing_definition
# Create new definition if it doesn't exist
new_definition = WorkflowDefinitionModel(
workflow_hash=workflow_hash,
workflow_json=workflow_definition,
workflow_id=workflow_id,
)
session.add(new_definition)
await session.flush() # Flush to get the ID without committing
return new_definition
async def create_workflow(
self,
name: str,
workflow_definition: dict,
user_id: int,
organization_id: int = None,
) -> WorkflowModel:
async with self.async_session() as session:
try:
new_workflow = WorkflowModel(
name=name,
workflow_definition=workflow_definition, # Keep for backwards compatibility
user_id=user_id,
organization_id=organization_id,
)
session.add(new_workflow)
await session.flush() # Flush to get the workflow ID
# Now get or create workflow definition with the workflow_id
definition = await self._get_or_create_workflow_definition(
workflow_definition, session, new_workflow.id
)
# Mark this definition as the current one and unset others
definition.is_current = True
# Set any other definitions for this workflow to not current
other_defs_result = await session.execute(
select(WorkflowDefinitionModel).where(
WorkflowDefinitionModel.workflow_id == new_workflow.id,
WorkflowDefinitionModel.id != definition.id,
)
)
for other_def in other_defs_result.scalars().all():
other_def.is_current = False
await session.commit()
except Exception as e:
await session.rollback()
raise e
await session.refresh(new_workflow)
return new_workflow
async def get_all_workflows(
self, user_id: int = None, organization_id: int = None, status: str = None
) -> list[WorkflowModel]:
async with self.async_session() as session:
query = select(WorkflowModel).options(
selectinload(WorkflowModel.current_definition)
)
if organization_id:
# Filter by organization_id when provided
query = query.where(WorkflowModel.organization_id == organization_id)
elif user_id:
# Fallback to user_id for backwards compatibility
query = query.where(WorkflowModel.user_id == user_id)
# Filter by status if provided
if status:
query = query.where(WorkflowModel.status == status)
result = await session.execute(query)
return result.scalars().all()
async def get_workflow(
self, workflow_id: int, user_id: int = None, organization_id: int = None
) -> WorkflowModel | None:
async with self.async_session() as session:
query = (
select(WorkflowModel)
.options(selectinload(WorkflowModel.current_definition))
.where(WorkflowModel.id == workflow_id)
)
if organization_id:
# Filter by organization_id when provided
query = query.where(WorkflowModel.organization_id == organization_id)
elif user_id:
# Fallback to user_id for backwards compatibility
query = query.where(WorkflowModel.user_id == user_id)
result = await session.execute(query)
return result.scalars().first()
async def get_workflow_by_id(self, workflow_id: int) -> WorkflowModel | None:
async with self.async_session() as session:
result = await session.execute(
select(WorkflowModel)
.options(selectinload(WorkflowModel.current_definition))
.where(WorkflowModel.id == workflow_id)
)
return result.scalars().first()
async def update_workflow(
self,
workflow_id: int,
name: str,
workflow_definition: dict | None,
template_context_variables: dict | None,
workflow_configurations: dict | None,
user_id: int = None,
organization_id: int = None,
) -> WorkflowModel:
"""
Update an existing workflow in the database.
Args:
workflow_id: The ID of the workflow to update
name: The new name for the workflow
workflow_definition: The new workflow definition
template_context_variables: The template context variables
user_id: The user ID (for backwards compatibility)
organization_id: The organization ID
Returns:
The updated WorkflowModel
Raises:
ValueError: If the workflow with the given ID is not found
"""
async with self.async_session() as session:
query = (
select(WorkflowModel)
.options(selectinload(WorkflowModel.current_definition))
.where(WorkflowModel.id == workflow_id)
)
if organization_id:
# Filter by organization_id when provided
query = query.where(WorkflowModel.organization_id == organization_id)
elif user_id:
# Fallback to user_id for backwards compatibility
query = query.where(WorkflowModel.user_id == user_id)
result = await session.execute(query)
workflow = result.scalars().first()
if not workflow:
raise ValueError(f"Workflow with ID {workflow_id} not found")
workflow.name = name
if template_context_variables is not None:
workflow.template_context_variables = template_context_variables
if workflow_configurations is not None:
workflow.workflow_configurations = workflow_configurations
# In case of only name update, the workflow_definition can be None
if workflow_definition:
# Get or create new workflow definition
definition = await self._get_or_create_workflow_definition(
workflow_definition, session, workflow_id
)
# Update legacy field for backwards compatibility
workflow.workflow_definition = workflow_definition
# Mark new definition as current and reset others
definition.is_current = True
other_defs_result = await session.execute(
select(WorkflowDefinitionModel).where(
WorkflowDefinitionModel.workflow_id == workflow_id,
WorkflowDefinitionModel.id != definition.id,
)
)
for other_def in other_defs_result.scalars().all():
other_def.is_current = False
try:
await session.commit()
except Exception as e:
await session.rollback()
raise e
await session.refresh(workflow)
return workflow
async def get_workflows_by_ids(
self, workflow_ids: list[int], organization_id: int
) -> list[WorkflowModel]:
"""Get workflows by IDs for a specific organization"""
async with self.async_session() as session:
result = await session.execute(
select(WorkflowModel)
.join(WorkflowModel.user)
.where(
WorkflowModel.id.in_(workflow_ids),
WorkflowModel.user.has(selected_organization_id=organization_id),
)
)
return result.scalars().all()
async def get_workflow_name(
self, workflow_id: int, user_id: int = None, organization_id: int = None
) -> Optional[str]:
"""Get just the workflow name by ID"""
async with self.async_session() as session:
query = select(WorkflowModel.name).where(WorkflowModel.id == workflow_id)
if organization_id:
# Filter by organization_id when provided
query = query.where(WorkflowModel.organization_id == organization_id)
elif user_id:
# Fallback to user_id for backwards compatibility
query = query.where(WorkflowModel.user_id == user_id)
result = await session.execute(query)
return result.scalar_one_or_none()
async def update_workflow_status(
self,
workflow_id: int,
status: str,
organization_id: int = None,
) -> WorkflowModel:
"""
Update the status of a workflow.
Args:
workflow_id: The ID of the workflow to update
status: The new status (active/archived)
organization_id: The organization ID
Returns:
The updated WorkflowModel
Raises:
ValueError: If the workflow is not found
"""
async with self.async_session() as session:
query = (
select(WorkflowModel)
.options(selectinload(WorkflowModel.current_definition))
.where(WorkflowModel.id == workflow_id)
)
if organization_id:
query = query.where(WorkflowModel.organization_id == organization_id)
result = await session.execute(query)
workflow = result.scalars().first()
if not workflow:
raise ValueError(f"Workflow with ID {workflow_id} not found")
workflow.status = status
try:
await session.commit()
except Exception as e:
await session.rollback()
raise e
await session.refresh(workflow)
return workflow
async def get_workflow_run_count(self, workflow_id: int) -> int:
"""Get the count of runs for a workflow."""
async with self.async_session() as session:
result = await session.execute(
select(func.count(WorkflowRunModel.id)).where(
WorkflowRunModel.workflow_id == workflow_id
)
)
return result.scalar() or 0

View file

@ -0,0 +1,404 @@
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional, Tuple
from sqlalchemy import func
from sqlalchemy.future import select
from sqlalchemy.orm import joinedload, selectinload
from api.db.base_client import BaseDBClient
from api.db.filters import apply_workflow_run_filters
from api.db.models import (
OrganizationModel,
UserModel,
WorkflowDefinitionModel,
WorkflowModel,
WorkflowRunModel,
)
from api.schemas.workflow import WorkflowRunResponseSchema
class WorkflowRunClient(BaseDBClient):
async def create_workflow_run(
self,
name: str,
workflow_id: int,
mode: str,
user_id: int,
initial_context: dict = None,
campaign_id: int = None,
queued_run_id: int = None,
) -> WorkflowRunModel:
async with self.async_session() as session:
# Get workflow and user to check organization
workflow = await session.execute(
select(WorkflowModel)
.options(joinedload(WorkflowModel.user))
.where(
WorkflowModel.id == workflow_id, WorkflowModel.user_id == user_id
)
)
workflow = workflow.scalars().first()
if not workflow:
raise ValueError(f"Workflow with ID {workflow_id} not found")
# # Check quota if user has an organization
# if workflow.user and workflow.user.selected_organization_id:
# # Import here to avoid circular dependency
# from api.db.organization_usage_client import OrganizationUsageClient
# usage_client = OrganizationUsageClient()
# # Check quota (no reservation for now, actual cost will be added after completion)
# has_quota = await usage_client.check_and_reserve_quota(
# workflow.user.selected_organization_id, estimated_tokens=0
# )
# if not has_quota:
# raise ValueError(
# "Organization quota exceeded. Please contact your administrator."
# )
# Fetch the current definition for this workflow
current_def_result = await session.execute(
select(WorkflowDefinitionModel).where(
WorkflowDefinitionModel.workflow_id == workflow.id,
WorkflowDefinitionModel.is_current == True,
)
)
current_def = current_def_result.scalars().first()
new_run = WorkflowRunModel(
name=name,
workflow=workflow,
mode=mode,
definition_id=current_def.id if current_def else None,
initial_context=initial_context or workflow.template_context_variables,
campaign_id=campaign_id,
queued_run_id=queued_run_id,
)
session.add(new_run)
try:
await session.commit()
except Exception as e:
await session.rollback()
raise e
await session.refresh(new_run)
return new_run
async def get_all_workflow_runs(self) -> list[WorkflowRunModel]:
async with self.async_session() as session:
result = await session.execute(select(WorkflowRunModel))
return result.scalars().all()
async def get_workflow_runs_for_superadmin(
self,
limit: int = 50,
offset: int = 0,
filters: Optional[List[Dict[str, Any]]] = None,
) -> tuple[list[dict], int]:
"""
Get paginated workflow runs for superadmin with organization information.
Returns tuple of (workflow_runs, total_count).
"""
async with self.async_session() as session:
# Build base query with joins
base_query = (
select(WorkflowRunModel)
.join(WorkflowModel, WorkflowRunModel.workflow_id == WorkflowModel.id)
.join(UserModel, WorkflowModel.user_id == UserModel.id)
.outerjoin(
OrganizationModel,
UserModel.selected_organization_id == OrganizationModel.id,
)
)
# Apply filters
base_query = apply_workflow_run_filters(base_query, filters)
# Count total with filters
count_query = base_query.with_only_columns(func.count(WorkflowRunModel.id))
count_result = await session.execute(count_query)
total_count = count_result.scalar()
# Get paginated results with filters
result = await session.execute(
base_query.options(
joinedload(WorkflowRunModel.workflow).joinedload(
WorkflowModel.user
),
joinedload(WorkflowRunModel.workflow)
.joinedload(WorkflowModel.user)
.joinedload(UserModel.selected_organization),
)
.order_by(WorkflowRunModel.created_at.desc())
.limit(limit)
.offset(offset)
)
workflow_runs = result.scalars().all()
# Format the response
formatted_runs = []
for run in workflow_runs:
organization = (
run.workflow.user.selected_organization
if run.workflow.user
else None
)
formatted_runs.append(
{
"id": run.id,
"name": run.name,
"workflow_id": run.workflow_id,
"workflow_name": run.workflow.name if run.workflow else None,
"user_id": run.workflow.user_id if run.workflow else None,
"organization_id": organization.id if organization else None,
"organization_name": organization.provider_id
if organization
else None,
"mode": run.mode,
"is_completed": run.is_completed,
"recording_url": run.recording_url,
"transcript_url": run.transcript_url,
"usage_info": run.usage_info,
"cost_info": run.cost_info,
"initial_context": run.initial_context,
"gathered_context": run.gathered_context,
"admin_comment": (run.annotations or {}).get("admin_comment"),
"admin_comment_ts": (run.annotations or {}).get(
"admin_comment_ts"
),
"created_at": run.created_at,
}
)
return formatted_runs, total_count
async def get_workflow_run(
self, run_id: int, user_id: int = None, organization_id: int = None
) -> WorkflowRunModel | None:
async with self.async_session() as session:
query = select(WorkflowRunModel).join(WorkflowRunModel.workflow)
if organization_id:
# Filter by organization_id when provided
query = query.where(
WorkflowRunModel.id == run_id,
WorkflowModel.organization_id == organization_id,
)
elif user_id:
# Fallback to user_id for backwards compatibility
query = query.where(
WorkflowRunModel.id == run_id,
WorkflowModel.user_id == user_id,
)
else:
query = query.where(WorkflowRunModel.id == run_id)
result = await session.execute(query)
return result.scalars().first()
async def get_workflow_run_by_id(self, run_id: int) -> WorkflowRunModel | None:
"""Get workflow run by ID without user filtering - for background tasks"""
async with self.async_session() as session:
result = await session.execute(
select(WorkflowRunModel)
.options(
joinedload(WorkflowRunModel.workflow).joinedload(WorkflowModel.user)
)
.where(WorkflowRunModel.id == run_id)
)
return result.scalars().first()
async def get_workflow_runs_by_workflow_id(
self,
workflow_id: int,
user_id: int = None,
organization_id: int = None,
limit: int = 50,
offset: int = 0,
filters: Optional[List[Dict[str, Any]]] = None,
) -> tuple[list[WorkflowRunResponseSchema], int]:
async with self.async_session() as session:
# Build base query
base_query = (
select(WorkflowRunModel)
.join(WorkflowModel, WorkflowRunModel.workflow_id == WorkflowModel.id)
.where(WorkflowRunModel.workflow_id == workflow_id)
)
if organization_id:
# Filter by organization_id when provided
base_query = base_query.where(
WorkflowModel.organization_id == organization_id
)
elif user_id:
# Fallback to user_id for backwards compatibility
base_query = base_query.where(WorkflowModel.user_id == user_id)
# Apply filters
base_query = apply_workflow_run_filters(base_query, filters)
# Count total with filters
count_query = base_query.with_only_columns(func.count(WorkflowRunModel.id))
count_result = await session.execute(count_query)
total_count = count_result.scalar()
# Get paginated results with filters
result = await session.execute(
base_query.order_by(WorkflowRunModel.created_at.desc())
.limit(limit)
.offset(offset)
)
runs = [
WorkflowRunResponseSchema.model_validate(
{
"id": run.id,
"workflow_id": run.workflow_id,
"name": run.name,
"mode": run.mode,
"created_at": run.created_at,
"is_completed": run.is_completed,
"recording_url": run.recording_url,
"transcript_url": run.transcript_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") or 0)
)
if run.cost_info
else None,
}
if run.cost_info
else None,
"definition_id": run.definition_id,
"initial_context": run.initial_context,
"gathered_context": run.gathered_context,
}
)
for run in result.scalars().all()
]
return runs, total_count
async def update_workflow_run(
self,
run_id: int,
is_completed: bool = False,
recording_url: str | None = None,
transcript_url: str | None = None,
storage_backend: str | None = None,
usage_info: dict | None = None,
cost_info: dict | None = None,
initial_context: dict | None = None,
gathered_context: dict | None = None,
logs: dict | None = None,
) -> WorkflowRunModel:
async with self.async_session() as session:
result = await session.execute(
select(WorkflowRunModel).where(WorkflowRunModel.id == run_id)
)
run = result.scalars().first()
if not run:
raise ValueError(f"Workflow run with ID {run_id} not found")
if recording_url:
run.recording_url = recording_url
if transcript_url:
run.transcript_url = transcript_url
if storage_backend:
run.storage_backend = storage_backend
if usage_info:
run.usage_info = usage_info
if cost_info:
run.cost_info = cost_info
if initial_context:
run.initial_context = initial_context
if gathered_context:
# Lets merge the incoming gathered context keys with the existing ones
run.gathered_context = {
**run.gathered_context,
**gathered_context,
}
if logs:
# Lets merge the incoming logs key with existing ones
run.logs = {**run.logs, **logs}
if is_completed:
run.is_completed = is_completed
try:
await session.commit()
except Exception as e:
await session.rollback()
raise e
await session.refresh(run)
return run
async def update_admin_comment(
self, run_id: int, admin_comment: str
) -> WorkflowRunModel:
"""Update (or create) the admin comment inside the ``annotations`` JSON column.
The comment is stored under the key ``admin_comment`` so we do not
overwrite any other existing annotations that may be present.
"""
async with self.async_session() as session:
result = await session.execute(
select(WorkflowRunModel).where(WorkflowRunModel.id == run_id)
)
run = result.scalars().first()
if run is None:
raise ValueError(f"Workflow run with ID {run_id} not found")
# Ensure we never mutate a shared dict between instances
current_annotations = dict(run.annotations or {})
current_annotations["admin_comment"] = admin_comment
current_annotations["admin_comment_ts"] = datetime.now(
timezone.utc
).isoformat()
run.annotations = current_annotations
try:
await session.commit()
except Exception as e:
await session.rollback()
raise e
await session.refresh(run)
return run
async def get_workflow_run_with_context(
self, workflow_run_id: int
) -> Tuple[Optional[WorkflowRunModel], Optional[int]]:
"""
Get workflow run with all related data and return organization_id.
Returns:
Tuple of (workflow_run, organization_id) or (None, None) if not found
"""
async with self.async_session() as session:
result = await session.execute(
select(WorkflowRunModel)
.options(
selectinload(WorkflowRunModel.workflow).selectinload(
WorkflowModel.user
)
)
.where(WorkflowRunModel.id == workflow_run_id)
)
workflow_run = result.scalars().first()
if not workflow_run:
return None, None
if not workflow_run.workflow or not workflow_run.workflow.user:
return workflow_run, None
organization_id = workflow_run.workflow.user.selected_organization_id
return workflow_run, organization_id

View file

@ -0,0 +1,99 @@
from sqlalchemy.future import select
from api.db.base_client import BaseDBClient
from api.db.models import WorkflowTemplates
class WorkflowTemplateClient(BaseDBClient):
async def get_workflow_template(self, template_id: int) -> WorkflowTemplates | None:
"""Get a workflow template by ID."""
async with self.async_session() as session:
result = await session.execute(
select(WorkflowTemplates).where(WorkflowTemplates.id == template_id)
)
return result.scalars().first()
async def get_workflow_template_by_name(
self, template_name: str
) -> WorkflowTemplates | None:
"""Get a workflow template by name."""
async with self.async_session() as session:
result = await session.execute(
select(WorkflowTemplates).where(
WorkflowTemplates.template_name == template_name
)
)
return result.scalars().first()
async def get_all_workflow_templates(self) -> list[WorkflowTemplates]:
"""Get all workflow templates."""
async with self.async_session() as session:
result = await session.execute(select(WorkflowTemplates))
return result.scalars().all()
async def create_workflow_template(
self, template_name: str, template_description: str, template_json: dict
) -> WorkflowTemplates:
"""Create a new workflow template."""
async with self.async_session() as session:
try:
new_template = WorkflowTemplates(
template_name=template_name,
template_description=template_description,
template_json=template_json,
)
session.add(new_template)
await session.commit()
await session.refresh(new_template)
return new_template
except Exception as e:
await session.rollback()
raise e
async def update_workflow_template(
self,
template_id: int,
template_name: str | None = None,
template_json: dict | None = None,
) -> WorkflowTemplates:
"""Update an existing workflow template."""
async with self.async_session() as session:
try:
result = await session.execute(
select(WorkflowTemplates).where(WorkflowTemplates.id == template_id)
)
template = result.scalars().first()
if not template:
raise ValueError(
f"Workflow template with ID {template_id} not found"
)
if template_name is not None:
template.template_name = template_name
if template_json is not None:
template.template_json = template_json
await session.commit()
await session.refresh(template)
return template
except Exception as e:
await session.rollback()
raise e
async def delete_workflow_template(self, template_id: int) -> bool:
"""Delete a workflow template by ID."""
async with self.async_session() as session:
try:
result = await session.execute(
select(WorkflowTemplates).where(WorkflowTemplates.id == template_id)
)
template = result.scalars().first()
if not template:
return False
await session.delete(template)
await session.commit()
return True
except Exception as e:
await session.rollback()
raise e

81
api/enums.py Normal file
View file

@ -0,0 +1,81 @@
from enum import Enum
class IntegrationAction(Enum):
ALL_CALLS = "All Calls"
QUALIFIED_CALLS = "Qualified Calls"
class Environment(Enum):
LOCAL = "local"
PRODUCTION = "production"
TEST = "test"
class WorkflowRunMode(Enum):
TWILIO = "twilio"
STASIS = "stasis"
WEBRTC = "webrtc"
SMALLWEBRTC = "smallwebrtc"
# Historical, not used anymore. Don't
# use and don't remove
VOICE = "VOICE"
CHAT = "CHAT"
class StorageBackend(Enum):
"""Storage backend enumeration.
Currently supported backends:
- S3: Amazon S3
- MINIO: MinIO
Future extensibility: Additional backends like GCS, Azure can be added by:
1. Adding new enum values as strings
2. Implementing storage logic in services/storage.py
3. Database will automatically support new values via SQLAlchemy Enum type
"""
# Currently implemented backends
S3 = "s3" # AWS S3 for cloud deployments
MINIO = "minio" # MinIO for local/OSS deployments
@classmethod
def get_current_backend(cls):
"""Get current backend based on ENABLE_AWS_S3 flag."""
from api.constants import ENABLE_AWS_S3
if ENABLE_AWS_S3:
return cls.S3
else:
return cls.MINIO
class WorkflowRunStatus(Enum):
# historical modes
VOICE = "VOICE"
CHAT = "CHAT"
class OrganizationConfigurationKey(Enum):
DISPOSITION_CODE_MAPPING = "DISPOSITION_CODE_MAPPING"
DISPOSITION_MESSAGE_TEMPLATE = "DISPOSITION_MESSAGE_TEMPLATE"
CONCURRENT_CALL_LIMIT = "CONCURRENT_CALL_LIMIT"
TWILIO_PHONE_NUMBERS = "TWILIO_PHONE_NUMBERS"
class WorkflowStatus(Enum):
"""Workflow status values"""
ACTIVE = "active"
ARCHIVED = "archived"
# Future statuses can be added here like:
# DRAFT = "draft"
# PAUSED = "paused"
class RedisChannel(Enum):
"""Redis pub/sub channel names"""
CAMPAIGN_EVENTS = "campaign_events"

217
api/logging_config.py Normal file
View file

@ -0,0 +1,217 @@
import atexit
import logging
import os
import queue
import sys
from logging.handlers import QueueHandler, QueueListener
import loguru
from axiom_py import Client
from axiom_py.logging import AxiomHandler
from pipecat.utils.context import run_id_var, turn_var
from api.enums import Environment
from api.utils.worker import get_worker_id, is_worker_process
# ----- NEW CODE START -----
# Helper to map string log level to Python logging level, adding support for "TRACE"
TRACE_LEVEL_NUM = 5 # Below DEBUG (10)
def _get_logging_level(level_name: str) -> int:
"""Return numeric logging level for a given level name.
Supports the standard logging levels as well as the custom ``TRACE`` level
used by *loguru*. If ``TRACE`` is requested and not yet defined in the
``logging`` module, it will be registered dynamically.
"""
level_name = level_name.upper()
# Standard levels are present on the ``logging`` module.
if hasattr(logging, level_name):
return getattr(logging, level_name)
# Add support for TRACE (finer-grained than DEBUG)
if level_name == "TRACE":
if not hasattr(logging, "TRACE"):
logging.addLevelName(TRACE_LEVEL_NUM, "TRACE")
def trace(self, message, *args, **kwargs): # type: ignore[override]
if self.isEnabledFor(TRACE_LEVEL_NUM):
self._log(TRACE_LEVEL_NUM, message, args, **kwargs)
logging.Logger.trace = trace # type: ignore[attr-defined]
return TRACE_LEVEL_NUM
# Fallback to DEBUG if an unknown level is provided
return logging.DEBUG
# ----- NEW CODE END -----
ENVIRONMENT = os.getenv("ENVIRONMENT", Environment.LOCAL.value)
ENABLE_TURN_LOGGING = os.getenv("ENABLE_TURN_LOGGING", "false").lower() == "true"
# Log rotation settings from environment
LOG_ROTATION_SIZE = os.getenv("LOG_ROTATION_SIZE", "100 MB") # e.g., "100 MB", "1 GB"
LOG_ROTATION_TIME = os.getenv("LOG_ROTATION_TIME", None) # e.g., "00:00", "12:00"
LOG_RETENTION = os.getenv(
"LOG_RETENTION", "7 days"
) # e.g., "7 days", "1 week", "10 files"
LOG_COMPRESSION = os.getenv(
"LOG_COMPRESSION", "gz"
) # "gz", "bz2", "xz", "tar", "tar.gz", "tar.bz2", "tar.xz", "zip"
LOG_FILE_PATH = os.getenv(
"LOG_FILE_PATH", None
) # If set, write to file instead of stdout
# Track if logging has been initialized
_logging_initialized = False
_axiom_listener = None
def inject_run_id(record):
"""Inject run_id from context variable into log record"""
record["extra"]["run_id"] = run_id_var.get()
# Only handle turn logging if enabled
if ENABLE_TURN_LOGGING:
# Get turn number with fallback mechanism
turn = turn_var.get()
# If turn is still 0, try the turn context manager
if turn == 0:
try:
from api.services.pipecat.turn_context import get_turn_context_manager
turn = get_turn_context_manager().get_turn()
except ImportError:
# Turn context manager not available
pass
record["extra"]["turn"] = turn
else:
# Turn logging disabled, use default value
record["extra"]["turn"] = 0
def setup_logging():
"""Set up logging for the main application"""
global _logging_initialized, _axiom_listener
# Return early if already initialized
if _logging_initialized:
return _axiom_listener
log_level = os.getenv("LOG_LEVEL", "DEBUG").upper()
# Don't setup logging in test environment
if ENVIRONMENT == Environment.TEST.value:
return
# Remove default loguru handler
try:
loguru.logger.remove(0)
except ValueError:
# Handler might already be removed
pass
# Patch loguru to inject run_id
patched = loguru.logger.patch(inject_run_id)
# Determine log format
if ENABLE_TURN_LOGGING:
log_format = "{time:YYYY-MM-DD HH:mm:ss.SSS} | <level>{level}</level> | [run_id={extra[run_id]}] [turn={extra[turn]}] | {file.name}:{line} | {message}"
else:
log_format = "{time:YYYY-MM-DD HH:mm:ss.SSS} | <level>{level}</level> | [run_id={extra[run_id]}] | {file.name}:{line} | {message}"
# Add handler - either file with rotation or console
if LOG_FILE_PATH:
# File handler with rotation
rotation_config = {}
# Size-based rotation (e.g., "100 MB", "1 GB")
if LOG_ROTATION_SIZE:
rotation_config["rotation"] = LOG_ROTATION_SIZE
# Time-based rotation (e.g., "00:00" for daily at midnight)
if LOG_ROTATION_TIME:
rotation_config["rotation"] = LOG_ROTATION_TIME
# If no rotation specified, default to 100 MB
if not rotation_config:
rotation_config["rotation"] = "100 MB"
# Retention policy (e.g., "7 days", "10 files")
if LOG_RETENTION:
rotation_config["retention"] = LOG_RETENTION
# Compression format
if LOG_COMPRESSION and LOG_COMPRESSION.lower() != "none":
rotation_config["compression"] = LOG_COMPRESSION
# Determine the actual log file path
actual_log_path = LOG_FILE_PATH
# If we're in a worker process, append worker ID to the filename
if is_worker_process():
worker_id = get_worker_id()
# Split the path to insert worker ID before extension
base_path, ext = os.path.splitext(LOG_FILE_PATH)
actual_log_path = f"{base_path}-worker-{worker_id}{ext}"
loguru.logger.info(f"Worker {worker_id} will log to: {actual_log_path}")
patched.add(
actual_log_path,
format=log_format,
level=log_level,
colorize=False, # No colors in file logs
enqueue=True, # Thread-safe writing
**rotation_config,
)
else:
# Console handler (existing behavior)
patched.add(
sys.stdout,
format=log_format,
level=log_level,
colorize=True,
)
# Set up queue-based logging for Axiom
log_q = queue.Queue(-1) # infinite size (tweak if needed)
queue_handler = QueueHandler(log_q) # puts LogRecord on the queue
queue_handler.setLevel(_get_logging_level(log_level))
# Set up Axiom handler if credentials are available
axiom_token = os.environ.get("AXIOM_TOKEN")
axiom_org = os.environ.get("AXIOM_ORG")
axiom_dataset = os.getenv("AXIOM_LOG_DATASET")
if axiom_token and axiom_org and axiom_dataset:
client = Client(token=axiom_token, org_id=axiom_org)
axiom_handler = AxiomHandler(client, axiom_dataset)
axiom_handler.setLevel(_get_logging_level(log_level))
listener = QueueListener(
log_q,
axiom_handler,
respect_handler_level=True,
)
listener.start()
patched.add(queue_handler, level=log_level, enqueue=False)
# Register cleanup
atexit.register(listener.stop)
# Return the listener for manual cleanup if needed
loguru.logger = patched
_logging_initialized = True
_axiom_listener = listener
return listener
else:
# No Axiom logging available
loguru.logger = patched
_logging_initialized = True
return None

View file

@ -0,0 +1 @@
librnnoise.so.0.4.1

View file

@ -0,0 +1 @@
librnnoise.so.0.4.1

Binary file not shown.

11
api/pytest.ini Normal file
View file

@ -0,0 +1,11 @@
[pytest]
asyncio_mode = strict
asyncio_default_fixture_loop_scope = function
testpaths = .
python_files = test_*.py *_test.py
python_classes = Test*
python_functions = test_*
addopts = -v --tb=short -s
markers =
asyncio: mark test as an async test
slow: mark test as slow running

5
api/requirements.dev.txt Normal file
View file

@ -0,0 +1,5 @@
mypy==1.15.0
ruff==0.11.3
pytest==8.3.5
pytest-asyncio==0.26.0
pre-commit==4.2.0

121
api/requirements.txt Normal file
View file

@ -0,0 +1,121 @@
aenum==3.1.16
aiofiles==24.1.0
aiohappyeyeballs==2.6.1
aiohttp==3.11.16
aiosignal==1.3.2
alembic==1.15.2
amqp==5.3.1
annotated-types==0.7.0
anyio==4.9.0
asyncpg==0.30.0
attrs==25.3.0
audioop-lts==0.2.1; python_version>='3.13'
av==14.2.0
backoff==2.2.1
billiard==4.2.1
certifi==2025.1.31
cffi==1.17.1
charset-normalizer==3.4.1
click==8.1.8
click-didyoumean==0.3.1
click-plugins==1.1.1
click-repl==0.3.0
colorama==0.4.6
coloredlogs==15.0.1
dataclasses-json==0.6.7
deprecation==2.1.0
distro==1.9.0
docstring_parser==0.16
eval_type_backport==0.2.2
fastapi==0.115.12
filelock==3.18.0
flatbuffers==25.2.10
frozenlist==1.5.0
fsspec==2025.3.2
future==1.0.0
greenlet==3.1.1
h11==0.14.0
httpcore==1.0.7
httpx==0.28.1
huggingface-hub==0.30.1
humanfriendly==10.0
idna==3.10
Jinja2==3.1.6
jiter==0.9.0
jmespath==1.0.1
kombu==5.5.2
langfuse==3.2.7
llvmlite==0.44.0
loguru==0.7.3
Mako==1.3.9
Markdown==3.8
MarkupSafe==3.0.2
marshmallow==3.26.1
mpmath==1.3.0
multidict==6.2.0
mypy_extensions==1.1.0
numba==0.61.2
numpy==1.26.4
onnxruntime==1.21.0
packaging==24.2
pillow==11.1.0
pipecat-ai[cartesia,deepgram,openai,elevenlabs,groq,google,azure] @ git+https://github.com/dograh-hq/pipecat.git@main
prompt_toolkit==3.0.51
propcache==0.3.1
protobuf==5.29.4
psutil==7.0.0
pycparser==2.22
pydantic==2.10.6
pydantic_core==2.27.2
PyJWT==2.10.1
pyloudnorm==0.1.1
python-dateutil==2.9.0.post0
python-dotenv==1.1.0
PyYAML==6.0.2
redis==5.2.1
regex==2024.11.6
requests==2.32.3
resampy==0.4.3
safetensors==0.5.3
scipy==1.15.2
setuptools==75.8.0
six==1.17.0
sniffio==1.3.1
sounddevice==0.5.1
soxr==0.5.0.post1
SQLAlchemy==2.0.40
starlette==0.46.1
sympy==1.13.3
tokenizers==0.21.1
tqdm==4.67.1
transformers==4.50.3
types-protobuf==4.25.0.20240417
typing-inspect==0.9.0
typing-inspection==0.4.0
typing_extensions==4.13.0
tzdata==2025.2
urllib3==2.3.0
uvicorn==0.34.0
vine==5.1.0
watchfiles==1.0.4
wcwidth==0.2.13
websockets==13.1
wheel==0.45.1
wrapt==1.17.2
yarl==1.18.3
aiortc==1.11.0
opencv-python-headless==4.11.0.86
aioboto3==14.3.0
arq==0.26.3
opentelemetry-sdk==1.33.1
opentelemetry-api==1.33.1
opentelemetry-exporter-otlp==1.33.1
torch==2.7.0
torchaudio==2.7.0
axiom-py==0.9.0
sentry-sdk[fastapi]==2.30.0
twilio==9.7.0
python-multipart==0.0.20
soundfile==0.13.1
minio==7.2.7
alembic-postgresql-enum==1.8.0

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

Some files were not shown because too many files have changed in this diff Show more