502 lines
17 KiB
Python
502 lines
17 KiB
Python
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,
|
|
)
|