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

@@ -1,10 +1,17 @@
"""Pydantic schemas for WebSocket messages and REST API"""
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, ConfigDict, field_serializer
from typing import Optional, Dict, Any, List
from datetime import datetime
from enum import Enum
def serialize_datetime_utc(dt: datetime) -> str:
"""Serialize datetime with 'Z' suffix to indicate UTC"""
if dt is None:
return None
return dt.isoformat() + "Z"
class MessageTypeEnum(str, Enum):
"""Message type enumeration for validation"""
TEXT = "text"
@@ -89,6 +96,13 @@ class MessageBroadcast(BaseModel):
deleted_at: Optional[datetime] = None
sequence_number: int
@field_serializer("created_at", "edited_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 SystemMessageBroadcast(BaseModel):
"""System message broadcast"""
@@ -99,6 +113,11 @@ class SystemMessageBroadcast(BaseModel):
timestamp: datetime
data: Optional[Dict[str, Any]] = None
@field_serializer("timestamp")
def serialize_datetime(self, dt: datetime) -> str:
"""Serialize datetime with 'Z' suffix to indicate UTC"""
return dt.isoformat() + "Z"
class TypingBroadcast(BaseModel):
"""Typing indicator broadcast"""
@@ -115,6 +134,11 @@ class MessageAck(BaseModel):
sequence_number: int
timestamp: datetime
@field_serializer("timestamp")
def serialize_datetime(self, dt: datetime) -> str:
"""Serialize datetime with 'Z' suffix to indicate UTC"""
return dt.isoformat() + "Z"
class ErrorMessage(BaseModel):
"""Error message"""
@@ -145,16 +169,25 @@ class MessageResponse(BaseModel):
sender_display_name: Optional[str] = None # Display name from users table
content: str
message_type: MessageTypeEnum
metadata: Optional[Dict[str, Any]] = Field(None, alias="message_metadata")
# Use validation_alias to read from ORM's message_metadata, but serialize as "metadata"
metadata: Optional[Dict[str, Any]] = Field(None, validation_alias="message_metadata")
created_at: datetime
edited_at: Optional[datetime] = None
deleted_at: Optional[datetime] = None
sequence_number: int
reaction_counts: Optional[Dict[str, int]] = None # emoji -> count
class Config:
from_attributes = True
populate_by_name = True # Allow both 'metadata' and 'message_metadata'
model_config = ConfigDict(
from_attributes=True,
populate_by_name=True,
)
@field_serializer("created_at", "edited_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 MessageListResponse(BaseModel):
@@ -179,8 +212,12 @@ class ReactionResponse(BaseModel):
emoji: str
created_at: datetime
class Config:
from_attributes = True
model_config = ConfigDict(from_attributes=True)
@field_serializer("created_at")
def serialize_datetime(self, dt: datetime) -> str:
"""Serialize datetime with 'Z' suffix to indicate UTC"""
return dt.isoformat() + "Z"
class ReactionSummary(BaseModel):
@@ -195,12 +232,18 @@ class OnlineUser(BaseModel):
user_id: str
connected_at: datetime
@field_serializer("connected_at")
def serialize_datetime(self, dt: datetime) -> str:
"""Serialize datetime with 'Z' suffix to indicate UTC"""
return dt.isoformat() + "Z"
# File Upload WebSocket Schemas
class FileUploadedBroadcast(BaseModel):
"""Broadcast when a file is uploaded to a room"""
type: str = "file_uploaded"
file_id: str
message_id: Optional[str] = None # Associated chat message ID
room_id: str
uploader_id: str
filename: str
@@ -208,6 +251,7 @@ class FileUploadedBroadcast(BaseModel):
file_size: int
mime_type: str
download_url: Optional[str] = None
thumbnail_url: Optional[str] = None # Thumbnail URL for images
uploaded_at: datetime
def to_dict(self) -> dict:
@@ -215,6 +259,7 @@ class FileUploadedBroadcast(BaseModel):
return {
"type": self.type,
"file_id": self.file_id,
"message_id": self.message_id,
"room_id": self.room_id,
"uploader_id": self.uploader_id,
"filename": self.filename,
@@ -222,7 +267,8 @@ class FileUploadedBroadcast(BaseModel):
"file_size": self.file_size,
"mime_type": self.mime_type,
"download_url": self.download_url,
"uploaded_at": self.uploaded_at.isoformat()
"thumbnail_url": self.thumbnail_url,
"uploaded_at": self.uploaded_at.isoformat() + "Z"
}
@@ -249,6 +295,7 @@ class FileDeletedBroadcast(BaseModel):
"""Broadcast when a file is deleted from a room"""
type: str = "file_deleted"
file_id: str
message_id: Optional[str] = None # Associated chat message ID (also deleted)
room_id: str
deleted_by: str
deleted_at: datetime
@@ -258,7 +305,27 @@ class FileDeletedBroadcast(BaseModel):
return {
"type": self.type,
"file_id": self.file_id,
"message_id": self.message_id,
"room_id": self.room_id,
"deleted_by": self.deleted_by,
"deleted_at": self.deleted_at.isoformat()
"deleted_at": self.deleted_at.isoformat() + "Z"
}
class MessageDeletedBroadcast(BaseModel):
"""Broadcast when a message is deleted"""
type: str = "message_deleted"
message_id: str
room_id: str
deleted_by: str
deleted_at: datetime
def to_dict(self) -> dict:
"""Convert to dictionary for WebSocket broadcast"""
return {
"type": self.type,
"message_id": self.message_id,
"room_id": self.room_id,
"deleted_by": self.deleted_by,
"deleted_at": self.deleted_at.isoformat() + "Z"
}

View File

@@ -14,7 +14,8 @@ settings = get_settings()
def json_serializer(obj: Any) -> str:
"""Custom JSON serializer for objects not serializable by default json code"""
if isinstance(obj, datetime):
return obj.isoformat()
# Append 'Z' to indicate UTC so JavaScript parses it correctly
return obj.isoformat() + 'Z'
raise TypeError(f"Object of type {type(obj).__name__} is not JSON serializable")