TorbatYar/backend/services/communication/app/repositories/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

329 lines
11 KiB
Python

"""Communication repositories."""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Sequence
from uuid import UUID
from sqlalchemy import asc, desc, func, or_, select
from app.models.foundation import (
CommunicationAuditLog,
ContactSource,
DeliveryEvent,
ManualContact,
Message,
MessageTemplate,
OTPChallenge,
ProviderConfig,
ProviderLog,
QueueItem,
SenderNumber,
WebhookReceipt,
)
from app.repositories.base import TenantBaseRepository
class ProviderConfigRepository(TenantBaseRepository[ProviderConfig]):
model = ProviderConfig
async def list_by_channel(
self, tenant_id: UUID, channel: str, *, active_only: bool = True
) -> Sequence[ProviderConfig]:
clauses = [
self.model.tenant_id == tenant_id,
self.model.channel == channel,
self.model.is_deleted.is_(False),
]
if active_only:
clauses.append(self.model.status.in_(["active", "degraded"]))
stmt = (
select(self.model)
.where(*clauses)
.order_by(asc(self.model.priority), asc(self.model.created_at))
)
result = await self.session.execute(stmt)
return result.scalars().all()
async def list_all_status(self, tenant_id: UUID) -> Sequence[ProviderConfig]:
stmt = (
select(self.model)
.where(
self.model.tenant_id == tenant_id,
self.model.is_deleted.is_(False),
)
.order_by(asc(self.model.channel), asc(self.model.priority))
)
result = await self.session.execute(stmt)
return result.scalars().all()
class SenderNumberRepository(TenantBaseRepository[SenderNumber]):
model = SenderNumber
async def get_default(self, tenant_id: UUID, channel: str) -> SenderNumber | None:
stmt = select(self.model).where(
self.model.tenant_id == tenant_id,
self.model.channel == channel,
self.model.is_default.is_(True),
self.model.is_active.is_(True),
self.model.is_deleted.is_(False),
)
result = await self.session.execute(stmt)
return result.scalar_one_or_none()
class MessageTemplateRepository(TenantBaseRepository[MessageTemplate]):
model = MessageTemplate
async def get_by_key(
self,
tenant_id: UUID,
template_key: str,
*,
locale: str = "fa",
approved_only: bool = True,
) -> MessageTemplate | None:
clauses = [
self.model.tenant_id == tenant_id,
self.model.template_key == template_key,
self.model.locale == locale,
self.model.is_deleted.is_(False),
]
if approved_only:
clauses.append(self.model.status == "approved")
stmt = (
select(self.model)
.where(*clauses)
.order_by(desc(self.model.version))
.limit(1)
)
result = await self.session.execute(stmt)
return result.scalar_one_or_none()
async def list_versions(
self, tenant_id: UUID, template_key: str, locale: str = "fa"
) -> Sequence[MessageTemplate]:
stmt = (
select(self.model)
.where(
self.model.tenant_id == tenant_id,
self.model.template_key == template_key,
self.model.locale == locale,
self.model.is_deleted.is_(False),
)
.order_by(desc(self.model.version))
)
result = await self.session.execute(stmt)
return result.scalars().all()
class ManualContactRepository(TenantBaseRepository[ManualContact]):
model = ManualContact
class ContactSourceRepository(TenantBaseRepository[ContactSource]):
model = ContactSource
async def list_active(self, tenant_id: UUID) -> Sequence[ContactSource]:
stmt = select(self.model).where(
self.model.tenant_id == tenant_id,
self.model.is_active.is_(True),
self.model.is_deleted.is_(False),
)
result = await self.session.execute(stmt)
return result.scalars().all()
class MessageRepository(TenantBaseRepository[Message]):
model = Message
async def get_by_correlation(
self, tenant_id: UUID, correlation_id: str
) -> Sequence[Message]:
stmt = (
select(self.model)
.where(
self.model.tenant_id == tenant_id,
self.model.correlation_id == correlation_id,
self.model.is_deleted.is_(False),
)
.order_by(asc(self.model.created_at))
)
result = await self.session.execute(stmt)
return result.scalars().all()
async def list_by_status(
self, tenant_id: UUID, status: str, *, offset: int = 0, limit: int = 20
) -> Sequence[Message]:
stmt = (
select(self.model)
.where(
self.model.tenant_id == tenant_id,
self.model.status == status,
self.model.is_deleted.is_(False),
)
.order_by(desc(self.model.created_at))
.offset(offset)
.limit(limit)
)
result = await self.session.execute(stmt)
return result.scalars().all()
async def count_by_status(self, tenant_id: UUID) -> dict[str, int]:
stmt = (
select(self.model.status, func.count())
.where(
self.model.tenant_id == tenant_id,
self.model.is_deleted.is_(False),
)
.group_by(self.model.status)
)
result = await self.session.execute(stmt)
return {row[0]: int(row[1]) for row in result.all()}
class QueueItemRepository(TenantBaseRepository[QueueItem]):
model = QueueItem
async def enqueue(self, item: QueueItem) -> QueueItem:
return await self.add(item)
async def claim_next(
self,
tenant_id: UUID | None = None,
*,
limit: int = 10,
increment_attempt: bool = True,
) -> Sequence[QueueItem]:
now = datetime.now(timezone.utc)
clauses = [
self.model.status.in_(["pending", "scheduled"]),
self.model.is_dead_letter.is_(False),
]
if tenant_id is not None:
clauses.append(self.model.tenant_id == tenant_id)
from sqlalchemy import case
priority_case = case(
(self.model.priority == "urgent", 1),
(self.model.priority == "high", 2),
(self.model.priority == "normal", 3),
else_=4,
)
stmt = (
select(self.model)
.where(*clauses)
.order_by(asc(priority_case), asc(self.model.available_at))
.limit(limit * 3)
)
bind = self.session.get_bind()
if bind is not None and bind.dialect.name == "postgresql":
stmt = stmt.with_for_update(skip_locked=True)
result = await self.session.execute(stmt)
candidates = list(result.scalars().all())
items: list[QueueItem] = []
for item in candidates:
available = item.available_at
if available.tzinfo is None:
available = available.replace(tzinfo=timezone.utc)
if available > now:
continue
item.status = "processing"
item.locked_at = now
if increment_attempt:
item.attempt += 1
items.append(item)
if len(items) >= limit:
break
await self.session.flush()
return items
async def count_by_status(self, tenant_id: UUID) -> dict[str, int]:
stmt = (
select(self.model.status, func.count())
.where(self.model.tenant_id == tenant_id)
.group_by(self.model.status)
)
result = await self.session.execute(stmt)
return {row[0]: int(row[1]) for row in result.all()}
async def list_dead_letters(
self, tenant_id: UUID, *, offset: int = 0, limit: int = 20
) -> Sequence[QueueItem]:
stmt = (
select(self.model)
.where(
self.model.tenant_id == tenant_id,
or_(
self.model.is_dead_letter.is_(True),
self.model.status == "dead_letter",
),
)
.offset(offset)
.limit(limit)
)
result = await self.session.execute(stmt)
return result.scalars().all()
class DeliveryEventRepository(TenantBaseRepository[DeliveryEvent]):
model = DeliveryEvent
async def list_for_message(
self, tenant_id: UUID, message_id: UUID
) -> Sequence[DeliveryEvent]:
stmt = (
select(self.model)
.where(
self.model.tenant_id == tenant_id,
self.model.message_id == message_id,
)
.order_by(asc(self.model.created_at))
)
result = await self.session.execute(stmt)
return result.scalars().all()
class ProviderLogRepository(TenantBaseRepository[ProviderLog]):
model = ProviderLog
class OTPChallengeRepository(TenantBaseRepository[OTPChallenge]):
model = OTPChallenge
async def get_latest_pending(
self, tenant_id: UUID, destination: str
) -> OTPChallenge | None:
stmt = (
select(self.model)
.where(
self.model.tenant_id == tenant_id,
self.model.destination == destination,
self.model.status == "pending",
)
.order_by(desc(self.model.created_at))
.limit(1)
)
result = await self.session.execute(stmt)
return result.scalar_one_or_none()
async def count_recent(
self, tenant_id: UUID, destination: str, since: datetime
) -> int:
stmt = select(func.count()).select_from(self.model).where(
self.model.tenant_id == tenant_id,
self.model.destination == destination,
self.model.created_at >= since,
)
result = await self.session.execute(stmt)
return int(result.scalar_one())
class WebhookReceiptRepository(TenantBaseRepository[WebhookReceipt]):
model = WebhookReceipt
class AuditLogRepository(TenantBaseRepository[CommunicationAuditLog]):
model = CommunicationAuditLog