feat(core): clear saved chat and add message deletion scopes
Some checks failed
CI / test (push) Failing after 26s
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:
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user