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

@@ -69,11 +69,38 @@ class FileService:
detail="File storage service temporarily unavailable"
)
# Create database record
# Create database record and associated message
try:
# Generate presigned download URL
download_url = minio_service.generate_presigned_url(
bucket=settings.MINIO_BUCKET,
object_path=object_path,
expiry_seconds=3600
)
# For images, the download URL also serves as thumbnail (CSS resized on frontend)
thumbnail_url = download_url if file_type == "image" else None
# Create the associated chat message first
message = FileService.create_file_reference_message(
db=db,
room_id=room_id,
sender_id=uploader_id,
file_id=file_id,
filename=file.filename,
file_type=file_type,
mime_type=mime_type,
file_size=file_size,
file_url=download_url,
thumbnail_url=thumbnail_url,
description=description
)
# Create file record with message_id reference
room_file = RoomFile(
file_id=file_id,
room_id=room_id,
message_id=message.message_id,
uploader_id=uploader_id,
filename=file.filename,
file_type=file_type,
@@ -88,20 +115,15 @@ class FileService:
db.commit()
db.refresh(room_file)
# Generate presigned download URL
download_url = minio_service.generate_presigned_url(
bucket=settings.MINIO_BUCKET,
object_path=object_path,
expiry_seconds=3600
)
return FileUploadResponse(
file_id=file_id,
message_id=message.message_id,
filename=file.filename,
file_type=file_type,
file_size=file_size,
mime_type=mime_type,
download_url=download_url,
thumbnail_url=thumbnail_url,
uploaded_at=room_file.uploaded_at,
uploader_id=uploader_id
)
@@ -160,12 +182,17 @@ class FileService:
file_id: str,
user_id: str,
is_room_owner: bool = False
) -> Optional[RoomFile]:
"""Soft delete file"""
) -> tuple[Optional[RoomFile], Optional[str]]:
"""
Soft delete file and its associated message.
Returns:
Tuple of (deleted_file, deleted_message_id) or (None, None) if not found
"""
file = db.query(RoomFile).filter(RoomFile.file_id == file_id).first()
if not file:
return None
return None, None
# Check permissions
if not is_room_owner and file.uploader_id != user_id:
@@ -174,12 +201,21 @@ class FileService:
detail="Only file uploader or room owner can delete files"
)
# Soft delete
deleted_message_id = None
# Soft delete the associated message if it exists
if file.message_id:
message = db.query(Message).filter(Message.message_id == file.message_id).first()
if message and message.deleted_at is None:
message.deleted_at = datetime.utcnow()
deleted_message_id = message.message_id
# Soft delete the file
file.deleted_at = datetime.utcnow()
db.commit()
db.refresh(file)
return file
return file, deleted_message_id
@staticmethod
def check_room_membership(db: Session, room_id: str, user_id: str) -> Optional[RoomMember]:
@@ -205,7 +241,10 @@ class FileService:
file_id: str,
filename: str,
file_type: str,
mime_type: str,
file_size: int,
file_url: str,
thumbnail_url: Optional[str] = None,
description: Optional[str] = None
) -> Message:
"""
@@ -218,7 +257,10 @@ class FileService:
file_id: File ID in room_files table
filename: Original filename
file_type: Type of file (image, document, log)
mime_type: MIME type of the file
file_size: File size in bytes
file_url: Presigned download URL
thumbnail_url: Presigned thumbnail URL for images
description: Optional description for the file
Returns:
@@ -237,9 +279,15 @@ class FileService:
"file_id": file_id,
"file_url": file_url,
"filename": filename,
"file_type": file_type
"file_type": file_type,
"mime_type": mime_type,
"file_size": file_size
}
# Add thumbnail URL for images
if thumbnail_url:
metadata["thumbnail_url"] = thumbnail_url
# Use MessageService to create the message
return MessageService.create_message(
db=db,