diff --git a/app/api/deps.py b/app/api/deps.py index 5467a8d..8eba210 100644 --- a/app/api/deps.py +++ b/app/api/deps.py @@ -10,10 +10,13 @@ from app.core.config import settings from app.core.database import get_session from app.core.security import jwt_service, password_hasher from app.models.user import User +from app.repositories.activity_repo import ActivityRepository from app.repositories.deal_repo import DealRepository from app.repositories.org_repo import OrganizationRepository +from app.repositories.task_repo import TaskRepository from app.repositories.user_repo import UserRepository from app.services.auth_service import AuthService +from app.services.activity_service import ActivityService from app.services.deal_service import DealService from app.services.organization_service import ( OrganizationAccessDeniedError, @@ -21,6 +24,7 @@ from app.services.organization_service import ( OrganizationContextMissingError, OrganizationService, ) +from app.services.task_service import TaskService from app.services.user_service import UserService oauth2_scheme = OAuth2PasswordBearer(tokenUrl=f"{settings.api_v1_prefix}/auth/token") @@ -44,6 +48,14 @@ def get_deal_repository(session: AsyncSession = Depends(get_db_session)) -> Deal return DealRepository(session=session) +def get_task_repository(session: AsyncSession = Depends(get_db_session)) -> TaskRepository: + return TaskRepository(session=session) + + +def get_activity_repository(session: AsyncSession = Depends(get_db_session)) -> ActivityRepository: + return ActivityRepository(session=session) + + def get_deal_service(repo: DealRepository = Depends(get_deal_repository)) -> DealService: return DealService(repository=repo) @@ -68,6 +80,19 @@ def get_organization_service( return OrganizationService(repository=repo) +def get_activity_service( + repo: ActivityRepository = Depends(get_activity_repository), +) -> ActivityService: + return ActivityService(repository=repo) + + +def get_task_service( + task_repo: TaskRepository = Depends(get_task_repository), + activity_repo: ActivityRepository = Depends(get_activity_repository), +) -> TaskService: + return TaskService(task_repository=task_repo, activity_repository=activity_repo) + + async def get_current_user( token: str = Depends(oauth2_scheme), repo: UserRepository = Depends(get_user_repository), diff --git a/app/services/__init__.py b/app/services/__init__.py index e235f99..33049f3 100644 --- a/app/services/__init__.py +++ b/app/services/__init__.py @@ -1,9 +1,26 @@ """Business logic services.""" +from .activity_service import ( # noqa: F401 + ActivityForbiddenError, + ActivityListFilters, + ActivityService, + ActivityServiceError, + ActivityValidationError, +) +from .auth_service import AuthService # noqa: F401 from .organization_service import ( # noqa: F401 OrganizationAccessDeniedError, OrganizationContext, OrganizationContextMissingError, OrganizationService, ) -from .user_service import UserService # noqa: F401 -from .auth_service import AuthService # noqa: F401 \ No newline at end of file +from .task_service import ( # noqa: F401 + TaskDueDateError, + TaskForbiddenError, + TaskListFilters, + TaskNotFoundError, + TaskOrganizationError, + TaskService, + TaskServiceError, + TaskUpdateData, +) +from .user_service import UserService # noqa: F401 \ No newline at end of file diff --git a/app/services/activity_service.py b/app/services/activity_service.py new file mode 100644 index 0000000..c846028 --- /dev/null +++ b/app/services/activity_service.py @@ -0,0 +1,104 @@ +"""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 diff --git a/app/services/task_service.py b/app/services/task_service.py new file mode 100644 index 0000000..0c34cae --- /dev/null +++ b/app/services/task_service.py @@ -0,0 +1,186 @@ +"""Business logic for tasks linked to deals.""" +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Any + +from app.models.activity import ActivityCreate, ActivityType +from app.models.organization_member import OrganizationRole +from app.models.task import Task, TaskCreate +from app.repositories.activity_repo import ActivityRepository, ActivityOrganizationMismatchError +from app.repositories.task_repo import ( + TaskAccessError as RepoTaskAccessError, + TaskOrganizationMismatchError as RepoTaskOrganizationMismatchError, + TaskQueryParams, + TaskRepository, +) +from app.services.organization_service import OrganizationContext + + +class TaskServiceError(Exception): + """Base class for task service errors.""" + + +class TaskDueDateError(TaskServiceError): + """Raised when due_date violates temporal constraints.""" + + +class TaskForbiddenError(TaskServiceError): + """Raised when the user lacks permissions for an operation.""" + + +class TaskOrganizationError(TaskServiceError): + """Raised when a task/deal belongs to another organization.""" + + +class TaskNotFoundError(TaskServiceError): + """Raised when task cannot be located in the current organization.""" + + +@dataclass(slots=True) +class TaskListFilters: + """Filters accepted by the task listing endpoint.""" + + deal_id: int | None = None + only_open: bool = False + due_before: datetime | None = None + due_after: datetime | None = None + + +@dataclass(slots=True) +class TaskUpdateData: + """Subset of fields allowed for partial updates.""" + + title: str | None = None + description: str | None = None + due_date: datetime | None = None + is_done: bool | None = None + + +class TaskService: + """Encapsulates task workflows and policy validations.""" + + def __init__( + self, + task_repository: TaskRepository, + activity_repository: ActivityRepository | None = None, + ) -> None: + self._task_repository = task_repository + self._activity_repository = activity_repository + + async def list_tasks( + self, + *, + filters: TaskListFilters, + context: OrganizationContext, + ) -> Sequence[Task]: + params = TaskQueryParams( + organization_id=context.organization_id, + deal_id=filters.deal_id, + only_open=filters.only_open, + due_before=filters.due_before, + due_after=filters.due_after, + ) + return await self._task_repository.list(params=params) + + async def get_task(self, task_id: int, *, context: OrganizationContext) -> Task: + task = await self._task_repository.get(task_id, organization_id=context.organization_id) + if task is None: + raise TaskNotFoundError("Task not found") + return task + + async def create_task( + self, + data: TaskCreate, + *, + context: OrganizationContext, + ) -> Task: + self._validate_due_date(data.due_date) + try: + task = await self._task_repository.create( + data, + organization_id=context.organization_id, + role=context.role, + user_id=context.user_id, + ) + except RepoTaskOrganizationMismatchError as exc: + raise TaskOrganizationError("Deal belongs to another organization") from exc + except RepoTaskAccessError as exc: + raise TaskForbiddenError(str(exc)) from exc + + await self._log_task_created(task, context=context) + return task + + async def update_task( + self, + task_id: int, + updates: TaskUpdateData, + *, + context: OrganizationContext, + ) -> Task: + task = await self.get_task(task_id, context=context) + if updates.due_date is not None: + self._validate_due_date(updates.due_date) + + payload = self._build_update_mapping(updates) + if not payload: + return task + + try: + return await self._task_repository.update( + task, + payload, + role=context.role, + user_id=context.user_id, + ) + except RepoTaskAccessError as exc: + raise TaskForbiddenError(str(exc)) from exc + + async def delete_task(self, task_id: int, *, context: OrganizationContext) -> None: + task = await self.get_task(task_id, context=context) + self._ensure_member_owns_task(task, context) + await self._task_repository.session.delete(task) + await self._task_repository.session.flush() + + def _ensure_member_owns_task(self, task: Task, context: OrganizationContext) -> None: + if context.role != OrganizationRole.MEMBER: + return + owner_id = task.deal.owner_id if task.deal is not None else None + if owner_id is None or owner_id != context.user_id: + raise TaskForbiddenError("Members can only modify their own tasks") + + def _validate_due_date(self, due_date: datetime | None) -> None: + if due_date is None: + return + today = datetime.now(timezone.utc).date() + value_date = (due_date.astimezone(timezone.utc) if due_date.tzinfo else due_date).date() + if value_date < today: + raise TaskDueDateError("Task due date cannot be in the past") + + def _build_update_mapping(self, updates: TaskUpdateData) -> Mapping[str, Any]: + payload: dict[str, Any] = {} + if updates.title is not None: + payload["title"] = updates.title + if updates.description is not None: + payload["description"] = updates.description + if updates.due_date is not None: + payload["due_date"] = updates.due_date + if updates.is_done is not None: + payload["is_done"] = updates.is_done + return payload + + async def _log_task_created(self, task: Task, *, context: OrganizationContext) -> None: + if self._activity_repository is None: + return + data = ActivityCreate( + deal_id=task.deal_id, + author_id=context.user_id, + type=ActivityType.TASK_CREATED, + payload={"task_id": task.id, "title": task.title}, + ) + try: + await self._activity_repository.create(data, organization_id=context.organization_id) + except ActivityOrganizationMismatchError: # pragma: no cover - defensive + raise TaskOrganizationError("Activity target does not belong to organization")