Private
Public Access
Add the ability to delete a message (#53)
Message.deleted_at has existed since the initial schema but was never wired up -- no WS envelope, no permission check, no frontend concept of it at all. Soft delete, author-only (mirrors the existing edit permission exactly): content and any attached image/file are cleared and the underlying MessageImage/MessageFile row and stored file are actually removed, not just detached, so the message becomes a "This message was deleted" tombstone with nothing left to recover through a stale attachment URL. A deleted message can no longer be edited or reacted to. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -134,6 +134,10 @@ async def _message_payload(db: AsyncSession, message: Message, username: str) ->
|
||||
"reactions": [],
|
||||
"created_at": message.created_at.isoformat(),
|
||||
"edited_at": message.edited_at.isoformat() if message.edited_at else None,
|
||||
# Always null here -- a message just being created can't already be
|
||||
# deleted -- but included for wire-format parity with MessageRead
|
||||
# and message_deleted (#53).
|
||||
"deleted_at": None,
|
||||
}
|
||||
|
||||
|
||||
@@ -204,6 +208,16 @@ async def broadcast_message_update(
|
||||
_maybe_fetch_link_preview(broadcaster, room_id, message)
|
||||
|
||||
|
||||
async def broadcast_message_delete(broadcaster: Broadcaster, room_id: uuid.UUID, message_id: uuid.UUID) -> None:
|
||||
# #53: no dispatch_event() call, deliberately -- same scope cut as
|
||||
# broadcast_reaction_update's, and for the same reason (see
|
||||
# backend/README.md): message.deleted isn't an outgoing-webhook event
|
||||
# type here.
|
||||
await broadcaster.publish(
|
||||
room_id, {"type": "message_deleted", "id": str(message_id), "room_id": str(room_id)}
|
||||
)
|
||||
|
||||
|
||||
async def broadcast_reaction_update(
|
||||
broadcaster: Broadcaster,
|
||||
room_id: uuid.UUID,
|
||||
|
||||
@@ -6,11 +6,19 @@ from sqlalchemy import or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models import Message, MessageMention, MessageReaction, MessageRoomReference
|
||||
from app.models import (
|
||||
Message,
|
||||
MessageFile,
|
||||
MessageImage,
|
||||
MessageMention,
|
||||
MessageReaction,
|
||||
MessageRoomReference,
|
||||
)
|
||||
from app.schemas.message import ReactionSummary
|
||||
from app.services.link_preview_service import extract_first_url
|
||||
from app.services.mention_service import extract_mentioned_user_ids
|
||||
from app.services.room_reference_service import extract_referenced_room_ids
|
||||
from app.storage import delete_file
|
||||
|
||||
|
||||
class MessageNotFoundError(Exception):
|
||||
@@ -56,7 +64,9 @@ async def edit_message(
|
||||
db: AsyncSession, message_id: uuid.UUID, editor_id: uuid.UUID, content: str
|
||||
) -> Message:
|
||||
message = await db.get(Message, message_id)
|
||||
if message is None:
|
||||
# A deleted message might as well not exist for editing purposes --
|
||||
# same MessageNotFoundError a genuinely missing id would raise.
|
||||
if message is None or message.deleted_at is not None:
|
||||
raise MessageNotFoundError()
|
||||
if message.user_id != editor_id:
|
||||
raise NotMessageAuthorError()
|
||||
@@ -69,6 +79,50 @@ async def edit_message(
|
||||
return message
|
||||
|
||||
|
||||
async def delete_message(db: AsyncSession, message_id: uuid.UUID, deleter_id: uuid.UUID) -> Message:
|
||||
message = await db.get(Message, message_id)
|
||||
if message is None or message.deleted_at is not None:
|
||||
raise MessageNotFoundError()
|
||||
if message.user_id != deleter_id:
|
||||
raise NotMessageAuthorError()
|
||||
|
||||
# Fetch the attachment's storage filename (if any) before clearing the
|
||||
# message's own FK to it -- the file is only unlinked from disk after a
|
||||
# successful commit below, mirroring delete_room's identical ordering:
|
||||
# a rolled-back transaction should never leave us having destroyed
|
||||
# something we couldn't get back.
|
||||
image_filename: str | None = None
|
||||
file_filename: str | None = None
|
||||
if message.image_id is not None:
|
||||
image = await db.get(MessageImage, message.image_id)
|
||||
if image is not None:
|
||||
image_filename = image.storage_filename
|
||||
await db.delete(image)
|
||||
if message.file_id is not None:
|
||||
message_file = await db.get(MessageFile, message.file_id)
|
||||
if message_file is not None:
|
||||
file_filename = message_file.storage_filename
|
||||
await db.delete(message_file)
|
||||
|
||||
# #53: a real delete, not just a UI hide -- content and any attachment
|
||||
# are actually gone, not merely unlinked-but-still-fetchable. Only
|
||||
# deleted_at (plus id/room_id/user_id/created_at, kept so the tombstone
|
||||
# still occupies its place in history) survives.
|
||||
message.content = None
|
||||
message.image_id = None
|
||||
message.file_id = None
|
||||
message.preview_url = None
|
||||
message.deleted_at = datetime.now(timezone.utc)
|
||||
await db.commit()
|
||||
await db.refresh(message)
|
||||
|
||||
for filename in (image_filename, file_filename):
|
||||
if filename is not None:
|
||||
delete_file(filename)
|
||||
|
||||
return message
|
||||
|
||||
|
||||
async def list_recent_messages(
|
||||
db: AsyncSession, room_id: uuid.UUID, limit: int = 50
|
||||
) -> list[Message]:
|
||||
|
||||
Reference in New Issue
Block a user