feat(core): clear saved chat and add message deletion scopes
Some checks failed
CI / test (push) Failing after 26s

backend:

- add message_hidden table for per-user message hiding

- support DELETE /messages/{id}?for_all=true|false

- implement delete-for-me vs delete-for-all logic by chat type/permissions

- add POST /chats/{chat_id}/clear and route saved chat deletion to clear

web:

- saved messages action changed from delete to clear

- message context menu now supports delete modal: for me / for everyone

- add local store helpers removeMessage/clearChatMessages

- include realtime stability improvements and app error boundary
This commit is contained in:
2026-03-08 01:13:20 +03:00
parent a42f97962b
commit 7f15edcb4e
15 changed files with 486 additions and 77 deletions

View File

@@ -17,6 +17,7 @@ from app.chats.schemas import (
from app.chats.service import (
add_chat_member_for_user,
create_chat_for_user,
clear_chat_for_user,
delete_chat_for_user,
discover_public_chats_for_user,
ensure_saved_messages_chat,
@@ -182,6 +183,15 @@ async def delete_chat(
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}/clear", status_code=status.HTTP_204_NO_CONTENT)
async def clear_chat(
chat_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> None:
await clear_chat_for_user(db, chat_id=chat_id, user_id=current_user.id)
@router.post("/{chat_id}/pin", response_model=ChatRead)
async def pin_chat_message(
chat_id: int,

View File

@@ -5,7 +5,13 @@ 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, ChatDeleteRequest, ChatDiscoverRead, ChatPinMessageRequest, ChatRead, ChatTitleUpdateRequest
from app.messages.repository import get_message_by_id
from app.messages.repository import (
delete_messages_in_chat,
get_hidden_message,
get_message_by_id,
hide_message_for_user,
list_chat_message_ids,
)
from app.users.repository import get_user_by_id
@@ -349,6 +355,9 @@ async def join_public_chat_for_user(db: AsyncSession, *, chat_id: int, user_id:
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)
if chat.is_saved:
await clear_chat_for_user(db, chat_id=chat_id, user_id=user_id)
return
delete_for_all = (payload.for_all and not chat.is_saved) 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}:
@@ -358,3 +367,18 @@ async def delete_chat_for_user(db: AsyncSession, *, chat_id: int, user_id: int,
return
await repository.delete_chat_member(db, membership)
await db.commit()
async def clear_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)
if chat.is_saved:
await delete_messages_in_chat(db, chat_id=chat_id)
await db.commit()
return
message_ids = await list_chat_message_ids(db, chat_id=chat_id)
for message_id in message_ids:
already_hidden = await get_hidden_message(db, message_id=message_id, user_id=user_id)
if already_hidden:
continue
await hide_message_for_user(db, message_id=message_id, user_id=user_id)
await db.commit()

View File

@@ -2,7 +2,7 @@ from app.auth.models import EmailVerificationToken, PasswordResetToken
from app.chats.models import Chat, ChatMember
from app.email.models import EmailLog
from app.media.models import Attachment
from app.messages.models import Message, MessageIdempotencyKey, MessageReceipt
from app.messages.models import Message, MessageHidden, MessageIdempotencyKey, MessageReceipt
from app.notifications.models import NotificationLog
from app.users.models import User

View File

@@ -83,3 +83,13 @@ class MessageReceipt(Base):
onupdate=func.now(),
nullable=False,
)
class MessageHidden(Base):
__tablename__ = "message_hidden"
__table_args__ = (UniqueConstraint("message_id", "user_id", name="uq_message_hidden_message_user"),)
id: Mapped[int] = mapped_column(primary_key=True, index=True)
message_id: Mapped[int] = mapped_column(ForeignKey("messages.id", ondelete="CASCADE"), nullable=False, index=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)

View File

@@ -1,8 +1,8 @@
from sqlalchemy import select
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.chats.models import ChatMember
from app.messages.models import Message, MessageIdempotencyKey, MessageReceipt, MessageType
from app.messages.models import Message, MessageHidden, MessageIdempotencyKey, MessageReceipt, MessageType
async def create_message(
@@ -76,10 +76,18 @@ async def list_chat_messages(
db: AsyncSession,
chat_id: int,
*,
user_id: int,
limit: int = 50,
before_id: int | None = None,
) -> list[Message]:
query = select(Message).where(Message.chat_id == chat_id)
query = (
select(Message)
.outerjoin(
MessageHidden,
(MessageHidden.message_id == Message.id) & (MessageHidden.user_id == user_id),
)
.where(Message.chat_id == chat_id, MessageHidden.id.is_(None))
)
if before_id is not None:
query = query.where(Message.id < before_id)
result = await db.execute(query.order_by(Message.id.desc()).limit(limit))
@@ -97,10 +105,15 @@ async def search_messages(
stmt = (
select(Message)
.join(ChatMember, ChatMember.chat_id == Message.chat_id)
.outerjoin(
MessageHidden,
(MessageHidden.message_id == Message.id) & (MessageHidden.user_id == user_id),
)
.where(
ChatMember.user_id == user_id,
Message.text.is_not(None),
Message.text.ilike(f"%{query.strip()}%"),
MessageHidden.id.is_(None),
)
.order_by(Message.id.desc())
.limit(limit)
@@ -115,6 +128,29 @@ async def delete_message(db: AsyncSession, message: Message) -> None:
await db.delete(message)
async def hide_message_for_user(db: AsyncSession, *, message_id: int, user_id: int) -> MessageHidden:
hidden = MessageHidden(message_id=message_id, user_id=user_id)
db.add(hidden)
await db.flush()
return hidden
async def get_hidden_message(db: AsyncSession, *, message_id: int, user_id: int) -> MessageHidden | None:
result = await db.execute(
select(MessageHidden).where(MessageHidden.message_id == message_id, MessageHidden.user_id == user_id).limit(1)
)
return result.scalar_one_or_none()
async def list_chat_message_ids(db: AsyncSession, *, chat_id: int) -> list[int]:
result = await db.execute(select(Message.id).where(Message.chat_id == chat_id))
return list(result.scalars().all())
async def delete_messages_in_chat(db: AsyncSession, *, chat_id: int) -> None:
await db.execute(delete(Message).where(Message.chat_id == chat_id))
async def get_message_receipt(db: AsyncSession, *, chat_id: int, user_id: int) -> MessageReceipt | None:
result = await db.execute(
select(MessageReceipt).where(

View File

@@ -4,7 +4,15 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.service import get_current_user
from app.database.session import get_db
from app.messages.schemas import MessageCreateRequest, MessageForwardRequest, MessageRead, MessageStatusUpdateRequest, MessageUpdateRequest
from app.messages.service import create_chat_message, delete_message, forward_message, get_messages, search_messages, update_message
from app.messages.service import (
create_chat_message,
delete_message,
delete_message_for_all,
forward_message,
get_messages,
search_messages,
update_message,
)
from app.realtime.schemas import MessageStatusPayload
from app.realtime.service import realtime_gateway
from app.users.models import User
@@ -62,9 +70,13 @@ async def edit_message(
@router.delete("/{message_id}", status_code=status.HTTP_204_NO_CONTENT)
async def remove_message(
message_id: int,
for_all: bool = False,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> None:
if for_all:
await delete_message_for_all(db, message_id=message_id, user_id=current_user.id)
return
await delete_message(db, message_id=message_id, user_id=current_user.id)

View File

@@ -2,6 +2,8 @@ from fastapi import HTTPException, status
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.chats import repository as chats_repository
from app.chats.models import ChatMemberRole, ChatType
from app.chats.service import ensure_chat_membership
from app.messages import repository
from app.messages.models import Message
@@ -79,7 +81,7 @@ async def get_messages(
) -> list[Message]:
await ensure_chat_membership(db, chat_id=chat_id, user_id=user_id)
safe_limit = max(1, min(limit, 100))
return await repository.list_chat_messages(db, chat_id, limit=safe_limit, before_id=before_id)
return await repository.list_chat_messages(db, chat_id, user_id=user_id, limit=safe_limit, before_id=before_id)
async def search_messages(
@@ -129,8 +131,48 @@ async def delete_message(db: AsyncSession, *, message_id: int, user_id: int) ->
if not message:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Message not found")
await ensure_chat_membership(db, chat_id=message.chat_id, user_id=user_id)
if message.sender_id != user_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="You can delete only your own messages")
chat = await chats_repository.get_chat_by_id(db, message.chat_id)
if not chat:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Chat not found")
membership = await chats_repository.get_chat_member(db, chat_id=message.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")
# Telegram-like default: delete only for current user.
hidden = await repository.get_hidden_message(db, message_id=message.id, user_id=user_id)
if not hidden:
try:
await repository.hide_message_for_user(db, message_id=message.id, user_id=user_id)
except IntegrityError:
await db.rollback()
return
await db.commit()
async def delete_message_for_all(db: AsyncSession, *, message_id: int, user_id: int) -> None:
message = await repository.get_message_by_id(db, message_id)
if not message:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Message not found")
await ensure_chat_membership(db, chat_id=message.chat_id, user_id=user_id)
chat = await chats_repository.get_chat_by_id(db, message.chat_id)
if not chat:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Chat not found")
membership = await chats_repository.get_chat_member(db, chat_id=message.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")
if chat.is_saved:
await delete_message(db, message_id=message_id, user_id=user_id)
return
can_delete_for_all = False
if chat.type == ChatType.PRIVATE:
can_delete_for_all = True
elif message.sender_id == user_id:
can_delete_for_all = True
elif chat.type in {ChatType.GROUP, ChatType.CHANNEL} and membership.role in {ChatMemberRole.OWNER, ChatMemberRole.ADMIN}:
can_delete_for_all = True
if not can_delete_for_all:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions for delete-for-all")
await repository.delete_message(db, message)
await db.commit()