From a4d729462876415b79e662a8d8289332cf96d762 Mon Sep 17 00:00:00 2001 From: benya Date: Sun, 8 Mar 2026 00:04:54 +0300 Subject: [PATCH] feat(chats): add role-based member management APIs - add owner/admin/member permission checks - implement member add/remove, role updates, and leave flow - add chat title update endpoint for manageable chat types --- app/chats/repository.py | 11 +++- app/chats/router.py | 89 ++++++++++++++++++++++++- app/chats/schemas.py | 12 ++++ app/chats/service.py | 140 +++++++++++++++++++++++++++++++++++++++- 4 files changed, 246 insertions(+), 6 deletions(-) diff --git a/app/chats/repository.py b/app/chats/repository.py index ba83425..03a412a 100644 --- a/app/chats/repository.py +++ b/app/chats/repository.py @@ -1,4 +1,4 @@ -from sqlalchemy import Select, select +from sqlalchemy import Select, func, select from sqlalchemy.orm import aliased from sqlalchemy.ext.asyncio import AsyncSession @@ -19,6 +19,15 @@ async def add_chat_member(db: AsyncSession, *, chat_id: int, user_id: int, role: return member +async def delete_chat_member(db: AsyncSession, member: ChatMember) -> None: + await db.delete(member) + + +async def count_chat_members(db: AsyncSession, *, chat_id: int) -> int: + result = await db.execute(select(func.count(ChatMember.id)).where(ChatMember.chat_id == chat_id)) + return int(result.scalar_one()) + + def _user_chats_query(user_id: int) -> Select[tuple[Chat]]: return ( select(Chat) diff --git a/app/chats/router.py b/app/chats/router.py index d53f1ee..1a8d606 100644 --- a/app/chats/router.py +++ b/app/chats/router.py @@ -1,9 +1,26 @@ -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, status from sqlalchemy.ext.asyncio import AsyncSession from app.auth.service import get_current_user -from app.chats.schemas import ChatCreateRequest, ChatDetailRead, ChatRead -from app.chats.service import create_chat_for_user, get_chat_for_user, get_chats_for_user +from app.chats.schemas import ( + ChatCreateRequest, + ChatDetailRead, + ChatMemberAddRequest, + ChatMemberRead, + ChatMemberRoleUpdateRequest, + ChatRead, + ChatTitleUpdateRequest, +) +from app.chats.service import ( + add_chat_member_for_user, + create_chat_for_user, + get_chat_for_user, + get_chats_for_user, + leave_chat_for_user, + remove_chat_member_for_user, + update_chat_member_role_for_user, + update_chat_title_for_user, +) from app.database.session import get_db from app.users.models import User @@ -43,3 +60,69 @@ async def get_chat( created_at=chat.created_at, members=members, ) + + +@router.patch("/{chat_id}/title", response_model=ChatRead) +async def update_chat_title( + chat_id: int, + payload: ChatTitleUpdateRequest, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> ChatRead: + return await update_chat_title_for_user(db, chat_id=chat_id, user_id=current_user.id, payload=payload) + + +@router.get("/{chat_id}/members", response_model=list[ChatMemberRead]) +async def list_chat_members( + chat_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> list[ChatMemberRead]: + _chat, members = await get_chat_for_user(db, chat_id=chat_id, user_id=current_user.id) + return members + + +@router.post("/{chat_id}/members", response_model=ChatMemberRead, status_code=status.HTTP_201_CREATED) +async def add_chat_member( + chat_id: int, + payload: ChatMemberAddRequest, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> ChatMemberRead: + return await add_chat_member_for_user(db, chat_id=chat_id, actor_user_id=current_user.id, target_user_id=payload.user_id) + + +@router.patch("/{chat_id}/members/{user_id}/role", response_model=ChatMemberRead) +async def update_chat_member_role( + chat_id: int, + user_id: int, + payload: ChatMemberRoleUpdateRequest, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> ChatMemberRead: + return await update_chat_member_role_for_user( + db, + chat_id=chat_id, + actor_user_id=current_user.id, + target_user_id=user_id, + role=payload.role, + ) + + +@router.delete("/{chat_id}/members/{user_id}", status_code=status.HTTP_204_NO_CONTENT) +async def remove_chat_member( + chat_id: int, + user_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> None: + await remove_chat_member_for_user(db, chat_id=chat_id, actor_user_id=current_user.id, target_user_id=user_id) + + +@router.post("/{chat_id}/leave", status_code=status.HTTP_204_NO_CONTENT) +async def leave_chat( + chat_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> None: + await leave_chat_for_user(db, chat_id=chat_id, user_id=current_user.id) diff --git a/app/chats/schemas.py b/app/chats/schemas.py index a37d804..833849d 100644 --- a/app/chats/schemas.py +++ b/app/chats/schemas.py @@ -31,3 +31,15 @@ class ChatCreateRequest(BaseModel): type: ChatType title: str | None = Field(default=None, max_length=255) member_ids: list[int] = Field(default_factory=list) + + +class ChatMemberAddRequest(BaseModel): + user_id: int + + +class ChatMemberRoleUpdateRequest(BaseModel): + role: ChatMemberRole + + +class ChatTitleUpdateRequest(BaseModel): + title: str = Field(min_length=1, max_length=255) diff --git a/app/chats/service.py b/app/chats/service.py index 7226958..61a0475 100644 --- a/app/chats/service.py +++ b/app/chats/service.py @@ -1,9 +1,10 @@ from fastapi import HTTPException, status +from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from app.chats import repository -from app.chats.models import Chat, ChatMemberRole, ChatType -from app.chats.schemas import ChatCreateRequest +from app.chats.models import Chat, ChatMember, ChatMemberRole, ChatType +from app.chats.schemas import ChatCreateRequest, ChatTitleUpdateRequest from app.users.repository import get_user_by_id @@ -68,3 +69,138 @@ async def ensure_chat_membership(db: AsyncSession, *, chat_id: int, user_id: int membership = await repository.get_chat_member(db, chat_id=chat_id, user_id=user_id) if not membership: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="You are not a member of this chat") + + +async def _get_chat_and_membership(db: AsyncSession, *, chat_id: int, user_id: int) -> tuple[Chat, ChatMember]: + chat = await repository.get_chat_by_id(db, chat_id) + if not chat: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Chat not found") + membership = await repository.get_chat_member(db, chat_id=chat_id, user_id=user_id) + if not membership: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="You are not a member of this chat") + return chat, membership + + +def _ensure_manage_permission(role: ChatMemberRole) -> None: + if role not in {ChatMemberRole.OWNER, ChatMemberRole.ADMIN}: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions") + + +def _ensure_group_or_channel(chat_type: ChatType) -> None: + if chat_type == ChatType.PRIVATE: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Private chat cannot be managed") + + +async def update_chat_title_for_user( + db: AsyncSession, + *, + chat_id: int, + user_id: int, + payload: ChatTitleUpdateRequest, +) -> Chat: + chat, membership = await _get_chat_and_membership(db, chat_id=chat_id, user_id=user_id) + _ensure_group_or_channel(chat.type) + _ensure_manage_permission(membership.role) + chat.title = payload.title + await db.commit() + await db.refresh(chat) + return chat + + +async def add_chat_member_for_user( + db: AsyncSession, + *, + chat_id: int, + actor_user_id: int, + target_user_id: int, +) -> ChatMember: + chat, membership = await _get_chat_and_membership(db, chat_id=chat_id, user_id=actor_user_id) + _ensure_group_or_channel(chat.type) + _ensure_manage_permission(membership.role) + if target_user_id == actor_user_id: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="User is already in chat") + target_user = await get_user_by_id(db, target_user_id) + if not target_user: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") + existing = await repository.get_chat_member(db, chat_id=chat_id, user_id=target_user_id) + if existing: + return existing + try: + member = await repository.add_chat_member( + db, + chat_id=chat_id, + user_id=target_user_id, + role=ChatMemberRole.MEMBER, + ) + await db.commit() + await db.refresh(member) + return member + except IntegrityError: + await db.rollback() + existing = await repository.get_chat_member(db, chat_id=chat_id, user_id=target_user_id) + if existing: + return existing + raise + + +async def update_chat_member_role_for_user( + db: AsyncSession, + *, + chat_id: int, + actor_user_id: int, + target_user_id: int, + role: ChatMemberRole, +) -> ChatMember: + chat, actor_membership = await _get_chat_and_membership(db, chat_id=chat_id, user_id=actor_user_id) + _ensure_group_or_channel(chat.type) + if actor_membership.role != ChatMemberRole.OWNER: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only owner can change roles") + target_membership = await repository.get_chat_member(db, chat_id=chat_id, user_id=target_user_id) + if not target_membership: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Target member not found") + if target_membership.role == ChatMemberRole.OWNER and target_user_id != actor_user_id: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Cannot change owner role") + if target_user_id == actor_user_id and role != ChatMemberRole.OWNER: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Owner cannot demote self") + target_membership.role = role + await db.commit() + await db.refresh(target_membership) + return target_membership + + +async def remove_chat_member_for_user( + db: AsyncSession, + *, + chat_id: int, + actor_user_id: int, + target_user_id: int, +) -> None: + chat, actor_membership = await _get_chat_and_membership(db, chat_id=chat_id, user_id=actor_user_id) + _ensure_group_or_channel(chat.type) + target_membership = await repository.get_chat_member(db, chat_id=chat_id, user_id=target_user_id) + if not target_membership: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Target member not found") + if target_membership.role == ChatMemberRole.OWNER: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Owner cannot be removed") + if actor_user_id == target_user_id: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Use leave endpoint") + if actor_membership.role == ChatMemberRole.ADMIN and target_membership.role != ChatMemberRole.MEMBER: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin can remove only members") + if actor_membership.role not in {ChatMemberRole.OWNER, ChatMemberRole.ADMIN}: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions") + await repository.delete_chat_member(db, target_membership) + await db.commit() + + +async def leave_chat_for_user(db: AsyncSession, *, chat_id: int, user_id: int) -> None: + chat, membership = await _get_chat_and_membership(db, chat_id=chat_id, user_id=user_id) + _ensure_group_or_channel(chat.type) + if membership.role == ChatMemberRole.OWNER: + members_count = await repository.count_chat_members(db, chat_id=chat_id) + if members_count > 1: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Owner cannot leave while chat has other members", + ) + await repository.delete_chat_member(db, membership) + await db.commit()