from typing import Any from uuid import uuid4 from fastapi import HTTPException, status from sqlalchemy import func, select from sqlalchemy.exc import IntegrityError from app.core.config import get_settings from app.core.constants import ActorValue from app.core.http.pagination import bounded_limit from app.core.utils.time import utc_now from app.modules.events.constants import ( EVENT_CODE_PREFIX, EventErrorDetail, ) from app.modules.events.models import DomainEvent from app.modules.events.services.serialization import _serialize_event class EventQueryMixin: def enqueue( self, event_type: str, source: str, aggregate_type: str, aggregate_id: str | int | None, actor: str = ActorValue.SYSTEM, payload: dict[str, Any] | None = None, idempotency_key: str | None = None, ) -> DomainEvent: """Stage an outbox event in the caller's transaction.""" existing = self._find_idempotent_event(idempotency_key) if existing is not None: return existing settings = get_settings() now = utc_now() record = DomainEvent( event_id=( f"{EVENT_CODE_PREFIX}-{now:%Y%m%d%H%M%S%f}-" f"{uuid4().hex[:8]}" ), event_type=event_type, source=source, aggregate_type=aggregate_type, aggregate_id=str(aggregate_id) if aggregate_id is not None else None, actor=actor, payload=payload or {}, idempotency_key=idempotency_key, next_attempt_at=now, max_attempts=settings.event_dispatch_max_attempts, ) if not idempotency_key: self.db.add(record) self.db.flush() return record try: with self.db.begin_nested(): self.db.add(record) self.db.flush() except IntegrityError: existing = self._find_idempotent_event(idempotency_key) if existing is None: raise return existing return record def emit( self, event_type: str, source: str, aggregate_type: str, aggregate_id: str | int | None, actor: str = ActorValue.SYSTEM, payload: dict[str, Any] | None = None, idempotency_key: str | None = None, ) -> DomainEvent: existing = self._find_idempotent_event(idempotency_key) if existing is not None: return existing record = self.enqueue( event_type=event_type, source=source, aggregate_type=aggregate_type, aggregate_id=aggregate_id, actor=actor, payload=payload, idempotency_key=idempotency_key, ) self.db.commit() self.db.refresh(record) return record def _find_idempotent_event(self, idempotency_key: str | None) -> DomainEvent | None: if not idempotency_key: return None return self.db.execute( select(DomainEvent).where(DomainEvent.idempotency_key == idempotency_key) ).scalar_one_or_none() def list_events( self, status_filter: str | None = None, event_type: str | None = None, limit: int = 100, ) -> list[dict[str, Any]]: stmt = select(DomainEvent).order_by(DomainEvent.id.desc()).limit(bounded_limit(limit)) if status_filter: stmt = stmt.where(DomainEvent.status == status_filter) if event_type: stmt = stmt.where(DomainEvent.event_type == event_type) return [_serialize_event(item) for item in self.db.execute(stmt).scalars()] def count_by_status(self) -> dict[str, int]: rows = self.db.execute( select(DomainEvent.status, func.count()).group_by(DomainEvent.status) ).all() return {str(status_value): int(count) for status_value, count in rows} def get_event(self, event_id: str) -> DomainEvent: record = self.db.execute( select(DomainEvent).where(DomainEvent.event_id == event_id) ).scalar_one_or_none() if record is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=EventErrorDetail.EVENT_NOT_FOUND, ) return record