TorbatYar/backend/services/communication/app/models/foundation.py
Mortezakoohjani e41ecfad4c Sync platform docs, infra, and module services with Accounting integration.
Include Loyalty/Communication/Sports Center backends and registry updates alongside production nginx and compose wiring.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-25 22:35:23 +03:30

398 lines
16 KiB
Python

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