Add a Files section to room info listing all sent attachments (#33)

Lists files and images actually attached to sent messages in a room,
newest first, with click-through to a lightbox, preview modal, or direct
download depending on type. Queries through messages.image_id/file_id
so an upload that was never sent doesn't show up as a phantom entry.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 19:57:14 -06:00
co-authored by Claude Sonnet 5
parent fd0b3863f0
commit 5643df6bab
11 changed files with 397 additions and 14 deletions
+50 -1
View File
@@ -18,6 +18,7 @@ from app.schemas.message_file import MessageFileCreated
from app.schemas.message_image import MessageImageCreated
from app.schemas.room import (
MyRoomItem,
RoomAttachmentRead,
RoomCreate,
RoomListItem,
RoomMemberAdd,
@@ -35,7 +36,11 @@ from app.schemas.webhook import (
WebhookIncomingRead,
)
from app.services.message_events import broadcast_room_added
from app.services.message_service import get_reactions_for_messages, list_recent_messages
from app.services.message_service import (
get_reactions_for_messages,
list_recent_messages,
list_room_attachments,
)
from app.services.upload_settings_service import format_mb, get_upload_settings
from app.services.room_service import (
AlreadyMemberError,
@@ -463,6 +468,50 @@ async def get_room_file_endpoint(
)
@router.get("/{room_id}/attachments", response_model=list[RoomAttachmentRead])
async def list_room_attachments_endpoint(
room_id: uuid.UUID,
limit: int = Query(default=100, ge=1, le=200),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
await require_room_member(room_id, current_user, db)
messages = await list_room_attachments(db, room_id, limit)
attachments: list[RoomAttachmentRead] = []
for m in messages:
# A message could in principle carry both an image and a file (the
# DB doesn't forbid it, even though the composer's UI only ever
# attaches one) -- emit an entry per attachment actually present
# rather than assuming exactly one.
if m.file:
attachments.append(
RoomAttachmentRead(
id=m.file.id,
kind="file",
filename=m.file.original_filename,
content_type=m.file.content_type,
size_bytes=m.file.size_bytes,
uploaded_by=m.user.username,
message_id=m.id,
created_at=m.created_at,
)
)
if m.image:
attachments.append(
RoomAttachmentRead(
id=m.image.id,
kind="image",
filename=None,
content_type=m.image.content_type,
size_bytes=m.image.size_bytes,
uploaded_by=m.user.username,
message_id=m.id,
created_at=m.created_at,
)
)
return attachments
@router.post("/{room_id}/members", response_model=RoomMemberRead, status_code=201)
async def add_member_endpoint(
room_id: uuid.UUID,
+13
View File
@@ -60,3 +60,16 @@ class RoomMemberRoleUpdate(BaseModel):
class TransferOwnershipRequest(BaseModel):
new_owner_user_id: uuid.UUID
class RoomAttachmentRead(BaseModel):
id: uuid.UUID
kind: Literal["file", "image"]
# None for images -- MessageImage has no stored original filename,
# unlike MessageFile (see backend/app/models/message_image.py).
filename: str | None
content_type: str
size_bytes: int
uploaded_by: str
message_id: uuid.UUID
created_at: datetime
+22 -1
View File
@@ -2,7 +2,7 @@ import uuid
from collections import defaultdict
from datetime import datetime, timezone
from sqlalchemy import select
from sqlalchemy import or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
@@ -66,6 +66,27 @@ async def list_recent_messages(
return messages
async def list_room_attachments(
db: AsyncSession, room_id: uuid.UUID, limit: int = 100
) -> list[Message]:
# Joins through messages.image_id/file_id rather than querying
# message_files/message_images directly -- a file/image is uploaded (and
# gets a row) *before* the message referencing it is ever sent, so an
# upload the user abandoned without sending would otherwise show up as
# a phantom attachment the room never actually saw.
result = await db.execute(
select(Message)
.where(
Message.room_id == room_id,
or_(Message.image_id.isnot(None), Message.file_id.isnot(None)),
)
.options(selectinload(Message.user), selectinload(Message.file), selectinload(Message.image))
.order_by(Message.created_at.desc())
.limit(limit)
)
return list(result.scalars().all())
async def get_reactions_for_messages(
db: AsyncSession, message_ids: list[uuid.UUID]
) -> dict[uuid.UUID, list[ReactionSummary]]: