from __future__ import annotations from datetime import datetime, timezone from typing import TYPE_CHECKING, Any from uuid import UUID from fastapi import HTTPException from sqlalchemy.exc import SQLAlchemyError from core.auth.models import CurrentUser from core.db.base_service import BaseService from core.logging import get_logger from models.friendships import FriendshipStatus from models.inbox_messages import InboxMessageStatus, InboxMessageType from v1.friendships.repository import FriendshipRepository from v1.friendships.schemas import ( FriendRequestCreate, FriendRequestResponse, FriendResponse, ) from v1.users.repository import UserRepository if TYPE_CHECKING: from sqlalchemy.ext.asyncio import AsyncSession from v1.friendships.schemas import UserBasicInfo logger = get_logger("v1.friendships.service") class FriendshipService(BaseService): """Friendship service handling friend requests and friends list. Responsibilities: - Authorization checks - Validation (not self, not already friends, not blocked) - Transaction boundary (commit/rollback) - Converting ORM models to response schemas """ _repository: FriendshipRepository _user_repository: UserRepository _session: AsyncSession def __init__( self, repository: FriendshipRepository, user_repository: UserRepository, session: AsyncSession, current_user: CurrentUser | None, ) -> None: super().__init__(current_user=current_user) self._repository = repository self._user_repository = user_repository self._session = session async def send_request(self, request: FriendRequestCreate) -> FriendRequestResponse: user_id = self.require_user_id() target_user_id = request.target_user_id if user_id == target_user_id: raise HTTPException( status_code=400, detail="Cannot send friend request to yourself" ) existing = await self._repository.get_friendship_between_users( user_id, target_user_id ) if existing: if existing.status == FriendshipStatus.ACCEPTED: raise HTTPException( status_code=400, detail="Already friends with this user" ) if existing.status == FriendshipStatus.BLOCKED: raise HTTPException( status_code=400, detail="Cannot send friend request to blocked user" ) try: friendship, inbox = await self._repository.create_request( user_id, target_user_id ) await self._session.commit() except SQLAlchemyError: await self._session.rollback() raise HTTPException( status_code=503, detail="Friendship service unavailable" ) logger.info( "friend_request_sent", extra={"initiator_id": str(user_id), "target_id": str(target_user_id)}, ) sender = await self._user_repository.get_by_user_id(user_id) recipient = await self._user_repository.get_by_user_id(target_user_id) return FriendRequestResponse( id=friendship.id, sender=self._build_user_basic_info(sender), recipient=self._build_user_basic_info(recipient), content=inbox.content, status="pending", created_at=friendship.created_at, ) async def accept_request(self, friendship_id: UUID) -> FriendRequestResponse: user_id = self.require_user_id() try: friendship = await self._repository.get_friendship_by_id(friendship_id) except SQLAlchemyError: raise HTTPException( status_code=503, detail="Friendship service unavailable" ) if friendship is None: raise HTTPException(status_code=404, detail="Friend request not found") recipient_id = ( friendship.user_low_id if friendship.initiator_id == friendship.user_high_id else friendship.user_high_id ) if recipient_id != user_id: logger.warning( "friend_request_accept_unauthorized", extra={ "actor_id": str(user_id), "friendship_id": str(friendship_id), }, ) raise HTTPException( status_code=403, detail="Not authorized to accept this request" ) if friendship.status != FriendshipStatus.PENDING: raise HTTPException(status_code=400, detail="Friend request is not pending") inbox = await self._repository.get_pending_inbox_for_recipient( user_id, friendship_id ) if inbox is None: raise HTTPException(status_code=404, detail="Inbox message not found") friendship.status = FriendshipStatus.ACCEPTED inbox.status = InboxMessageStatus.ACCEPTED try: await self._session.commit() except SQLAlchemyError: await self._session.rollback() raise HTTPException( status_code=503, detail="Friendship service unavailable" ) sender_id = friendship.initiator_id if sender_id is None: raise HTTPException( status_code=503, detail="Friendship service unavailable" ) logger.info( "friend_request_accepted", extra={ "actor_id": str(user_id), "friendship_id": str(friendship_id), "initiator_id": str(sender_id), }, ) sender = await self._user_repository.get_by_user_id(sender_id) recipient = await self._user_repository.get_by_user_id(user_id) return FriendRequestResponse( id=friendship.id, sender=self._build_user_basic_info(sender), recipient=self._build_user_basic_info(recipient), content=inbox.content, status="accepted", created_at=friendship.created_at, ) async def decline_request(self, friendship_id: UUID) -> FriendRequestResponse: user_id = self.require_user_id() try: friendship = await self._repository.get_friendship_by_id(friendship_id) except SQLAlchemyError: raise HTTPException( status_code=503, detail="Friendship service unavailable" ) if friendship is None: raise HTTPException(status_code=404, detail="Friend request not found") recipient_id = ( friendship.user_low_id if friendship.initiator_id == friendship.user_high_id else friendship.user_high_id ) if recipient_id != user_id: logger.warning( "friend_request_decline_unauthorized", extra={ "actor_id": str(user_id), "friendship_id": str(friendship_id), }, ) raise HTTPException( status_code=403, detail="Not authorized to decline this request" ) if friendship.status != FriendshipStatus.PENDING: raise HTTPException(status_code=400, detail="Friend request is not pending") inbox = await self._repository.get_pending_inbox_for_recipient( user_id, friendship_id ) friendship.status = FriendshipStatus.DECLINED if inbox: inbox.status = InboxMessageStatus.REJECTED try: await self._session.commit() except SQLAlchemyError: await self._session.rollback() raise HTTPException( status_code=503, detail="Friendship service unavailable" ) sender_id = friendship.initiator_id if sender_id is None: raise HTTPException( status_code=503, detail="Friendship service unavailable" ) logger.info( "friend_request_declined", extra={ "actor_id": str(user_id), "friendship_id": str(friendship_id), "initiator_id": str(sender_id), }, ) sender = await self._user_repository.get_by_user_id(sender_id) recipient = await self._user_repository.get_by_user_id(user_id) return FriendRequestResponse( id=friendship.id, sender=self._build_user_basic_info(sender), recipient=self._build_user_basic_info(recipient), content=inbox.content if inbox else None, status="rejected", created_at=friendship.created_at, ) async def cancel_request(self, friendship_id: UUID) -> FriendRequestResponse: user_id = self.require_user_id() try: friendship = await self._repository.get_friendship_by_id(friendship_id) except SQLAlchemyError: raise HTTPException( status_code=503, detail="Friendship service unavailable" ) if friendship is None: raise HTTPException(status_code=404, detail="Friend request not found") if friendship.initiator_id != user_id: logger.warning( "friend_request_cancel_unauthorized", extra={ "actor_id": str(user_id), "friendship_id": str(friendship_id), }, ) raise HTTPException( status_code=403, detail="Not authorized to cancel this request" ) if friendship.status != FriendshipStatus.PENDING: raise HTTPException(status_code=400, detail="Friend request is not pending") inbox = await self._repository.get_pending_inbox_for_recipient( friendship.user_high_id, friendship_id ) friendship.status = FriendshipStatus.CANCELED if inbox: inbox.status = InboxMessageStatus.DISMISSED try: await self._session.commit() except SQLAlchemyError: await self._session.rollback() raise HTTPException( status_code=503, detail="Friendship service unavailable" ) sender = await self._user_repository.get_by_user_id(user_id) recipient_id = friendship.user_high_id if recipient_id is None: raise HTTPException( status_code=503, detail="Friendship service unavailable" ) recipient = await self._user_repository.get_by_user_id(recipient_id) logger.info( "friend_request_canceled", extra={ "actor_id": str(user_id), "friendship_id": str(friendship_id), "target_id": str(recipient_id), }, ) return FriendRequestResponse( id=friendship.id, sender=self._build_user_basic_info(sender), recipient=self._build_user_basic_info(recipient), content=inbox.content if inbox else None, status="canceled", created_at=friendship.created_at, ) async def get_inbox(self) -> list[FriendRequestResponse]: user_id = self.require_user_id() try: inbox_messages = await self._repository.get_inbox_messages_for_user( user_id, InboxMessageStatus.PENDING ) except SQLAlchemyError: raise HTTPException( status_code=503, detail="Friendship service unavailable" ) result: list[FriendRequestResponse] = [] for inbox in inbox_messages: if inbox.message_type != InboxMessageType.FRIEND_REQUEST: continue friendship_id = inbox.friendship_id if friendship_id is None: continue friendship = await self._repository.get_friendship_by_id(friendship_id) if friendship is None or friendship.status != FriendshipStatus.PENDING: continue sender_id = inbox.sender_id if sender_id is None: continue sender = await self._user_repository.get_by_user_id(sender_id) recipient = await self._user_repository.get_by_user_id(user_id) result.append( FriendRequestResponse( id=friendship.id, sender=self._build_user_basic_info(sender), recipient=self._build_user_basic_info(recipient), content=inbox.content, status="pending", created_at=friendship.created_at, ) ) return result async def get_outgoing_requests(self) -> list[FriendRequestResponse]: user_id = self.require_user_id() try: outgoing = await self._repository.get_outgoing_requests(user_id) except SQLAlchemyError: raise HTTPException( status_code=503, detail="Friendship service unavailable" ) result: list[FriendRequestResponse] = [] for friendship in outgoing: recipient_id = ( friendship.user_low_id if friendship.initiator_id == friendship.user_high_id else friendship.user_high_id ) sender = await self._user_repository.get_by_user_id(user_id) recipient = await self._user_repository.get_by_user_id(recipient_id) result.append( FriendRequestResponse( id=friendship.id, sender=self._build_user_basic_info(sender), recipient=self._build_user_basic_info(recipient), content=None, status="pending", created_at=friendship.created_at, ) ) return result async def get_friends_list(self) -> list[FriendResponse]: user_id = self.require_user_id() try: friendships = await self._repository.get_friends_list(user_id) except SQLAlchemyError: raise HTTPException( status_code=503, detail="Friendship service unavailable" ) result: list[FriendResponse] = [] for friendship in friendships: friend_id = ( friendship.user_high_id if friendship.user_low_id == user_id else friendship.user_low_id ) friend = await self._user_repository.get_by_user_id(friend_id) result.append( FriendResponse( id=friendship.id, friend=self._build_user_basic_info(friend), status="active", created_at=friendship.created_at, accepted_at=friendship.updated_at, ) ) return result async def remove_friend(self, friend_id: UUID) -> FriendResponse: user_id = self.require_user_id() try: friendship = await self._repository.get_friendship_between_users( user_id, friend_id ) except SQLAlchemyError: raise HTTPException( status_code=503, detail="Friendship service unavailable" ) if friendship is None: raise HTTPException(status_code=404, detail="Friendship not found") if friendship.status != FriendshipStatus.ACCEPTED: raise HTTPException( status_code=400, detail="Can only remove accepted friendships" ) friendship.deleted_at = datetime.now(timezone.utc) try: await self._session.commit() except SQLAlchemyError: await self._session.rollback() raise HTTPException( status_code=503, detail="Friendship service unavailable" ) logger.info( "friend_removed", extra={ "actor_id": str(user_id), "friendship_id": str(friendship.id), "removed_friend_id": str(friend_id), }, ) friend = await self._user_repository.get_by_user_id(friend_id) return FriendResponse( id=friendship.id, friend=self._build_user_basic_info(friend), status="active", created_at=friendship.created_at, accepted_at=friendship.updated_at, ) def _build_user_basic_info(self, profile: Any) -> "UserBasicInfo": from v1.friendships.schemas import UserBasicInfo if profile is None: return UserBasicInfo(id="", username="") p = profile # type: ignore[assignment] return UserBasicInfo( id=str(p.id), username=p.username, avatar_url=p.avatar_url if hasattr(p, "avatar_url") else None, )