Merge remote-tracking branch 'origin/feature/friendship' into dev
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from core.auth.models import CurrentUser
|
||||
from core.db import get_db
|
||||
from v1.friendships.repository import SQLAlchemyFriendshipRepository
|
||||
from v1.friendships.service import FriendshipService
|
||||
from v1.users.dependencies import get_current_user
|
||||
from v1.users.repository import SQLAlchemyUserRepository
|
||||
|
||||
|
||||
async def get_friendship_repository(
|
||||
session: Annotated[AsyncSession, Depends(get_db)],
|
||||
) -> SQLAlchemyFriendshipRepository:
|
||||
return SQLAlchemyFriendshipRepository(session)
|
||||
|
||||
|
||||
async def get_friendship_service(
|
||||
session: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[CurrentUser, Depends(get_current_user)],
|
||||
) -> FriendshipService:
|
||||
friendship_repository = SQLAlchemyFriendshipRepository(session)
|
||||
user_repository = SQLAlchemyUserRepository(session)
|
||||
return FriendshipService(
|
||||
repository=friendship_repository,
|
||||
user_repository=user_repository,
|
||||
session=session,
|
||||
current_user=current_user,
|
||||
)
|
||||
@@ -0,0 +1,221 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Protocol
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import select, or_
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from core.db.base_repository import BaseRepository
|
||||
from core.logging import get_logger
|
||||
from models.friendships import Friendship, FriendshipStatus
|
||||
from models.inbox_messages import InboxMessage, InboxMessageStatus, InboxMessageType
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
logger = get_logger("v1.friendships.repository")
|
||||
|
||||
|
||||
class FriendshipRepository(Protocol):
|
||||
"""Protocol defining the friendship repository interface."""
|
||||
|
||||
async def create_request(
|
||||
self, initiator_id: UUID, recipient_id: UUID
|
||||
) -> tuple[Friendship, InboxMessage]:
|
||||
"""Create a friendship request and inbox message."""
|
||||
...
|
||||
|
||||
async def get_friendship_between_users(
|
||||
self, user_id_1: UUID, user_id_2: UUID
|
||||
) -> Friendship | None:
|
||||
"""Check if friendship exists between two users."""
|
||||
...
|
||||
|
||||
async def get_pending_inbox_for_recipient(
|
||||
self, recipient_id: UUID, friendship_id: UUID
|
||||
) -> InboxMessage | None:
|
||||
"""Get pending inbox message for recipient."""
|
||||
...
|
||||
|
||||
async def get_friendship_by_id(self, friendship_id: UUID) -> Friendship | None:
|
||||
"""Get friendship by ID."""
|
||||
...
|
||||
|
||||
async def get_inbox_messages_for_user(
|
||||
self, user_id: UUID, status: InboxMessageStatus | None = None
|
||||
) -> list[InboxMessage]:
|
||||
"""Get inbox messages for a user."""
|
||||
...
|
||||
|
||||
async def get_outgoing_requests(self, user_id: UUID) -> list[Friendship]:
|
||||
"""Get outgoing friend requests."""
|
||||
...
|
||||
|
||||
async def get_friends_list(self, user_id: UUID) -> list[Friendship]:
|
||||
"""Get accepted friends list."""
|
||||
...
|
||||
|
||||
|
||||
class SQLAlchemyFriendshipRepository(BaseRepository[Friendship]):
|
||||
"""SQLAlchemy implementation of FriendshipRepository.
|
||||
|
||||
Note: This repository only performs CRUD operations.
|
||||
- No commit (only flush) - service layer handles transactions
|
||||
- No auth logic - service layer handles authorization
|
||||
- No HTTP exceptions - returns None or raises SQLAlchemyError
|
||||
"""
|
||||
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
super().__init__(session, Friendship)
|
||||
|
||||
async def create_request(
|
||||
self, initiator_id: UUID, recipient_id: UUID
|
||||
) -> tuple[Friendship, InboxMessage]:
|
||||
try:
|
||||
user_low_id = min(initiator_id, recipient_id)
|
||||
user_high_id = max(initiator_id, recipient_id)
|
||||
|
||||
friendship = Friendship(
|
||||
user_low_id=user_low_id,
|
||||
user_high_id=user_high_id,
|
||||
initiator_id=initiator_id,
|
||||
status=FriendshipStatus.PENDING,
|
||||
requested_at=UUID(int=0),
|
||||
)
|
||||
self._session.add(friendship)
|
||||
await self._session.flush()
|
||||
|
||||
inbox = InboxMessage(
|
||||
recipient_id=recipient_id,
|
||||
sender_id=initiator_id,
|
||||
message_type=InboxMessageType.FRIEND_REQUEST,
|
||||
friendship_id=friendship.id,
|
||||
status=InboxMessageStatus.PENDING,
|
||||
)
|
||||
self._session.add(inbox)
|
||||
await self._session.flush()
|
||||
|
||||
return friendship, inbox
|
||||
except SQLAlchemyError:
|
||||
logger.exception(
|
||||
"Failed to create friendship request",
|
||||
initiator_id=str(initiator_id),
|
||||
recipient_id=str(recipient_id),
|
||||
)
|
||||
raise
|
||||
|
||||
async def get_friendship_between_users(
|
||||
self, user_id_1: UUID, user_id_2: UUID
|
||||
) -> Friendship | None:
|
||||
try:
|
||||
user_low_id = min(user_id_1, user_id_2)
|
||||
user_high_id = max(user_id_1, user_id_2)
|
||||
|
||||
stmt = (
|
||||
select(Friendship)
|
||||
.where(Friendship.user_low_id == user_low_id)
|
||||
.where(Friendship.user_high_id == user_high_id)
|
||||
.where(Friendship.deleted_at.is_(None))
|
||||
)
|
||||
result = await self._session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
except SQLAlchemyError:
|
||||
logger.exception(
|
||||
"Failed to get friendship between users",
|
||||
user_id_1=str(user_id_1),
|
||||
user_id_2=str(user_id_2),
|
||||
)
|
||||
raise
|
||||
|
||||
async def get_pending_inbox_for_recipient(
|
||||
self, recipient_id: UUID, friendship_id: UUID
|
||||
) -> InboxMessage | None:
|
||||
try:
|
||||
stmt = (
|
||||
select(InboxMessage)
|
||||
.where(InboxMessage.recipient_id == recipient_id)
|
||||
.where(InboxMessage.friendship_id == friendship_id)
|
||||
.where(InboxMessage.status == InboxMessageStatus.PENDING)
|
||||
)
|
||||
result = await self._session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
except SQLAlchemyError:
|
||||
logger.exception(
|
||||
"Failed to get pending inbox message",
|
||||
recipient_id=str(recipient_id),
|
||||
friendship_id=str(friendship_id),
|
||||
)
|
||||
raise
|
||||
|
||||
async def get_friendship_by_id(self, friendship_id: UUID) -> Friendship | None:
|
||||
try:
|
||||
return await self.get_by_id(friendship_id)
|
||||
except SQLAlchemyError:
|
||||
logger.exception(
|
||||
"Failed to get friendship by id",
|
||||
friendship_id=str(friendship_id),
|
||||
)
|
||||
raise
|
||||
|
||||
async def get_inbox_messages_for_user(
|
||||
self, user_id: UUID, status: InboxMessageStatus | None = None
|
||||
) -> list[InboxMessage]:
|
||||
try:
|
||||
stmt = (
|
||||
select(InboxMessage)
|
||||
.where(InboxMessage.recipient_id == user_id)
|
||||
.order_by(InboxMessage.created_at.desc())
|
||||
)
|
||||
if status is not None:
|
||||
stmt = stmt.where(InboxMessage.status == status)
|
||||
|
||||
result = await self._session.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
except SQLAlchemyError:
|
||||
logger.exception(
|
||||
"Failed to get inbox messages for user",
|
||||
user_id=str(user_id),
|
||||
)
|
||||
raise
|
||||
|
||||
async def get_outgoing_requests(self, user_id: UUID) -> list[Friendship]:
|
||||
try:
|
||||
stmt = (
|
||||
select(Friendship)
|
||||
.where(Friendship.initiator_id == user_id)
|
||||
.where(Friendship.status == FriendshipStatus.PENDING)
|
||||
.where(Friendship.deleted_at.is_(None))
|
||||
.order_by(Friendship.created_at.desc())
|
||||
)
|
||||
result = await self._session.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
except SQLAlchemyError:
|
||||
logger.exception(
|
||||
"Failed to get outgoing requests",
|
||||
user_id=str(user_id),
|
||||
)
|
||||
raise
|
||||
|
||||
async def get_friends_list(self, user_id: UUID) -> list[Friendship]:
|
||||
try:
|
||||
stmt = (
|
||||
select(Friendship)
|
||||
.where(
|
||||
or_(
|
||||
Friendship.user_low_id == user_id,
|
||||
Friendship.user_high_id == user_id,
|
||||
)
|
||||
)
|
||||
.where(Friendship.status == FriendshipStatus.ACCEPTED)
|
||||
.where(Friendship.deleted_at.is_(None))
|
||||
.order_by(Friendship.updated_at.desc())
|
||||
)
|
||||
result = await self._session.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
except SQLAlchemyError:
|
||||
logger.exception(
|
||||
"Failed to get friends list",
|
||||
user_id=str(user_id),
|
||||
)
|
||||
raise
|
||||
@@ -0,0 +1,97 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, status
|
||||
|
||||
from v1.friendships.dependencies import get_friendship_service
|
||||
from v1.friendships.schemas import (
|
||||
FriendRequestAction,
|
||||
FriendRequestCreate,
|
||||
FriendRequestResponse,
|
||||
FriendResponse,
|
||||
)
|
||||
from v1.friendships.service import FriendshipService
|
||||
|
||||
|
||||
router = APIRouter(prefix="/friends", tags=["friends"])
|
||||
|
||||
|
||||
@router.post(
|
||||
"/requests",
|
||||
response_model=FriendRequestResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def send_friend_request(
|
||||
payload: FriendRequestCreate,
|
||||
service: Annotated[FriendshipService, Depends(get_friendship_service)],
|
||||
) -> FriendRequestResponse:
|
||||
return await service.send_request(payload)
|
||||
|
||||
|
||||
@router.get("/requests/inbox", response_model=list[FriendRequestResponse])
|
||||
async def get_inbox(
|
||||
service: Annotated[FriendshipService, Depends(get_friendship_service)],
|
||||
) -> list[FriendRequestResponse]:
|
||||
return await service.get_inbox()
|
||||
|
||||
|
||||
@router.get("/requests/outgoing", response_model=list[FriendRequestResponse])
|
||||
async def get_outgoing_requests(
|
||||
service: Annotated[FriendshipService, Depends(get_friendship_service)],
|
||||
) -> list[FriendRequestResponse]:
|
||||
return await service.get_outgoing_requests()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/requests/{friendship_id}/accept",
|
||||
response_model=FriendRequestResponse,
|
||||
)
|
||||
async def accept_friend_request(
|
||||
friendship_id: UUID,
|
||||
_: FriendRequestAction,
|
||||
service: Annotated[FriendshipService, Depends(get_friendship_service)],
|
||||
) -> FriendRequestResponse:
|
||||
return await service.accept_request(friendship_id)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/requests/{friendship_id}/decline",
|
||||
response_model=FriendRequestResponse,
|
||||
)
|
||||
async def decline_friend_request(
|
||||
friendship_id: UUID,
|
||||
_: FriendRequestAction,
|
||||
service: Annotated[FriendshipService, Depends(get_friendship_service)],
|
||||
) -> FriendRequestResponse:
|
||||
return await service.decline_request(friendship_id)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/requests/{friendship_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
)
|
||||
async def cancel_friend_request(
|
||||
friendship_id: UUID,
|
||||
service: Annotated[FriendshipService, Depends(get_friendship_service)],
|
||||
) -> None:
|
||||
await service.cancel_request(friendship_id)
|
||||
|
||||
|
||||
@router.get("", response_model=list[FriendResponse])
|
||||
async def get_friends_list(
|
||||
service: Annotated[FriendshipService, Depends(get_friendship_service)],
|
||||
) -> list[FriendResponse]:
|
||||
return await service.get_friends_list()
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{friendship_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
)
|
||||
async def remove_friend(
|
||||
friendship_id: UUID,
|
||||
service: Annotated[FriendshipService, Depends(get_friendship_service)],
|
||||
) -> None:
|
||||
await service.remove_friend(friendship_id)
|
||||
@@ -0,0 +1,42 @@
|
||||
from __future__ import annotations
|
||||
from datetime import datetime
|
||||
from typing import ClassVar, Literal
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class UserBasicInfo(BaseModel):
|
||||
id: str
|
||||
username: str
|
||||
avatar_url: str | None = None
|
||||
|
||||
|
||||
class FriendRequestCreate(BaseModel):
|
||||
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
|
||||
|
||||
target_user_id: UUID
|
||||
content: str | None = Field(None, max_length=200)
|
||||
|
||||
|
||||
class FriendRequestResponse(BaseModel):
|
||||
id: UUID
|
||||
sender: UserBasicInfo
|
||||
recipient: UserBasicInfo
|
||||
content: str | None
|
||||
status: Literal["pending", "accepted", "rejected", "canceled"]
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class FriendResponse(BaseModel):
|
||||
id: UUID
|
||||
friend: UserBasicInfo
|
||||
status: Literal["active"]
|
||||
created_at: datetime
|
||||
accepted_at: datetime | None
|
||||
|
||||
|
||||
class FriendRequestAction(BaseModel):
|
||||
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
|
||||
|
||||
pass
|
||||
@@ -0,0 +1,501 @@
|
||||
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,
|
||||
)
|
||||
@@ -5,6 +5,7 @@ from fastapi import APIRouter
|
||||
from core.http.models import HealthResponse
|
||||
from v1.agent_chat.router import router as agent_chat_router
|
||||
from v1.auth.router import router as auth_router
|
||||
from v1.friendships.router import router as friendships_router
|
||||
from v1.infra.router import router as infra_router
|
||||
from v1.schedule_items.router import router as schedule_items_router
|
||||
from v1.users.router import router as users_router
|
||||
@@ -12,6 +13,7 @@ from v1.users.router import router as users_router
|
||||
|
||||
router = APIRouter(prefix="/api/v1")
|
||||
router.include_router(auth_router)
|
||||
router.include_router(friendships_router)
|
||||
router.include_router(infra_router)
|
||||
router.include_router(users_router)
|
||||
router.include_router(agent_chat_router)
|
||||
|
||||
Reference in New Issue
Block a user