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

@@ -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()