""" Database connection and session management. PostgreSQL via asyncpg + SQLAlchemy async. """ from collections.abc import AsyncIterator from contextlib import asynccontextmanager from pathlib import Path from sqlalchemy import ( JSON, Boolean, Column, DateTime, Float, ForeignKey, Integer, String, Text, func, ) from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from sqlalchemy.orm import DeclarativeBase from config import get_settings class Base(DeclarativeBase): """SQLAlchemy declarative base for all ORM models.""" pass # ============================================================ # ORM Models # ============================================================ class CallRecord(Base): __tablename__ = "call_records" id = Column(String, primary_key=True) direction = Column(String, nullable=False) # inbound / outbound remote_number = Column(String, index=True, nullable=False) status = Column(String, nullable=False) # completed / missed / failed / active / on_hold mode = Column(String, nullable=False) # direct / hold_slayer / ai_assisted intent = Column(Text) # What the user wanted (for hold_slayer) started_at = Column(DateTime, default=func.now()) ended_at = Column(DateTime, nullable=True) duration = Column(Integer, default=0) # seconds hold_time = Column(Integer, default=0) # seconds spent on hold device_used = Column(String) recording_path = Column(String, nullable=True) summary = Column(Text, nullable=True) action_items = Column(JSON, nullable=True) sentiment = Column(String, nullable=True) call_flow_id = Column(String, nullable=True) # which flow was used classification_timeline = Column(JSON, nullable=True) # [{time, type, confidence}, ...] metadata_ = Column("metadata", JSON, nullable=True) def __repr__(self) -> str: return f"" class StoredCallFlow(Base): __tablename__ = "call_flows" id = Column(String, primary_key=True) name = Column(String, nullable=False) phone_number = Column(String, index=True, nullable=False) description = Column(Text) steps = Column(JSON, nullable=False) # Serialized list[CallFlowStep] last_verified = Column(DateTime, nullable=True) avg_hold_time = Column(Integer, nullable=True) success_rate = Column(Float, nullable=True) times_used = Column(Integer, default=0) last_used = Column(DateTime, nullable=True) notes = Column(Text, nullable=True) tags = Column(JSON, default=list) created_at = Column(DateTime, default=func.now()) updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) def __repr__(self) -> str: return f"" class Device(Base): __tablename__ = "devices" id = Column(String, primary_key=True) name = Column(String, nullable=False) # "Office SIP Phone" type = Column(String, nullable=False) # sip_phone / cell / tablet / softphone sip_uri = Column(String, nullable=True) # sip:robert@gateway.helu.ca phone_number = Column(String, nullable=True) # For PSTN devices priority = Column(Integer, default=10) # Routing priority (lower = higher priority) is_online = Column(Boolean, default=False, nullable=False) capabilities = Column(JSON, default=list) # ["voice", "video", "sms"] dnd = Column(Boolean, default=False, nullable=False) last_seen = Column(DateTime, nullable=True) created_at = Column(DateTime, default=func.now()) updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) def __repr__(self) -> str: return f"" class RoutingRuleRecord(Base): __tablename__ = "routing_rules" id = Column(String, primary_key=True) name = Column(String, nullable=False) priority = Column(Integer, default=100, nullable=False) # lower runs first enabled = Column(Boolean, default=True, nullable=False) match = Column(JSON, nullable=False) # caller_pattern, dnis, time_range, days action = Column(JSON, nullable=False) # {type, ...} created_at = Column(DateTime, default=func.now()) updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) def __repr__(self) -> str: return f"" class TranscriptChunk(Base): __tablename__ = "transcript_chunks" id = Column(String, primary_key=True) call_id = Column(String, index=True, nullable=False) seq = Column(Integer, nullable=False) t_offset_ms = Column(Integer, default=0) # offset from call start speaker = Column(String, default="unknown") # caller / agent / receptionist / unknown text = Column(Text, nullable=False) confidence = Column(Float, nullable=True) created_at = Column(DateTime, default=func.now()) def __repr__(self) -> str: return f"" class RecordingRecord(Base): __tablename__ = "recordings" id = Column(String, primary_key=True) call_id = Column(String, index=True, nullable=False) path = Column(String, nullable=False) format = Column(String, default="wav") duration_s = Column(Float, default=0.0) size_bytes = Column(Integer, default=0) channels = Column(Integer, default=1) started_at = Column(DateTime, default=func.now()) ended_at = Column(DateTime, nullable=True) def __repr__(self) -> str: return f"" class User(Base): """An SSO-provisioned identity. The gateway is owner-only: the single owner is the user whose `name` matches settings.owner_name; everyone else is created on first login but reaches nothing (403 on every surface).""" __tablename__ = "users" id = Column(String, primary_key=True) # uuid4().hex, set in Python name = Column(String, nullable=False) # Casdoor username — owner-match key display_name = Column(String, nullable=True) # Casdoor display name (UI only) email = Column(String, nullable=True, unique=True) casdoor_sub = Column(String, nullable=True, unique=True) # OIDC subject claim created_at = Column(DateTime, default=func.now()) updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) def __repr__(self) -> str: return f"" class PersonalAccessToken(Base): """Long-lived bearer token for API/MCP clients (Claude Desktop, Cline) that can't refresh a JWT. Plaintext is shown once at creation; only the SHA-256 hash is persisted. Soft-revoked by setting revoked_at.""" __tablename__ = "personal_access_tokens" id = Column(String, primary_key=True) # uuid4().hex user_id = Column( String, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True ) name = Column(String, nullable=False) token_hash = Column(String, nullable=False, unique=True, index=True) token_prefix = Column(String, nullable=False) # for display, not a secret created_at = Column(DateTime, default=func.now()) last_used_at = Column(DateTime, nullable=True) expires_at = Column(DateTime, nullable=True) revoked_at = Column(DateTime, nullable=True) def __repr__(self) -> str: return f"" # ============================================================ # Engine & Session # ============================================================ _engine = None _session_factory = None def get_engine(): """Get or create the async engine.""" global _engine if _engine is None: settings = get_settings() _engine = create_async_engine( settings.database_url, echo=settings.debug, pool_size=10, max_overflow=20, ) return _engine def get_session_factory() -> async_sessionmaker[AsyncSession]: """Get or create the session factory.""" global _session_factory if _session_factory is None: _session_factory = async_sessionmaker( get_engine(), class_=AsyncSession, expire_on_commit=False, ) return _session_factory @asynccontextmanager async def session_scope() -> AsyncIterator[AsyncSession]: """A commit-on-success session — the one session-lifecycle convention. REST handlers get it via the get_db dependency; services and MCP tools use it directly. """ factory = get_session_factory() async with factory() as session: try: yield session await session.commit() except Exception: await session.rollback() raise async def get_db() -> AsyncIterator[AsyncSession]: """FastAPI dependency: yield an async database session.""" async with session_scope() as session: yield session # The autogenerated baseline revision — a schema created by the old # create_all path is identical to it, so such databases are stamped # here and then migrated forward like any other. _BASELINE_REVISION = "1173a71329ed" def _upgrade_to_head(connection) -> None: from alembic import command from alembic.config import Config from sqlalchemy import inspect cfg = Config(str(Path(__file__).resolve().parent.parent / "alembic.ini")) cfg.attributes["connection"] = connection inspector = inspect(connection) if not inspector.has_table("alembic_version") and inspector.has_table("call_records"): command.stamp(cfg, _BASELINE_REVISION) command.upgrade(cfg, "head") async def init_db(): """Bring the schema to Alembic head (tests create tables directly).""" engine = get_engine() async with engine.begin() as conn: await conn.run_sync(_upgrade_to_head) async def close_db(): """Close the database engine.""" global _engine, _session_factory if _engine is not None: await _engine.dispose() _engine = None _session_factory = None