feat(chats): add role-based member management APIs
All checks were successful
CI / test (push) Successful in 32s

- add owner/admin/member permission checks

- implement member add/remove, role updates, and leave flow

- add chat title update endpoint for manageable chat types
This commit is contained in:
2026-03-08 00:04:54 +03:00
parent 16a584c6cb
commit a4d7294628
4 changed files with 246 additions and 6 deletions

View File

@@ -1,4 +1,4 @@
from sqlalchemy import Select, select from sqlalchemy import Select, func, select
from sqlalchemy.orm import aliased from sqlalchemy.orm import aliased
from sqlalchemy.ext.asyncio import AsyncSession 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 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]]: def _user_chats_query(user_id: int) -> Select[tuple[Chat]]:
return ( return (
select(Chat) select(Chat)

View File

@@ -1,9 +1,26 @@
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends, status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.service import get_current_user from app.auth.service import get_current_user
from app.chats.schemas import ChatCreateRequest, ChatDetailRead, ChatRead from app.chats.schemas import (
from app.chats.service import create_chat_for_user, get_chat_for_user, get_chats_for_user 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.database.session import get_db
from app.users.models import User from app.users.models import User
@@ -43,3 +60,69 @@ async def get_chat(
created_at=chat.created_at, created_at=chat.created_at,
members=members, 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)

View File

@@ -31,3 +31,15 @@ class ChatCreateRequest(BaseModel):
type: ChatType type: ChatType
title: str | None = Field(default=None, max_length=255) title: str | None = Field(default=None, max_length=255)
member_ids: list[int] = Field(default_factory=list) 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)

View File

@@ -1,9 +1,10 @@
from fastapi import HTTPException, status from fastapi import HTTPException, status
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.chats import repository from app.chats import repository
from app.chats.models import Chat, ChatMemberRole, ChatType from app.chats.models import Chat, ChatMember, ChatMemberRole, ChatType
from app.chats.schemas import ChatCreateRequest from app.chats.schemas import ChatCreateRequest, ChatTitleUpdateRequest
from app.users.repository import get_user_by_id 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) membership = await repository.get_chat_member(db, chat_id=chat_id, user_id=user_id)
if not membership: if not membership:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="You are not a member of this chat") 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()