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

@@ -16,6 +16,9 @@ class RoomFile(Base):
# Foreign key to incident room (CASCADE delete when room is permanently deleted)
room_id = Column(String(36), ForeignKey("tr_incident_rooms.room_id", ondelete="CASCADE"), nullable=False)
# Foreign key to associated message (nullable for legacy files)
message_id = Column(String(36), ForeignKey("tr_messages.message_id", ondelete="SET NULL"), nullable=True)
# File metadata
uploader_id = Column(String(255), nullable=False)
filename = Column(String(255), nullable=False)
@@ -33,11 +36,13 @@ class RoomFile(Base):
# Relationships
room = relationship("IncidentRoom", back_populates="files")
message = relationship("Message", backref="file_attachment", uselist=False)
# Indexes
__table_args__ = (
Index("ix_tr_room_files_room_uploaded", "room_id", "uploaded_at"),
Index("ix_tr_room_files_uploader", "uploader_id"),
Index("ix_tr_room_files_message", "message_id"),
)
def __repr__(self):

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

View File

@@ -1,5 +1,5 @@
"""Pydantic schemas for file storage operations"""
from pydantic import BaseModel, Field, field_validator
from pydantic import BaseModel, Field, field_validator, field_serializer, ConfigDict
from typing import Optional, List
from datetime import datetime
from enum import Enum
@@ -15,21 +15,28 @@ class FileType(str, Enum):
class FileUploadResponse(BaseModel):
"""Response after successful file upload"""
file_id: str
message_id: Optional[str] = None # Associated chat message ID
filename: str
file_type: FileType
file_size: int
mime_type: str
download_url: str # Presigned URL
thumbnail_url: Optional[str] = None # Thumbnail URL for images
uploaded_at: datetime
uploader_id: str
class Config:
from_attributes = True
model_config = ConfigDict(from_attributes=True)
@field_serializer("uploaded_at")
def serialize_datetime(self, dt: datetime) -> str:
"""Serialize datetime with 'Z' suffix to indicate UTC"""
return dt.isoformat() + "Z"
class FileMetadata(BaseModel):
"""File metadata response"""
file_id: str
message_id: Optional[str] = None # Associated chat message ID
room_id: str
filename: str
file_type: FileType
@@ -41,9 +48,9 @@ class FileMetadata(BaseModel):
uploader_id: str
deleted_at: Optional[datetime] = None
download_url: Optional[str] = None # Presigned URL (only when requested)
thumbnail_url: Optional[str] = None # Thumbnail URL for images
class Config:
from_attributes = True
model_config = ConfigDict(from_attributes=True)
@field_validator("file_size")
@classmethod
@@ -53,6 +60,13 @@ class FileMetadata(BaseModel):
raise ValueError("File size must be positive")
return v
@field_serializer("uploaded_at", "deleted_at")
def serialize_datetime(self, dt: Optional[datetime]) -> Optional[str]:
"""Serialize datetime with 'Z' suffix to indicate UTC"""
if dt is None:
return None
return dt.isoformat() + "Z"
class FileListResponse(BaseModel):
"""Paginated file list response"""
@@ -62,13 +76,11 @@ class FileListResponse(BaseModel):
offset: int
has_more: bool
class Config:
from_attributes = True
model_config = ConfigDict(from_attributes=True)
class FileUploadParams(BaseModel):
"""Parameters for file upload (optional description)"""
description: Optional[str] = Field(None, max_length=500)
class Config:
from_attributes = True
model_config = ConfigDict(from_attributes=True)

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,

View File

@@ -1,7 +1,8 @@
"""File validation utilities"""
import magic
import os
from fastapi import UploadFile, HTTPException
from typing import Set
from typing import Set, Dict
import logging
from app.core.config import get_settings
@@ -17,7 +18,15 @@ IMAGE_TYPES: Set[str] = {
}
DOCUMENT_TYPES: Set[str] = {
"application/pdf"
"application/pdf",
"application/x-pdf", # Some systems detect PDF as x-pdf
}
# Extensions that can be accepted even if MIME detection fails
EXTENSION_FALLBACK: Dict[str, str] = {
".pdf": "application/pdf",
".doc": "application/msword",
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
}
LOG_TYPES: Set[str] = {
@@ -67,6 +76,17 @@ def validate_file_type(file: UploadFile, allowed_types: Set[str]) -> str:
detected_mime = detect_mime_type(header)
if detected_mime not in allowed_types:
# Try extension fallback for known safe file types
filename = file.filename or ""
_, ext = os.path.splitext(filename.lower())
if ext in EXTENSION_FALLBACK:
logger.info(
f"MIME detection returned {detected_mime} for {filename}, "
f"using extension fallback: {EXTENSION_FALLBACK[ext]}"
)
return EXTENSION_FALLBACK[ext]
raise HTTPException(
status_code=400,
detail=f"File type not allowed: {detected_mime}. Allowed types: {', '.join(allowed_types)}"
@@ -115,9 +135,12 @@ def get_file_type_and_limits(mime_type: str) -> tuple[str, int]:
Raises:
HTTPException if MIME type not recognized
"""
# Include extension fallback types as documents
document_types_extended = DOCUMENT_TYPES | set(EXTENSION_FALLBACK.values())
if mime_type in IMAGE_TYPES:
return ("image", settings.get_image_max_size_bytes())
elif mime_type in DOCUMENT_TYPES:
elif mime_type in document_types_extended:
return ("document", settings.get_document_max_size_bytes())
elif mime_type in LOG_TYPES:
return ("log", settings.get_log_max_size_bytes())