feat: Improve file display, timezone handling, and LOT management

Changes:
- Fix datetime serialization with UTC 'Z' suffix for correct timezone display
- Add PDF upload support with extension fallback for MIME detection
- Fix LOT add/remove by creating new list for SQLAlchemy JSON change detection
- Add file message components (FileMessage, ImageLightbox, UploadPreview)
- Add multi-file upload support with progress tracking
- Link uploaded files to chat messages via message_id
- Include file attachments in AI report generation
- Update specs for file-storage, realtime-messaging, and ai-report-generation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
egg
2025-12-08 12:39:15 +08:00
parent 599802b818
commit 44822a561a
36 changed files with 2252 additions and 156 deletions

View File

@@ -19,7 +19,8 @@ from app.modules.file_storage.schemas import FileUploadResponse, FileMetadata, F
from app.modules.file_storage.services.file_service import FileService
from app.modules.file_storage.services import minio_service
from app.modules.realtime.websocket_manager import manager as websocket_manager
from app.modules.realtime.schemas import FileUploadedBroadcast, FileDeletedBroadcast, FileUploadAck
from app.modules.realtime.schemas import FileUploadedBroadcast, FileDeletedBroadcast, FileUploadAck, MessageDeletedBroadcast, MessageBroadcast, MessageTypeEnum
from app.modules.realtime.services.message_service import MessageService
logger = logging.getLogger(__name__)
@@ -58,11 +59,52 @@ async def upload_file(
# Upload file
result = FileService.upload_file(db, room_id, user_email, file, description)
# Fetch the message and display name for broadcasting (before background task)
message_obj = MessageService.get_message(db, result.message_id) if result.message_id else None
display_name = MessageService.get_display_name(db, user_email)
# Prepare message broadcast data (needed before db session closes)
message_data = None
if message_obj:
message_data = {
"message_id": message_obj.message_id,
"room_id": message_obj.room_id,
"sender_id": message_obj.sender_id,
"sender_display_name": display_name,
"content": message_obj.content,
"message_type": message_obj.message_type.value,
"metadata": message_obj.message_metadata,
"created_at": message_obj.created_at,
"sequence_number": message_obj.sequence_number,
}
# Broadcast file upload event to room members via WebSocket
async def broadcast_file_upload():
try:
# First, broadcast the message event so it appears in chat
if message_data:
logger.info(f"Broadcasting message for file upload. message_data: {message_data}")
msg_broadcast = MessageBroadcast(
type="message",
message_id=message_data["message_id"],
room_id=message_data["room_id"],
sender_id=message_data["sender_id"],
sender_display_name=message_data["sender_display_name"],
content=message_data["content"],
message_type=MessageTypeEnum(message_data["message_type"]),
metadata=message_data["metadata"],
created_at=message_data["created_at"],
sequence_number=message_data["sequence_number"],
)
broadcast_dict = msg_broadcast.model_dump(mode='json')
logger.info(f"Message broadcast dict: {broadcast_dict}")
await websocket_manager.broadcast_to_room(room_id, broadcast_dict)
logger.info(f"Broadcasted file message: {message_data['message_id']} to room {room_id}")
# Then broadcast file uploaded event (for file drawer updates)
broadcast = FileUploadedBroadcast(
file_id=result.file_id,
message_id=result.message_id,
room_id=room_id,
uploader_id=user_email,
filename=result.filename,
@@ -70,10 +112,11 @@ async def upload_file(
file_size=result.file_size,
mime_type=result.mime_type,
download_url=result.download_url,
thumbnail_url=result.thumbnail_url,
uploaded_at=result.uploaded_at
)
await websocket_manager.broadcast_to_room(room_id, broadcast.to_dict())
logger.info(f"Broadcasted file upload event: {result.file_id} to room {room_id}")
logger.info(f"Broadcasted file upload event: {result.file_id} (message: {result.message_id}) to room {room_id}")
# Send acknowledgment to uploader
ack = FileUploadAck(
@@ -86,7 +129,7 @@ async def upload_file(
logger.error(f"Failed to broadcast file upload: {e}")
# Run broadcast in background
background_tasks.add_task(asyncio.create_task, broadcast_file_upload())
background_tasks.add_task(broadcast_file_upload)
return result
@@ -149,9 +192,13 @@ async def get_file(
expiry_seconds=3600
)
# For images, the download URL also serves as thumbnail (CSS resized on frontend)
thumbnail_url = download_url if file_record.file_type == "image" else None
# Build response with download URL
return FileMetadata(
file_id=file_record.file_id,
message_id=file_record.message_id,
room_id=file_record.room_id,
filename=file_record.filename,
file_type=file_record.file_type,
@@ -162,7 +209,8 @@ async def get_file(
uploaded_at=file_record.uploaded_at,
uploader_id=file_record.uploader_id,
deleted_at=file_record.deleted_at,
download_url=download_url
download_url=download_url,
thumbnail_url=thumbnail_url
)
@@ -204,25 +252,38 @@ async def delete_file(
# Check if admin
is_admin = membership_service.is_system_admin(user_email)
# Delete file (service will verify permissions)
deleted_file = FileService.delete_file(db, file_id, user_email, is_room_owner or is_admin)
# Delete file (service will verify permissions and cascade to message)
deleted_file, deleted_message_id = FileService.delete_file(db, file_id, user_email, is_room_owner or is_admin)
# Broadcast file deletion event to room members via WebSocket
# Broadcast file and message deletion events to room members via WebSocket
if deleted_file:
async def broadcast_file_delete():
try:
broadcast = FileDeletedBroadcast(
# Broadcast file deleted event
file_broadcast = FileDeletedBroadcast(
file_id=file_id,
message_id=deleted_message_id,
room_id=room_id,
deleted_by=user_email,
deleted_at=deleted_file.deleted_at
)
await websocket_manager.broadcast_to_room(room_id, broadcast.to_dict())
await websocket_manager.broadcast_to_room(room_id, file_broadcast.to_dict())
logger.info(f"Broadcasted file deletion event: {file_id} from room {room_id}")
# Also broadcast message deleted event if there was an associated message
if deleted_message_id:
msg_broadcast = MessageDeletedBroadcast(
message_id=deleted_message_id,
room_id=room_id,
deleted_by=user_email,
deleted_at=deleted_file.deleted_at
)
await websocket_manager.broadcast_to_room(room_id, msg_broadcast.to_dict())
logger.info(f"Broadcasted message deletion event: {deleted_message_id} from room {room_id}")
except Exception as e:
logger.error(f"Failed to broadcast file deletion: {e}")
logger.error(f"Failed to broadcast file/message deletion: {e}")
# Run broadcast in background
background_tasks.add_task(asyncio.create_task, broadcast_file_delete())
background_tasks.add_task(broadcast_file_delete)
return None