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