"""Base command infrastructure — CommandResult and BaseCommand. Template method pattern: execute() → authorize() → run() → audit() Commands must NOT call db.commit() or db.rollback(). The transaction boundary is owned by the calling layer (FastAPI ``get_db``). Commands MAY call db.flush() to send SQL within the transaction. """ from __future__ import annotations import logging import uuid from dataclasses import dataclass, field from typing import Any import redis.asyncio as aioredis from sqlalchemy.ext.asyncio import AsyncSession from app.core.permissions import check_permission logger = logging.getLogger(__name__) @dataclass class CommandResult: """Result of a command execution. Attributes: success: Whether the command succeeded. data: Response data on success (dict or None). error: Error message on failure (str or None). events: List of outbox event dicts that were enqueued. """ success: bool data: dict | None = None error: str | None = None events: list[dict] = field(default_factory=list) @classmethod def ok(cls, data: dict | None = None, events: list[dict] | None = None) -> CommandResult: """Create a successful result.""" return cls(success=True, data=data, events=events or []) @classmethod def fail(cls, error: str) -> CommandResult: """Create a failed result.""" return cls(success=False, error=error) class BaseCommand: """Base class for all commands using the template method pattern. Subclasses must implement: - authorize(current_user) → check permissions - run(db, redis, current_user) → execute business logic, return CommandResult - audit(db, current_user) → create audit log entry The ``permission`` attribute is checked by the default ``authorize`` implementation. Set it to the required permission string (e.g. "contacts:write"). """ permission: str | None = None async def execute( self, db: AsyncSession, redis: aioredis.Redis, current_user: dict[str, Any], ) -> CommandResult: """Execute the command: authorize → run → audit. Does NOT commit — the caller manages the transaction. """ # Authorization auth_result = await self.authorize(current_user) if not auth_result: return CommandResult.fail( f"Permission denied: '{self.permission}' required" ) # Execute business logic result = await self.run(db, redis, current_user) # Audit (only if the command succeeded) if result.success: try: await self.audit(db, current_user) except Exception: logger.exception("Audit logging failed for %s", self.__class__.__name__) # Audit failure should not roll back the business operation return result async def authorize(self, current_user: dict[str, Any]) -> bool: """Check if the current user has the required permission. Override in subclass for custom authorization logic. """ if self.permission is None: return True return check_permission(current_user, self.permission) async def run( self, db: AsyncSession, redis: aioredis.Redis, current_user: dict[str, Any], ) -> CommandResult: """Execute the business logic. Must be overridden by subclasses.""" raise NotImplementedError(f"{self.__class__.__name__}.run() not implemented") async def audit(self, db: AsyncSession, current_user: dict[str, Any]) -> None: """Create an audit log entry. Override in subclass.""" pass # ── Helpers ── @staticmethod def _tenant_id(current_user: dict[str, Any]) -> uuid.UUID: """Extract tenant_id from current_user session.""" return uuid.UUID(current_user["tenant_id"]) @staticmethod def _user_id(current_user: dict[str, Any]) -> uuid.UUID: """Extract user_id from current_user session.""" return uuid.UUID(current_user["user_id"])