Include Loyalty/Communication/Sports Center backends and registry updates alongside production nginx and compose wiring. Co-authored-by: Cursor <cursoragent@cursor.com>
398 lines
16 KiB
Python
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)
|