feat: add saved messages, public chat discovery/join, and chat delete options
All checks were successful
CI / test (push) Successful in 19s

- add Saved Messages system chat with dedicated API

- add public group/channel metadata and discover/join endpoints

- add chat delete flow with for_all option and channel-wide delete

- switch message actions to context menu and improve reply/forward visuals

- improve microphone permission handling for voice recording
This commit is contained in:
2026-03-08 00:41:35 +03:00
parent b5a7d733c6
commit b9f71b9528
12 changed files with 529 additions and 119 deletions

View File

@@ -2,7 +2,7 @@ from datetime import datetime
from enum import Enum
from typing import TYPE_CHECKING
from sqlalchemy import DateTime, Enum as SAEnum, ForeignKey, String, UniqueConstraint, func
from sqlalchemy import Boolean, DateTime, Enum as SAEnum, ForeignKey, String, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database.base import Base
@@ -30,6 +30,10 @@ class Chat(Base):
id: Mapped[int] = mapped_column(primary_key=True, index=True)
type: Mapped[ChatType] = mapped_column(SAEnum(ChatType), nullable=False, index=True)
title: Mapped[str | None] = mapped_column(String(255), nullable=True)
handle: Mapped[str | None] = mapped_column(String(64), nullable=True, unique=True, index=True)
description: Mapped[str | None] = mapped_column(String(512), nullable=True)
is_public: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
is_saved: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
pinned_message_id: Mapped[int | None] = mapped_column(ForeignKey("messages.id", ondelete="SET NULL"), nullable=True, index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)

View File

@@ -12,6 +12,29 @@ async def create_chat(db: AsyncSession, *, chat_type: ChatType, title: str | Non
return chat
async def create_chat_with_meta(
db: AsyncSession,
*,
chat_type: ChatType,
title: str | None,
handle: str | None,
description: str | None,
is_public: bool,
is_saved: bool = False,
) -> Chat:
chat = Chat(
type=chat_type,
title=title,
handle=handle,
description=description,
is_public=is_public,
is_saved=is_saved,
)
db.add(chat)
await db.flush()
return chat
async def add_chat_member(db: AsyncSession, *, chat_id: int, user_id: int, role: ChatMemberRole) -> ChatMember:
member = ChatMember(chat_id=chat_id, user_id=user_id, role=role)
db.add(member)
@@ -61,6 +84,11 @@ async def get_chat_by_id(db: AsyncSession, chat_id: int) -> Chat | None:
return result.scalar_one_or_none()
async def get_chat_by_handle(db: AsyncSession, handle: str) -> Chat | None:
result = await db.execute(select(Chat).where(Chat.handle == handle))
return result.scalar_one_or_none()
async def get_chat_member(db: AsyncSession, *, chat_id: int, user_id: int) -> ChatMember | None:
result = await db.execute(
select(ChatMember).where(
@@ -83,6 +111,38 @@ async def list_user_chat_ids(db: AsyncSession, *, user_id: int) -> list[int]:
return list(result.scalars().all())
async def find_saved_chat_for_user(db: AsyncSession, *, user_id: int) -> Chat | None:
stmt = (
select(Chat)
.join(ChatMember, ChatMember.chat_id == Chat.id)
.where(ChatMember.user_id == user_id, Chat.is_saved.is_(True))
.limit(1)
)
result = await db.execute(stmt)
return result.scalar_one_or_none()
async def discover_public_chats(
db: AsyncSession,
*,
user_id: int,
query: str | None = None,
limit: int = 30,
) -> list[tuple[Chat, bool]]:
q = select(Chat).where(Chat.is_public.is_(True), Chat.type.in_([ChatType.GROUP, ChatType.CHANNEL]), Chat.is_saved.is_(False))
if query and query.strip():
like = f"%{query.strip()}%"
q = q.where(or_(Chat.title.ilike(like), Chat.handle.ilike(like), Chat.description.ilike(like)))
q = q.order_by(Chat.id.desc()).limit(limit)
chats = list((await db.execute(q)).scalars().all())
if not chats:
return []
chat_ids = [c.id for c in chats]
m_stmt = select(ChatMember.chat_id).where(ChatMember.user_id == user_id, ChatMember.chat_id.in_(chat_ids))
memberships = set((await db.execute(m_stmt)).scalars().all())
return [(chat, chat.id in memberships) for chat in chats]
async def find_private_chat_between_users(db: AsyncSession, *, user_a_id: int, user_b_id: int) -> Chat | None:
cm_a = aliased(ChatMember)
cm_b = aliased(ChatMember)

View File

@@ -5,6 +5,8 @@ from app.auth.service import get_current_user
from app.chats.schemas import (
ChatCreateRequest,
ChatDetailRead,
ChatDiscoverRead,
ChatDeleteRequest,
ChatMemberAddRequest,
ChatMemberRead,
ChatMemberRoleUpdateRequest,
@@ -15,8 +17,12 @@ from app.chats.schemas import (
from app.chats.service import (
add_chat_member_for_user,
create_chat_for_user,
delete_chat_for_user,
discover_public_chats_for_user,
ensure_saved_messages_chat,
get_chat_for_user,
get_chats_for_user,
join_public_chat_for_user,
leave_chat_for_user,
pin_chat_message_for_user,
remove_chat_member_for_user,
@@ -40,6 +46,24 @@ async def list_chats(
return await get_chats_for_user(db, user_id=current_user.id, limit=limit, before_id=before_id, query=query)
@router.get("/saved", response_model=ChatRead)
async def get_saved_messages_chat(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> ChatRead:
return await ensure_saved_messages_chat(db, user_id=current_user.id)
@router.get("/discover", response_model=list[ChatDiscoverRead])
async def discover_chats(
query: str | None = None,
limit: int = 30,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> list[ChatDiscoverRead]:
return await discover_public_chats_for_user(db, user_id=current_user.id, query=query, limit=limit)
@router.post("", response_model=ChatRead)
async def create_chat(
payload: ChatCreateRequest,
@@ -49,6 +73,15 @@ async def create_chat(
return await create_chat_for_user(db, creator_id=current_user.id, payload=payload)
@router.post("/{chat_id}/join", response_model=ChatRead)
async def join_chat(
chat_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> ChatRead:
return await join_public_chat_for_user(db, chat_id=chat_id, user_id=current_user.id)
@router.get("/{chat_id}", response_model=ChatDetailRead)
async def get_chat(
chat_id: int,
@@ -132,6 +165,16 @@ async def leave_chat(
await leave_chat_for_user(db, chat_id=chat_id, user_id=current_user.id)
@router.delete("/{chat_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_chat(
chat_id: int,
for_all: bool = False,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> None:
await delete_chat_for_user(db, chat_id=chat_id, user_id=current_user.id, payload=ChatDeleteRequest(for_all=for_all))
@router.post("/{chat_id}/pin", response_model=ChatRead)
async def pin_chat_message(
chat_id: int,

View File

@@ -11,6 +11,10 @@ class ChatRead(BaseModel):
id: int
type: ChatType
title: str | None = None
handle: str | None = None
description: str | None = None
is_public: bool = False
is_saved: bool = False
pinned_message_id: int | None = None
created_at: datetime
@@ -31,6 +35,9 @@ class ChatDetailRead(ChatRead):
class ChatCreateRequest(BaseModel):
type: ChatType
title: str | None = Field(default=None, max_length=255)
handle: str | None = Field(default=None, max_length=64)
description: str | None = Field(default=None, max_length=512)
is_public: bool = False
member_ids: list[int] = Field(default_factory=list)
@@ -48,3 +55,11 @@ class ChatTitleUpdateRequest(BaseModel):
class ChatPinMessageRequest(BaseModel):
message_id: int | None = None
class ChatDeleteRequest(BaseModel):
for_all: bool = False
class ChatDiscoverRead(ChatRead):
is_member: bool

View File

@@ -4,7 +4,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.chats import repository
from app.chats.models import Chat, ChatMember, ChatMemberRole, ChatType
from app.chats.schemas import ChatCreateRequest, ChatPinMessageRequest, ChatTitleUpdateRequest
from app.chats.schemas import ChatCreateRequest, ChatDeleteRequest, ChatDiscoverRead, ChatPinMessageRequest, ChatTitleUpdateRequest
from app.messages.repository import get_message_by_id
from app.users.repository import get_user_by_id
@@ -31,13 +31,29 @@ async def create_chat_for_user(db: AsyncSession, *, creator_id: int, payload: Ch
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Group and channel chats require title.",
)
if payload.type == ChatType.PRIVATE and payload.is_public:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Private chat cannot be public")
if payload.is_public and payload.type in {ChatType.GROUP, ChatType.CHANNEL} and not payload.handle:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Public chat requires handle")
if payload.handle:
existing = await repository.get_chat_by_handle(db, payload.handle.strip().lower())
if existing:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Handle is already taken")
for member_id in member_ids:
user = await get_user_by_id(db, member_id)
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"User {member_id} not found")
chat = await repository.create_chat(db, chat_type=payload.type, title=payload.title)
chat = await repository.create_chat_with_meta(
db,
chat_type=payload.type,
title=payload.title,
handle=payload.handle.strip().lower() if payload.handle else None,
description=payload.description,
is_public=payload.is_public,
is_saved=False,
)
await repository.add_chat_member(db, chat_id=chat.id, user_id=creator_id, role=ChatMemberRole.OWNER)
default_role = ChatMemberRole.MEMBER
@@ -57,7 +73,13 @@ async def get_chats_for_user(
query: str | None = None,
) -> list[Chat]:
safe_limit = max(1, min(limit, 100))
return await repository.list_user_chats(db, user_id=user_id, limit=safe_limit, before_id=before_id, query=query)
chats = await repository.list_user_chats(db, user_id=user_id, limit=safe_limit, before_id=before_id, query=query)
saved = await ensure_saved_messages_chat(db, user_id=user_id)
if saved.id not in [c.id for c in chats]:
chats = [saved, *chats]
else:
chats = [saved, *[c for c in chats if c.id != saved.id]]
return chats
async def get_chat_for_user(db: AsyncSession, *, chat_id: int, user_id: int) -> tuple[Chat, list]:
@@ -222,8 +244,8 @@ async def pin_chat_message_for_user(
payload: ChatPinMessageRequest,
) -> 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)
if chat.type in {ChatType.GROUP, ChatType.CHANNEL}:
_ensure_manage_permission(membership.role)
if payload.message_id is None:
chat.pinned_message_id = None
await db.commit()
@@ -237,3 +259,71 @@ async def pin_chat_message_for_user(
await db.commit()
await db.refresh(chat)
return chat
async def ensure_saved_messages_chat(db: AsyncSession, *, user_id: int) -> Chat:
saved = await repository.find_saved_chat_for_user(db, user_id=user_id)
if saved:
return saved
chat = await repository.create_chat_with_meta(
db,
chat_type=ChatType.PRIVATE,
title="Saved Messages",
handle=None,
description="Personal cloud chat",
is_public=False,
is_saved=True,
)
await repository.add_chat_member(db, chat_id=chat.id, user_id=user_id, role=ChatMemberRole.OWNER)
await db.commit()
await db.refresh(chat)
return chat
async def discover_public_chats_for_user(db: AsyncSession, *, user_id: int, query: str | None, limit: int = 30) -> list[ChatDiscoverRead]:
rows = await repository.discover_public_chats(db, user_id=user_id, query=query, limit=max(1, min(limit, 50)))
return [
ChatDiscoverRead.model_validate(
{
"id": chat.id,
"type": chat.type,
"title": chat.title,
"handle": chat.handle,
"description": chat.description,
"is_public": chat.is_public,
"is_saved": chat.is_saved,
"pinned_message_id": chat.pinned_message_id,
"created_at": chat.created_at,
"is_member": is_member,
}
)
for chat, is_member in rows
]
async def join_public_chat_for_user(db: AsyncSession, *, chat_id: int, user_id: int) -> Chat:
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")
if not chat.is_public or chat.type not in {ChatType.GROUP, ChatType.CHANNEL}:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Chat is not joinable")
membership = await repository.get_chat_member(db, chat_id=chat_id, user_id=user_id)
if membership:
return chat
await repository.add_chat_member(db, chat_id=chat_id, user_id=user_id, role=ChatMemberRole.MEMBER)
await db.commit()
await db.refresh(chat)
return chat
async def delete_chat_for_user(db: AsyncSession, *, chat_id: int, user_id: int, payload: ChatDeleteRequest) -> None:
chat, membership = await _get_chat_and_membership(db, chat_id=chat_id, user_id=user_id)
delete_for_all = payload.for_all or chat.type == ChatType.CHANNEL
if delete_for_all:
if chat.type in {ChatType.GROUP, ChatType.CHANNEL} and membership.role not in {ChatMemberRole.OWNER, ChatMemberRole.ADMIN}:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions")
await db.delete(chat)
await db.commit()
return
await repository.delete_chat_member(db, membership)
await db.commit()