"""Business logic for timeline activities.""" from __future__ import annotations from collections.abc import Sequence from dataclasses import dataclass from typing import Any from app.models.activity import Activity, ActivityCreate, ActivityType from app.models.deal import Deal from app.repositories.activity_repo import ( ActivityOrganizationMismatchError, ActivityQueryParams, ActivityRepository, ) from app.services.organization_service import OrganizationContext class ActivityServiceError(Exception): """Base class for activity service errors.""" class ActivityValidationError(ActivityServiceError): """Raised when payload does not satisfy business constraints.""" class ActivityForbiddenError(ActivityServiceError): """Raised when a user accesses activities from another organization.""" @dataclass(slots=True) class ActivityListFilters: """Filtering helpers for listing activities.""" deal_id: int limit: int | None = None offset: int = 0 class ActivityService: """Encapsulates timeline-specific workflows.""" def __init__(self, repository: ActivityRepository) -> None: self._repository = repository async def list_activities( self, *, filters: ActivityListFilters, context: OrganizationContext, ) -> Sequence[Activity]: await self._ensure_deal_in_context(filters.deal_id, context) params = ActivityQueryParams( organization_id=context.organization_id, deal_id=filters.deal_id, limit=filters.limit, offset=max(filters.offset, 0), ) return await self._repository.list(params=params) async def add_comment( self, *, deal_id: int, author_id: int, text: str, context: OrganizationContext, ) -> Activity: normalized = text.strip() if not normalized: raise ActivityValidationError("Comment text cannot be empty") return await self.record_activity( deal_id=deal_id, activity_type=ActivityType.COMMENT, payload={"text": normalized}, author_id=author_id, context=context, ) async def record_activity( self, *, deal_id: int, activity_type: ActivityType, context: OrganizationContext, payload: dict[str, Any] | None = None, author_id: int | None = None, ) -> Activity: await self._ensure_deal_in_context(deal_id, context) data = ActivityCreate( deal_id=deal_id, author_id=author_id, type=activity_type, payload=payload or {}, ) try: return await self._repository.create(data, organization_id=context.organization_id) except ActivityOrganizationMismatchError as exc: # pragma: no cover - defensive raise ActivityForbiddenError("Deal belongs to another organization") from exc async def _ensure_deal_in_context(self, deal_id: int, context: OrganizationContext) -> Deal: deal = await self._repository.session.get(Deal, deal_id) if deal is None or deal.organization_id != context.organization_id: raise ActivityForbiddenError("Deal not found in current organization") return deal