from datetime import timedelta from typing import Any from uuid import uuid4 from fastapi import HTTPException, status from sqlalchemy import func, or_, select from sqlalchemy.orm import Session from app.core.config import get_settings from app.core.constants import ActorValue from app.core.pagination import bounded_limit from app.core.time import utc_now from app.modules.audit.constants import ( AuditAction, AuditRiskLevel, AuditSource, AuditTargetType, ) from app.modules.audit.schemas import AuditLogCreate from app.modules.audit.service import AuditService from app.modules.events.constants import ( EVENT_CODE_PREFIX, EventAggregateType, EventErrorDetail, EventPayloadKey, EventStatus, EventType, ) from app.modules.events.models import DomainEvent def _serialize_event(record: DomainEvent) -> dict[str, Any]: return { column.name: getattr(record, column.name) for column in record.__table__.columns } class EventService: """Persist outbox events and dispatch the V3 internal handlers.""" def __init__(self, db: Session): self.db = db 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, dispatch: bool = False, ) -> DomainEvent: if idempotency_key: existing = self.db.execute( select(DomainEvent).where(DomainEvent.idempotency_key == idempotency_key) ).scalar_one_or_none() if existing is not None: if dispatch and existing.status == EventStatus.PENDING: return self.dispatch_event(existing.event_id) return existing settings = get_settings() now = utc_now() record = DomainEvent( event_id=f"{EVENT_CODE_PREFIX}-{now:%Y%m%d%H%M%S%f}", 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, ) self.db.add(record) self.db.commit() self.db.refresh(record) if dispatch: return self.dispatch_event(record.event_id) return record 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 def dispatch_event(self, event_id: str, worker_id: str | None = None) -> DomainEvent: record = self.get_event(event_id) if record.status == EventStatus.PROCESSED: return record if not self._can_attempt(record): return record settings = get_settings() now = utc_now() lock_owner = worker_id or f"api:{uuid4().hex}" record.locked_by = lock_owner record.locked_until = now + timedelta(seconds=settings.event_dispatch_lock_seconds) record.status = EventStatus.PENDING record.attempts += 1 try: self._handle_event(record) except Exception as exc: retryable = record.attempts < self._max_attempts(record) record.status = EventStatus.PENDING if retryable else EventStatus.FAILED record.last_error = str(exc) record.next_attempt_at = ( utc_now() + timedelta(seconds=settings.event_dispatch_retry_delay_seconds) if retryable else None ) record.locked_by = None record.locked_until = None self.db.commit() self.db.refresh(record) self._audit_dispatch(record) return record record.status = EventStatus.PROCESSED record.last_error = None record.processed_at = utc_now() record.next_attempt_at = None record.locked_by = None record.locked_until = None self.db.commit() self.db.refresh(record) self._audit_dispatch(record) return record def dispatch_pending( self, limit: int = 100, worker_id: str | None = None, ) -> list[dict[str, Any]]: now = utc_now() stmt = ( select(DomainEvent) .where( DomainEvent.status == EventStatus.PENDING, or_( DomainEvent.next_attempt_at.is_(None), DomainEvent.next_attempt_at <= now, ), or_( DomainEvent.locked_until.is_(None), DomainEvent.locked_until <= now, ), or_( DomainEvent.max_attempts.is_(None), DomainEvent.attempts < DomainEvent.max_attempts, ), ) .order_by(DomainEvent.id.asc()) .limit(bounded_limit(limit)) ) records = list(self.db.execute(stmt).scalars()) lock_owner = worker_id or f"worker:{uuid4().hex}" return [ _serialize_event(self.dispatch_event(record.event_id, worker_id=lock_owner)) for record in records ] def retry_event(self, event_id: str, actor: str = ActorValue.API) -> DomainEvent: record = self.get_event(event_id) if record.status == EventStatus.PROCESSED or not self._can_attempt(record): raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail=EventErrorDetail.EVENT_NOT_RETRYABLE, ) record.status = EventStatus.PENDING record.actor = actor record.last_error = None record.locked_by = None record.locked_until = None record.next_attempt_at = utc_now() self.db.commit() self.db.refresh(record) return record def _handle_event(self, record: DomainEvent) -> None: if record.event_type == EventType.RISK_ACTION_RECORDED: self._handle_risk_action(record) return if record.event_type in { EventType.REPORT_PUSH_SUCCEEDED, EventType.REPORT_PUSH_FAILED, EventType.REPORT_GENERATED, }: self._handle_report_event(record) return if record.event_type in { EventType.LEGACY_SYNC_COMPLETED, EventType.LEGACY_SYNC_FAILED, }: self._handle_legacy_sync_event(record) return if record.event_type == EventType.AI_MEMORY_WRITTEN: self._handle_ai_memory_event(record) return if record.event_type == EventType.ENTERPRISE_ANALYTICS_GENERATED: self._handle_enterprise_analytics_event(record) return def _handle_risk_action(self, record: DomainEvent) -> None: from app.modules.risk.constants import RiskEventActionValue from app.modules.workflows.service import WorkflowService from app.modules.workflows.constants import WorkflowStatus, WorkflowType payload = record.payload or {} action = str(payload.get(EventPayloadKey.ACTION) or "") if action == RiskEventActionValue.CLOSE: workflow_status = WorkflowStatus.COMPLETED elif action == RiskEventActionValue.RESOLVE: workflow_status = WorkflowStatus.WAITING_REVIEW else: workflow_status = WorkflowStatus.RUNNING WorkflowService(self.db).start_or_update( workflow_type=WorkflowType.RISK_EVENT_REVIEW, aggregate_type=EventAggregateType.RISK_EVENT, aggregate_id=record.aggregate_id, status_value=workflow_status, action=action or record.event_type, actor=record.actor, payload=payload, ) def _handle_report_event(self, record: DomainEvent) -> None: from app.modules.workflows.constants import WorkflowStatus, WorkflowType workflow_status = ( WorkflowStatus.FAILED if record.event_type == EventType.REPORT_PUSH_FAILED else WorkflowStatus.COMPLETED ) self._track_operational_workflow( record, workflow_type=WorkflowType.REPORT_DELIVERY, workflow_status=workflow_status, ) def _handle_legacy_sync_event(self, record: DomainEvent) -> None: from app.modules.workflows.constants import WorkflowStatus, WorkflowType workflow_status = ( WorkflowStatus.FAILED if record.event_type == EventType.LEGACY_SYNC_FAILED else WorkflowStatus.COMPLETED ) self._track_operational_workflow( record, workflow_type=WorkflowType.LEGACY_SYNC_MONITOR, workflow_status=workflow_status, ) def _handle_ai_memory_event(self, record: DomainEvent) -> None: from app.modules.ai_memory.constants import AIMemoryPayloadKey, AIMemoryStatus from app.modules.workflows.constants import WorkflowStatus, WorkflowType payload = record.payload or {} workflow_status = ( WorkflowStatus.BLOCKED if payload.get(AIMemoryPayloadKey.STATUS) == AIMemoryStatus.REJECTED else WorkflowStatus.COMPLETED ) self._track_operational_workflow( record, workflow_type=WorkflowType.AI_MEMORY_CAPTURE, workflow_status=workflow_status, ) def _handle_enterprise_analytics_event(self, record: DomainEvent) -> None: from app.modules.workflows.constants import WorkflowStatus, WorkflowType self._track_operational_workflow( record, workflow_type=WorkflowType.ENTERPRISE_ANALYTICS, workflow_status=WorkflowStatus.COMPLETED, ) def _track_operational_workflow( self, record: DomainEvent, workflow_type: str, workflow_status: str, ) -> None: from app.modules.workflows.service import WorkflowService WorkflowService(self.db).start_or_update( workflow_type=workflow_type, aggregate_type=record.aggregate_type, aggregate_id=record.aggregate_id, status_value=workflow_status, action=record.event_type, actor=record.actor, payload=record.payload or {}, ) def _audit_dispatch(self, record: DomainEvent) -> None: AuditService(self.db).log( AuditLogCreate( actor=record.actor, source=AuditSource.EVENTS, action=AuditAction.EVENT_DISPATCH, target_type=AuditTargetType.DOMAIN_EVENT, target_id=record.event_id, risk_level=AuditRiskLevel.LOW, response_payload={ EventPayloadKey.STATUS: record.status, EventPayloadKey.ATTEMPTS: record.attempts, EventPayloadKey.ERROR_MESSAGE: record.last_error, }, ) ) def _can_attempt(self, record: DomainEvent) -> bool: return record.attempts < self._max_attempts(record) @staticmethod def _max_attempts(record: DomainEvent) -> int: return record.max_attempts or get_settings().event_dispatch_max_attempts