"""Communication platform domain models — Phase 8.""" from __future__ import annotations import uuid from datetime import datetime from decimal import Decimal from sqlalchemy import ( Boolean, DateTime, Index, Integer, Numeric, String, Text, UniqueConstraint, ) from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.types import JSON from app.core.database import Base from app.models.base import ( ActorAuditMixin, SoftDeleteMixin, TenantMixin, TimestampMixin, UUIDPrimaryKeyMixin, ) from app.models.types import GUID class ProviderConfig( Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin, SoftDeleteMixin, ActorAuditMixin, ): """Tenant-specific communication provider credentials and routing metadata.""" __tablename__ = "provider_configs" __table_args__ = ( Index("ix_provider_configs_tenant_channel", "tenant_id", "channel"), Index("ix_provider_configs_tenant_priority", "tenant_id", "priority"), ) name: Mapped[str] = mapped_column(String(120), nullable=False) provider_kind: Mapped[str] = mapped_column(String(40), nullable=False) channel: Mapped[str] = mapped_column(String(40), nullable=False) status: Mapped[str] = mapped_column(String(40), nullable=False, default="active") priority: Mapped[int] = mapped_column(Integer, nullable=False, default=100) credentials: Mapped[dict | None] = mapped_column(JSON, nullable=True) settings: Mapped[dict | None] = mapped_column(JSON, nullable=True) balance: Mapped[Decimal | None] = mapped_column(Numeric(18, 4), nullable=True) rate_limit_per_minute: Mapped[int | None] = mapped_column(Integer, nullable=True) max_retries: Mapped[int] = mapped_column(Integer, nullable=False, default=3) circuit_state: Mapped[str] = mapped_column( String(20), nullable=False, default="closed" ) circuit_opened_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), nullable=True ) consecutive_failures: Mapped[int] = mapped_column(Integer, nullable=False, default=0) last_health_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), nullable=True ) last_error: Mapped[str | None] = mapped_column(Text, nullable=True) is_default: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) class SenderNumber( Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin, SoftDeleteMixin, ActorAuditMixin, ): __tablename__ = "sender_numbers" __table_args__ = ( Index("ix_sender_numbers_tenant_channel", "tenant_id", "channel"), # App layer enforces uniqueness among non-deleted rows; DB unique is tenant+channel+value. UniqueConstraint( "tenant_id", "channel", "value", name="uq_sender_numbers_tenant_channel_value" ), ) channel: Mapped[str] = mapped_column(String(40), nullable=False) value: Mapped[str] = mapped_column(String(64), nullable=False) label: Mapped[str | None] = mapped_column(String(120), nullable=True) provider_config_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) is_default: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) class MessageTemplate( Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin, SoftDeleteMixin, ActorAuditMixin, ): __tablename__ = "message_templates" __table_args__ = ( Index("ix_message_templates_tenant_key", "tenant_id", "template_key"), UniqueConstraint( "tenant_id", "template_key", "locale", "version", name="uq_message_templates_key_locale_version", ), ) template_key: Mapped[str] = mapped_column(String(120), nullable=False) name: Mapped[str] = mapped_column(String(200), nullable=False) channel: Mapped[str] = mapped_column(String(40), nullable=False) locale: Mapped[str] = mapped_column(String(16), nullable=False, default="fa") version: Mapped[int] = mapped_column(Integer, nullable=False, default=1) status: Mapped[str] = mapped_column(String(40), nullable=False, default="draft") body: Mapped[str] = mapped_column(Text, nullable=False) subject: Mapped[str | None] = mapped_column(String(300), nullable=True) variables: Mapped[list | None] = mapped_column(JSON, nullable=True) approved_by: Mapped[str | None] = mapped_column(String(100), nullable=True) approved_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), nullable=True ) rejection_reason: Mapped[str | None] = mapped_column(Text, nullable=True) class ManualContact( Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin, SoftDeleteMixin, ActorAuditMixin, ): """Contacts owned by Communication only — no business-module data duplication.""" __tablename__ = "manual_contacts" __table_args__ = ( Index("ix_manual_contacts_tenant_phone", "tenant_id", "phone"), Index("ix_manual_contacts_tenant_email", "tenant_id", "email"), ) display_name: Mapped[str | None] = mapped_column(String(200), nullable=True) phone: Mapped[str | None] = mapped_column(String(32), nullable=True) email: Mapped[str | None] = mapped_column(String(255), nullable=True) push_token: Mapped[str | None] = mapped_column(String(512), nullable=True) metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) tags: Mapped[list | None] = mapped_column(JSON, nullable=True) class ContactSource( Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin, SoftDeleteMixin, ActorAuditMixin, ): """Dynamic contact source configuration — resolves contacts via API, never copies data.""" __tablename__ = "contact_sources" __table_args__ = ( Index("ix_contact_sources_tenant_type", "tenant_id", "source_type"), ) name: Mapped[str] = mapped_column(String(120), nullable=False) source_type: Mapped[str] = mapped_column(String(40), nullable=False) base_url: Mapped[str | None] = mapped_column(String(500), nullable=True) endpoint_path: Mapped[str | None] = mapped_column(String(300), nullable=True) auth_config: Mapped[dict | None] = mapped_column(JSON, nullable=True) query_mapping: Mapped[dict | None] = mapped_column(JSON, nullable=True) field_mapping: Mapped[dict | None] = mapped_column(JSON, nullable=True) is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) class Message( Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin, SoftDeleteMixin, ActorAuditMixin, ): __tablename__ = "messages" __table_args__ = ( Index("ix_messages_tenant_status", "tenant_id", "status"), Index("ix_messages_tenant_channel", "tenant_id", "channel"), Index("ix_messages_tenant_created", "tenant_id", "created_at"), Index("ix_messages_tenant_correlation", "tenant_id", "correlation_id"), Index("ix_messages_tenant_provider_msg", "tenant_id", "provider_message_id"), UniqueConstraint( "tenant_id", "correlation_id", "to_address", name="uq_messages_tenant_correlation_to", ), ) channel: Mapped[str] = mapped_column(String(40), nullable=False) status: Mapped[str] = mapped_column(String(40), nullable=False, default="queued") to_address: Mapped[str] = mapped_column(String(255), nullable=False) from_address: Mapped[str | None] = mapped_column(String(255), nullable=True) subject: Mapped[str | None] = mapped_column(String(300), nullable=True) body: Mapped[str] = mapped_column(Text, nullable=False) template_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) template_key: Mapped[str | None] = mapped_column(String(120), nullable=True) template_variables: Mapped[dict | None] = mapped_column(JSON, nullable=True) provider_config_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) provider_message_id: Mapped[str | None] = mapped_column(String(200), nullable=True) priority: Mapped[str] = mapped_column(String(20), nullable=False, default="normal") scheduled_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), nullable=True ) sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) delivered_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), nullable=True ) failed_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), nullable=True ) expires_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), nullable=True ) error_code: Mapped[str | None] = mapped_column(String(80), nullable=True) error_message: Mapped[str | None] = mapped_column(Text, nullable=True) retry_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) correlation_id: Mapped[str | None] = mapped_column(String(100), nullable=True) source_module: Mapped[str | None] = mapped_column(String(80), nullable=True) class QueueItem( Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin, ): """Append-only queue row — soft delete intentionally omitted.""" __tablename__ = "queue_items" __table_args__ = ( Index("ix_queue_items_tenant_status", "tenant_id", "status"), Index("ix_queue_items_priority_available", "priority", "available_at"), ) message_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False, index=True) status: Mapped[str] = mapped_column(String(40), nullable=False, default="pending") priority: Mapped[str] = mapped_column(String(20), nullable=False, default="normal") attempt: Mapped[int] = mapped_column(Integer, nullable=False, default=0) max_attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=3) available_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False ) locked_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), nullable=True ) completed_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), nullable=True ) last_error: Mapped[str | None] = mapped_column(Text, nullable=True) batch_id: Mapped[str | None] = mapped_column(String(100), nullable=True) is_dead_letter: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) class DeliveryEvent( Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin, ): """Immutable delivery timeline events for a message.""" __tablename__ = "delivery_events" __table_args__ = ( Index("ix_delivery_events_message", "tenant_id", "message_id", "created_at"), ) message_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) status: Mapped[str] = mapped_column(String(40), nullable=False) provider_config_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) detail: Mapped[str | None] = mapped_column(Text, nullable=True) payload: Mapped[dict | None] = mapped_column(JSON, nullable=True) class ProviderLog( Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin, ): """Append-only provider I/O log — soft delete intentionally omitted.""" __tablename__ = "provider_logs" __table_args__ = ( Index("ix_provider_logs_tenant_provider", "tenant_id", "provider_config_id"), Index("ix_provider_logs_tenant_created", "tenant_id", "created_at"), ) provider_config_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) message_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) request_payload: Mapped[dict | None] = mapped_column(JSON, nullable=True) response_payload: Mapped[dict | None] = mapped_column(JSON, nullable=True) success: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) latency_ms: Mapped[int | None] = mapped_column(Integer, nullable=True) error_message: Mapped[str | None] = mapped_column(Text, nullable=True) class OTPChallenge( Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin, ): """Business/platform OTP challenges — independent from Core auth OTP.""" __tablename__ = "otp_challenges" __table_args__ = ( Index("ix_otp_challenges_tenant_destination", "tenant_id", "destination"), Index("ix_otp_challenges_tenant_status", "tenant_id", "status"), ) channel: Mapped[str] = mapped_column(String(40), nullable=False, default="sms") destination: Mapped[str] = mapped_column(String(255), nullable=False) code_hash: Mapped[str] = mapped_column(String(128), nullable=False) status: Mapped[str] = mapped_column(String(40), nullable=False, default="pending") attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=0) max_attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=5) expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) verified_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), nullable=True ) message_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) purpose: Mapped[str | None] = mapped_column(String(80), nullable=True) metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) class WebhookReceipt( Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin, ): """Append-only inbound webhook receipt — soft delete intentionally omitted.""" __tablename__ = "webhook_receipts" __table_args__ = ( Index("ix_webhook_receipts_tenant_provider", "tenant_id", "provider_kind"), Index( "ix_webhook_receipts_tenant_external", "tenant_id", "provider_kind", "external_id", ), UniqueConstraint( "tenant_id", "provider_kind", "external_id", name="uq_webhook_receipts_tenant_provider_external", ), ) provider_kind: Mapped[str] = mapped_column(String(40), nullable=False) provider_config_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) event_type: Mapped[str] = mapped_column(String(80), nullable=False) external_id: Mapped[str | None] = mapped_column(String(200), nullable=True) message_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) payload: Mapped[dict | None] = mapped_column(JSON, nullable=True) signature_valid: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) processed: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) processing_error: Mapped[str | None] = mapped_column(Text, nullable=True) class CommunicationAuditLog( Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin, ): """Append-only audit trail — soft delete intentionally omitted.""" __tablename__ = "communication_audit_logs" __table_args__ = ( Index("ix_comm_audit_tenant_entity", "tenant_id", "entity_type", "entity_id"), ) entity_type: Mapped[str] = mapped_column(String(80), nullable=False) entity_id: Mapped[str] = mapped_column(String(64), nullable=False) action: Mapped[str] = mapped_column(String(40), nullable=False) actor_id: Mapped[str | None] = mapped_column(String(100), nullable=True) changes: Mapped[dict | None] = mapped_column(JSON, nullable=True) detail: Mapped[str | None] = mapped_column(Text, nullable=True)