mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-25 08:48:13 +02:00
feat: refactor telephony to support multiple telephony configurations (#251)
Co-authored-by: Sabiha Khan <sabihak89@gmail.com>
This commit is contained in:
parent
2f860e7f6d
commit
e16f6438bd
101 changed files with 10906 additions and 5420 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
130
api/db/models.py
130
api/db/models.py
|
|
@ -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",
|
||||
|
|
|
|||
270
api/db/telephony_configuration_client.py
Normal file
270
api/db/telephony_configuration_client.py
Normal 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)
|
||||
)
|
||||
250
api/db/telephony_phone_number_client.py
Normal file
250
api/db/telephony_phone_number_client.py
Normal 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)
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue