feat: refactor telephony to support multiple telephony configurations (#251)

Co-authored-by: Sabiha Khan <sabihak89@gmail.com>
This commit is contained in:
Abhishek 2026-04-29 11:39:57 +05:30 committed by GitHub
parent 2f860e7f6d
commit e16f6438bd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
101 changed files with 10906 additions and 5420 deletions

View file

@ -23,6 +23,7 @@ class CampaignClient(BaseDBClient):
max_concurrency: Optional[int] = None,
schedule_config: Optional[dict] = None,
circuit_breaker: Optional[dict] = None,
telephony_configuration_id: Optional[int] = None,
) -> CampaignModel:
"""Create a new campaign"""
async with self.async_session() as session:
@ -46,6 +47,7 @@ class CampaignClient(BaseDBClient):
if retry_config
else CampaignModel.retry_config.default.arg,
orchestrator_metadata=orchestrator_metadata,
telephony_configuration_id=telephony_configuration_id,
)
session.add(campaign)
try:

View file

@ -9,6 +9,8 @@ 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.telephony_configuration_client import TelephonyConfigurationClient
from api.db.telephony_phone_number_client import TelephonyPhoneNumberClient
from api.db.tool_client import ToolClient
from api.db.user_client import UserClient
from api.db.webhook_credential_client import WebhookCredentialClient
@ -37,6 +39,8 @@ class DBClient(
ToolClient,
KnowledgeBaseClient,
WorkflowRecordingClient,
TelephonyConfigurationClient,
TelephonyPhoneNumberClient,
):
"""
Unified database client that combines all specialized database operations.

View file

@ -30,7 +30,6 @@ from ..enums import (
ToolStatus,
TriggerState,
WebhookCredentialType,
WorkflowRunMode,
WorkflowRunState,
WorkflowStatus,
)
@ -178,6 +177,117 @@ class OrganizationConfigurationModel(Base):
)
class TelephonyConfigurationModel(Base):
__tablename__ = "telephony_configurations"
id = Column(Integer, primary_key=True, index=True)
organization_id = Column(
Integer, ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False
)
name = Column(String(64), nullable=False)
provider = Column(String(32), nullable=False)
credentials = Column(JSON, nullable=False, default=dict)
is_default_outbound = Column(
Boolean, nullable=False, default=False, server_default=text("false")
)
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),
)
organization = relationship("OrganizationModel")
phone_numbers = relationship(
"TelephonyPhoneNumberModel",
back_populates="configuration",
cascade="all, delete-orphan",
)
__table_args__ = (
UniqueConstraint(
"organization_id", "name", name="uq_telephony_configurations_org_name"
),
Index("ix_telephony_configurations_org", "organization_id"),
Index(
"uq_telephony_configurations_default",
"organization_id",
unique=True,
postgresql_where=text("is_default_outbound = true"),
),
)
class TelephonyPhoneNumberModel(Base):
__tablename__ = "telephony_phone_numbers"
id = Column(Integer, primary_key=True, index=True)
organization_id = Column(
Integer, ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False
)
telephony_configuration_id = Column(
Integer,
ForeignKey("telephony_configurations.id", ondelete="CASCADE"),
nullable=False,
)
address = Column(String(255), nullable=False)
address_normalized = Column(String(255), nullable=False)
address_type = Column(String(16), nullable=False)
country_code = Column(String(2), nullable=True)
label = Column(String(64), nullable=True)
inbound_workflow_id = Column(
Integer,
ForeignKey("workflows.id", ondelete="SET NULL"),
nullable=True,
)
is_active = Column(
Boolean, nullable=False, default=True, server_default=text("true")
)
is_default_caller_id = Column(
Boolean, nullable=False, default=False, server_default=text("false")
)
extra_metadata = Column(
JSON, nullable=False, default=dict, server_default=text("'{}'::json")
)
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),
)
configuration = relationship(
"TelephonyConfigurationModel", back_populates="phone_numbers"
)
inbound_workflow = relationship("WorkflowModel")
__table_args__ = (
UniqueConstraint(
"organization_id",
"address_normalized",
name="uq_phone_numbers_org_address",
),
Index("ix_phone_numbers_config", "telephony_configuration_id"),
Index(
"ix_phone_numbers_workflow",
"inbound_workflow_id",
postgresql_where=text("inbound_workflow_id IS NOT NULL"),
),
Index(
"ix_phone_numbers_inbound_lookup",
"address_normalized",
"organization_id",
postgresql_where=text("is_active = true"),
),
Index(
"uq_phone_numbers_default_caller",
"telephony_configuration_id",
unique=True,
postgresql_where=text("is_default_caller_id = true"),
),
)
class IntegrationModel(Base):
__tablename__ = "integrations"
@ -334,10 +444,10 @@ class WorkflowRunModel(Base):
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,
)
# Stored as VARCHAR (not a Postgres ENUM) so new telephony providers can
# be added purely in application code without a database migration.
# See WorkflowRunMode in api/enums.py for the canonical value set.
mode = Column(String(64), nullable=False)
call_type = Column(
Enum(*[call_type.value for call_type in CallType], name="workflow_call_type"),
nullable=False,
@ -519,6 +629,11 @@ class CampaignModel(Base):
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)
# Nullable during the legacy → multi-config migration window. Backfilled to the
# org's default config by the migration; will become NOT NULL in a follow-up.
telephony_configuration_id = Column(
Integer, ForeignKey("telephony_configurations.id"), nullable=True
)
# Source configuration
source_type = Column(String, nullable=False, default="google-sheet")
@ -588,6 +703,11 @@ class CampaignModel(Base):
Index("ix_campaigns_org_id", "organization_id"),
Index("ix_campaigns_state", "state"),
Index("ix_campaigns_workflow_id", "workflow_id"),
Index(
"ix_campaigns_telephony_config",
"telephony_configuration_id",
postgresql_where=text("telephony_configuration_id IS NOT NULL"),
),
# Index for efficient querying of active campaigns
Index(
"idx_campaigns_active_status",

View file

@ -0,0 +1,270 @@
"""Database access for telephony configurations.
Each row represents one provider account that an organization has connected
(e.g. "Twilio US prod", "Vobiz IN sandbox"). Replaces the single-row-per-org
``OrganizationConfiguration(TELEPHONY_CONFIGURATION)`` storage.
"""
from typing import Any, Dict, List, Optional
from sqlalchemy import update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.future import select
from api.db.base_client import BaseDBClient
from api.db.models import CampaignModel, TelephonyConfigurationModel
class TelephonyConfigurationDuplicateAccountError(Exception):
"""Raised when saving a config whose account_id collides with an existing
config of the same provider in the same organization."""
class TelephonyConfigurationInUseError(Exception):
"""Raised when deleting a config that is still referenced by a campaign."""
class TelephonyConfigurationClient(BaseDBClient):
async def list_telephony_configurations(
self, organization_id: int
) -> List[TelephonyConfigurationModel]:
async with self.async_session() as session:
result = await session.execute(
select(TelephonyConfigurationModel)
.where(TelephonyConfigurationModel.organization_id == organization_id)
.order_by(TelephonyConfigurationModel.created_at)
)
return list(result.scalars().all())
async def get_telephony_configuration(
self, config_id: int
) -> Optional[TelephonyConfigurationModel]:
async with self.async_session() as session:
return await session.get(TelephonyConfigurationModel, config_id)
async def get_telephony_configuration_for_org(
self, config_id: int, organization_id: int
) -> Optional[TelephonyConfigurationModel]:
"""Lookup scoped to an org — used to authorize per-org access."""
async with self.async_session() as session:
result = await session.execute(
select(TelephonyConfigurationModel).where(
TelephonyConfigurationModel.id == config_id,
TelephonyConfigurationModel.organization_id == organization_id,
)
)
return result.scalars().first()
async def get_default_telephony_configuration(
self, organization_id: int
) -> Optional[TelephonyConfigurationModel]:
async with self.async_session() as session:
result = await session.execute(
select(TelephonyConfigurationModel).where(
TelephonyConfigurationModel.organization_id == organization_id,
TelephonyConfigurationModel.is_default_outbound.is_(True),
)
)
return result.scalars().first()
async def find_telephony_config_by_account(
self, provider: str, account_id_field: str, account_id: str
) -> Optional[TelephonyConfigurationModel]:
"""Global lookup used by the workflow-agnostic inbound dispatcher.
Returns the single config whose stored credentials contain
``credentials[account_id_field] == account_id``. Filters in Python
over the per-provider candidate set since credentials is JSON.
"""
if not account_id_field or not account_id:
return None
async with self.async_session() as session:
result = await session.execute(
select(TelephonyConfigurationModel).where(
TelephonyConfigurationModel.provider == provider,
)
)
for cand in result.scalars().all():
stored = (cand.credentials or {}).get(account_id_field)
if stored and stored == account_id:
return cand
return None
async def list_telephony_configurations_by_provider(
self, organization_id: int, provider: str
) -> List[TelephonyConfigurationModel]:
"""Used by inbound matching to enumerate candidates of a given provider."""
async with self.async_session() as session:
result = await session.execute(
select(TelephonyConfigurationModel).where(
TelephonyConfigurationModel.organization_id == organization_id,
TelephonyConfigurationModel.provider == provider,
)
)
return list(result.scalars().all())
async def list_all_telephony_configurations_by_provider(
self, provider: str
) -> List[TelephonyConfigurationModel]:
"""List configs of a given provider across every organization.
Used by background workers like the ARI manager that maintain
long-lived connections per config row, independent of any one org.
"""
async with self.async_session() as session:
result = await session.execute(
select(TelephonyConfigurationModel).where(
TelephonyConfigurationModel.provider == provider,
)
)
return list(result.scalars().all())
async def create_telephony_configuration(
self,
organization_id: int,
name: str,
provider: str,
credentials: Dict[str, Any],
is_default_outbound: bool = False,
account_id_credential_field: Optional[str] = None,
) -> TelephonyConfigurationModel:
"""Create a new config. Raises ``TelephonyConfigurationDuplicateAccountError``
if the same provider+account_id is already configured for the org."""
if account_id_credential_field:
await self._guard_duplicate_account(
organization_id,
provider,
credentials.get(account_id_credential_field),
account_id_credential_field,
exclude_id=None,
)
async with self.async_session() as session:
if is_default_outbound:
await self._clear_default_outbound(session, organization_id)
row = TelephonyConfigurationModel(
organization_id=organization_id,
name=name,
provider=provider,
credentials=credentials,
is_default_outbound=is_default_outbound,
)
session.add(row)
try:
await session.commit()
except IntegrityError as e:
await session.rollback()
raise e
await session.refresh(row)
return row
async def update_telephony_configuration(
self,
config_id: int,
organization_id: int,
name: Optional[str] = None,
credentials: Optional[Dict[str, Any]] = None,
account_id_credential_field: Optional[str] = None,
) -> Optional[TelephonyConfigurationModel]:
async with self.async_session() as session:
row = await session.get(TelephonyConfigurationModel, config_id)
if not row or row.organization_id != organization_id:
return None
if credentials is not None and account_id_credential_field:
await self._guard_duplicate_account(
organization_id,
row.provider,
credentials.get(account_id_credential_field),
account_id_credential_field,
exclude_id=config_id,
)
if name is not None:
row.name = name
if credentials is not None:
row.credentials = credentials
try:
await session.commit()
except IntegrityError as e:
await session.rollback()
raise e
await session.refresh(row)
return row
async def set_default_telephony_configuration(
self, config_id: int, organization_id: int
) -> Optional[TelephonyConfigurationModel]:
"""Mark this config as the org's default outbound, clearing any other default."""
async with self.async_session() as session:
row = await session.get(TelephonyConfigurationModel, config_id)
if not row or row.organization_id != organization_id:
return None
await self._clear_default_outbound(session, organization_id)
row.is_default_outbound = True
await session.commit()
await session.refresh(row)
return row
async def delete_telephony_configuration(
self, config_id: int, organization_id: int
) -> bool:
async with self.async_session() as session:
row = await session.get(TelephonyConfigurationModel, config_id)
if not row or row.organization_id != organization_id:
return False
campaign_ref = await session.execute(
select(CampaignModel.id)
.where(CampaignModel.telephony_configuration_id == config_id)
.limit(1)
)
if campaign_ref.first():
raise TelephonyConfigurationInUseError(
f"Telephony configuration {config_id} is referenced by one or "
f"more campaigns and cannot be deleted."
)
await session.delete(row)
await session.commit()
return True
async def _guard_duplicate_account(
self,
organization_id: int,
provider: str,
account_id: Optional[str],
credential_field: str,
exclude_id: Optional[int],
) -> None:
if not account_id:
return
async with self.async_session() as session:
result = await session.execute(
select(TelephonyConfigurationModel).where(
TelephonyConfigurationModel.organization_id == organization_id,
TelephonyConfigurationModel.provider == provider,
)
)
for row in result.scalars().all():
if exclude_id is not None and row.id == exclude_id:
continue
stored = (row.credentials or {}).get(credential_field)
if stored and stored == account_id:
raise TelephonyConfigurationDuplicateAccountError(
f"A {provider} configuration with this account is already "
f"registered (config id {row.id})."
)
@staticmethod
async def _clear_default_outbound(session, organization_id: int) -> None:
await session.execute(
update(TelephonyConfigurationModel)
.where(
TelephonyConfigurationModel.organization_id == organization_id,
TelephonyConfigurationModel.is_default_outbound.is_(True),
)
.values(is_default_outbound=False)
)

View file

@ -0,0 +1,250 @@
"""Database access for telephony phone numbers.
Phone numbers are first-class entities (PSTN, SIP URI, or SIP extension)
owned by a telephony configuration. They power both outbound caller-ID
selection and inbound call routing.
"""
from typing import Any, Dict, List, Optional, Tuple
from sqlalchemy import update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.future import select
from api.db.base_client import BaseDBClient
from api.db.models import (
TelephonyConfigurationModel,
TelephonyPhoneNumberModel,
WorkflowModel,
)
from api.utils.telephony_address import normalize_telephony_address
class TelephonyPhoneNumberClient(BaseDBClient):
async def list_phone_numbers_for_config(
self, telephony_configuration_id: int
) -> List[TelephonyPhoneNumberModel]:
async with self.async_session() as session:
result = await session.execute(
select(TelephonyPhoneNumberModel)
.where(
TelephonyPhoneNumberModel.telephony_configuration_id
== telephony_configuration_id
)
.order_by(TelephonyPhoneNumberModel.created_at)
)
return list(result.scalars().all())
async def list_phone_numbers_with_workflow_name_for_config(
self, telephony_configuration_id: int
) -> List[Tuple[TelephonyPhoneNumberModel, Optional[str]]]:
"""Same as :meth:`list_phone_numbers_for_config` but also returns the
inbound workflow's display name (or None) for each row, fetched via a
single LEFT JOIN so we don't load entire workflow rows."""
async with self.async_session() as session:
result = await session.execute(
select(TelephonyPhoneNumberModel, WorkflowModel.name)
.join(
WorkflowModel,
WorkflowModel.id == TelephonyPhoneNumberModel.inbound_workflow_id,
isouter=True,
)
.where(
TelephonyPhoneNumberModel.telephony_configuration_id
== telephony_configuration_id
)
.order_by(TelephonyPhoneNumberModel.created_at)
)
return [(row, name) for row, name in result.all()]
async def list_active_normalized_addresses_for_config(
self, telephony_configuration_id: int
) -> List[str]:
"""Active phone numbers as canonical address strings (E.164 for PSTN,
normalized SIP otherwise) the shape providers want in their
``from_numbers`` list for caller-ID and rate-limit pool keys."""
async with self.async_session() as session:
result = await session.execute(
select(TelephonyPhoneNumberModel.address_normalized)
.where(
TelephonyPhoneNumberModel.telephony_configuration_id
== telephony_configuration_id,
TelephonyPhoneNumberModel.is_active.is_(True),
)
.order_by(TelephonyPhoneNumberModel.created_at)
)
return [row[0] for row in result.all()]
async def get_phone_number(
self, phone_number_id: int
) -> Optional[TelephonyPhoneNumberModel]:
async with self.async_session() as session:
return await session.get(TelephonyPhoneNumberModel, phone_number_id)
async def get_phone_number_for_config(
self, phone_number_id: int, telephony_configuration_id: int
) -> Optional[TelephonyPhoneNumberModel]:
async with self.async_session() as session:
result = await session.execute(
select(TelephonyPhoneNumberModel).where(
TelephonyPhoneNumberModel.id == phone_number_id,
TelephonyPhoneNumberModel.telephony_configuration_id
== telephony_configuration_id,
)
)
return result.scalars().first()
async def find_active_phone_number_for_inbound(
self,
organization_id: int,
address: str,
provider: str,
country_hint: Optional[str] = None,
) -> Optional[TelephonyPhoneNumberModel]:
"""Inbound routing primary lookup: normalize the called address and find
the matching active row whose config is for the detected provider."""
normalized = normalize_telephony_address(address, country_hint=country_hint)
async with self.async_session() as session:
result = await session.execute(
select(TelephonyPhoneNumberModel)
.join(
TelephonyConfigurationModel,
TelephonyConfigurationModel.id
== TelephonyPhoneNumberModel.telephony_configuration_id,
)
.where(
TelephonyPhoneNumberModel.organization_id == organization_id,
TelephonyPhoneNumberModel.address_normalized
== normalized.canonical,
TelephonyPhoneNumberModel.is_active.is_(True),
TelephonyConfigurationModel.provider == provider,
)
)
return result.scalars().first()
async def create_phone_number(
self,
organization_id: int,
telephony_configuration_id: int,
address: str,
country_code: Optional[str] = None,
label: Optional[str] = None,
inbound_workflow_id: Optional[int] = None,
is_active: bool = True,
is_default_caller_id: bool = False,
extra_metadata: Optional[Dict[str, Any]] = None,
) -> TelephonyPhoneNumberModel:
normalized = normalize_telephony_address(address, country_hint=country_code)
async with self.async_session() as session:
if is_default_caller_id:
await self._clear_default_caller_id(session, telephony_configuration_id)
row = TelephonyPhoneNumberModel(
organization_id=organization_id,
telephony_configuration_id=telephony_configuration_id,
address=address,
address_normalized=normalized.canonical,
address_type=normalized.address_type,
country_code=country_code or normalized.country_code,
label=label,
inbound_workflow_id=inbound_workflow_id,
is_active=is_active,
is_default_caller_id=is_default_caller_id,
extra_metadata=extra_metadata or {},
)
session.add(row)
try:
await session.commit()
except IntegrityError as e:
await session.rollback()
raise e
await session.refresh(row)
return row
async def update_phone_number(
self,
phone_number_id: int,
telephony_configuration_id: int,
label: Optional[str] = None,
inbound_workflow_id: Optional[int] = None,
is_active: Optional[bool] = None,
country_code: Optional[str] = None,
extra_metadata: Optional[Dict[str, Any]] = None,
clear_inbound_workflow: bool = False,
) -> Optional[TelephonyPhoneNumberModel]:
"""Partial update. ``address`` is intentionally immutable — create a new
row instead. Set ``clear_inbound_workflow=True`` to null out the FK."""
async with self.async_session() as session:
row = await session.get(TelephonyPhoneNumberModel, phone_number_id)
if not row or row.telephony_configuration_id != telephony_configuration_id:
return None
if label is not None:
row.label = label
if inbound_workflow_id is not None:
row.inbound_workflow_id = inbound_workflow_id
elif clear_inbound_workflow:
row.inbound_workflow_id = None
if is_active is not None:
row.is_active = is_active
if country_code is not None:
row.country_code = country_code
if extra_metadata is not None:
row.extra_metadata = extra_metadata
await session.commit()
await session.refresh(row)
return row
async def set_default_caller_id(
self, phone_number_id: int, telephony_configuration_id: int
) -> Optional[TelephonyPhoneNumberModel]:
async with self.async_session() as session:
row = await session.get(TelephonyPhoneNumberModel, phone_number_id)
if not row or row.telephony_configuration_id != telephony_configuration_id:
return None
await self._clear_default_caller_id(session, telephony_configuration_id)
row.is_default_caller_id = True
await session.commit()
await session.refresh(row)
return row
async def get_default_caller_id(
self, telephony_configuration_id: int
) -> Optional[TelephonyPhoneNumberModel]:
async with self.async_session() as session:
result = await session.execute(
select(TelephonyPhoneNumberModel).where(
TelephonyPhoneNumberModel.telephony_configuration_id
== telephony_configuration_id,
TelephonyPhoneNumberModel.is_default_caller_id.is_(True),
)
)
return result.scalars().first()
async def delete_phone_number(
self, phone_number_id: int, telephony_configuration_id: int
) -> bool:
async with self.async_session() as session:
row = await session.get(TelephonyPhoneNumberModel, phone_number_id)
if not row or row.telephony_configuration_id != telephony_configuration_id:
return False
await session.delete(row)
await session.commit()
return True
@staticmethod
async def _clear_default_caller_id(
session, telephony_configuration_id: int
) -> None:
await session.execute(
update(TelephonyPhoneNumberModel)
.where(
TelephonyPhoneNumberModel.telephony_configuration_id
== telephony_configuration_id,
TelephonyPhoneNumberModel.is_default_caller_id.is_(True),
)
.values(is_default_caller_id=False)
)