diff --git a/alembic/versions/a1b2c3d4e5f6_add_message_id_to_room_files.py b/alembic/versions/a1b2c3d4e5f6_add_message_id_to_room_files.py new file mode 100644 index 0000000..1a9990e --- /dev/null +++ b/alembic/versions/a1b2c3d4e5f6_add_message_id_to_room_files.py @@ -0,0 +1,45 @@ +"""add message_id to room_files + +Revision ID: a1b2c3d4e5f6 +Revises: 4c5eb6e941db +Create Date: 2025-12-08 12:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'a1b2c3d4e5f6' +down_revision = '4c5eb6e941db' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Add message_id column to tr_room_files table + op.add_column('tr_room_files', sa.Column('message_id', sa.String(36), nullable=True)) + + # Add foreign key constraint + op.create_foreign_key( + 'fk_room_files_message_id', + 'tr_room_files', + 'tr_messages', + ['message_id'], + ['message_id'], + ondelete='SET NULL' + ) + + # Add index for message_id + op.create_index('ix_tr_room_files_message', 'tr_room_files', ['message_id']) + + +def downgrade() -> None: + # Remove index + op.drop_index('ix_tr_room_files_message', table_name='tr_room_files') + + # Remove foreign key constraint + op.drop_constraint('fk_room_files_message_id', 'tr_room_files', type_='foreignkey') + + # Remove column + op.drop_column('tr_room_files', 'message_id') diff --git a/app/main.py b/app/main.py index 9f4fa13..0d3be02 100644 --- a/app/main.py +++ b/app/main.py @@ -3,12 +3,37 @@ 生產線異常即時反應系統 (Task Reporter) """ import os +import json from pathlib import Path +from datetime import datetime from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles -from fastapi.responses import FileResponse +from fastapi.responses import FileResponse, JSONResponse from app.core.config import get_settings + + +class UTCDateTimeEncoder(json.JSONEncoder): + """Custom JSON encoder that formats datetime with 'Z' suffix for UTC""" + def default(self, obj): + if isinstance(obj, datetime): + return obj.isoformat() + 'Z' + return super().default(obj) + + +class UTCJSONResponse(JSONResponse): + """JSONResponse that uses UTCDateTimeEncoder""" + def render(self, content) -> bytes: + return json.dumps( + content, + ensure_ascii=False, + allow_nan=False, + indent=None, + separators=(",", ":"), + cls=UTCDateTimeEncoder, + ).encode("utf-8") + + from app.modules.auth import router as auth_router from app.modules.auth.users_router import router as users_router from app.modules.auth.middleware import auth_middleware @@ -26,12 +51,13 @@ settings = get_settings() # Database tables are managed by Alembic migrations # Run: alembic upgrade head -# Initialize FastAPI app +# Initialize FastAPI app with custom JSON response for UTC datetime app = FastAPI( title="Task Reporter API", description="Production Line Incident Response System - 生產線異常即時反應系統", version="1.0.0", debug=settings.DEBUG, + default_response_class=UTCJSONResponse, ) # CORS middleware - origins configured via CORS_ORIGINS environment variable diff --git a/app/modules/chat_room/models.py b/app/modules/chat_room/models.py index aeb4b0a..3084392 100644 --- a/app/modules/chat_room/models.py +++ b/app/modules/chat_room/models.py @@ -79,6 +79,7 @@ class IncidentRoom(Base): # Relationships members = relationship("RoomMember", back_populates="room", cascade="all, delete-orphan") files = relationship("RoomFile", back_populates="room", cascade="all, delete-orphan") + reports = relationship("GeneratedReport", back_populates="room", cascade="all, delete-orphan") # Indexes for common queries __table_args__ = ( diff --git a/app/modules/chat_room/router.py b/app/modules/chat_room/router.py index efb4f0e..3fb659b 100644 --- a/app/modules/chat_room/router.py +++ b/app/modules/chat_room/router.py @@ -193,7 +193,7 @@ async def permanent_delete_room( "type": "system", "event": "room_deleted", "room_id": room_id, - "timestamp": datetime.utcnow().isoformat() + "timestamp": datetime.utcnow().isoformat() + "Z" }) success, error = room_service.permanent_delete_room(db, room_id) @@ -246,7 +246,7 @@ async def join_room( detail={ "message": "Already a member of this room", "current_role": existing.role.value, - "added_at": existing.added_at.isoformat() + "added_at": existing.added_at.isoformat() + "Z" } ) @@ -505,12 +505,12 @@ async def add_lot( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Room not found") # Get current lots or initialize empty list - current_lots = room.lots or [] + current_lots = list(room.lots or []) # Create a new list to ensure change detection # Prevent duplicates if request.lot not in current_lots: current_lots.append(request.lot) - room.lots = current_lots + room.lots = current_lots # Assign new list triggers SQLAlchemy change detection room.last_updated_at = datetime.utcnow() db.commit() db.refresh(room) @@ -532,11 +532,11 @@ async def remove_lot( if not room: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Room not found") - current_lots = room.lots or [] + current_lots = list(room.lots or []) # Create a new list to ensure change detection if lot in current_lots: current_lots.remove(lot) - room.lots = current_lots + room.lots = current_lots # Assign new list triggers SQLAlchemy change detection room.last_updated_at = datetime.utcnow() db.commit() db.refresh(room) diff --git a/app/modules/chat_room/schemas.py b/app/modules/chat_room/schemas.py index 3d18f5c..e87cac4 100644 --- a/app/modules/chat_room/schemas.py +++ b/app/modules/chat_room/schemas.py @@ -2,7 +2,7 @@ Request and response models for API endpoints """ -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, ConfigDict, field_serializer from typing import Optional, List from datetime import datetime from enum import Enum @@ -98,8 +98,14 @@ class MemberResponse(BaseModel): added_at: datetime removed_at: Optional[datetime] = None - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) + + @field_serializer("added_at", "removed_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 RoomResponse(BaseModel): @@ -127,8 +133,17 @@ class RoomResponse(BaseModel): is_member: bool = False is_admin_view: bool = False - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) + + @field_serializer( + "created_at", "resolved_at", "archived_at", + "last_activity_at", "last_updated_at", "ownership_transferred_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 RoomListResponse(BaseModel): diff --git a/app/modules/chat_room/services/room_service.py b/app/modules/chat_room/services/room_service.py index ab557ac..293ee28 100644 --- a/app/modules/chat_room/services/room_service.py +++ b/app/modules/chat_room/services/room_service.py @@ -467,7 +467,11 @@ class RoomService: f"Failed to delete report file: {report.docx_storage_path}" ) - # Step 3: Delete room from database (CASCADE handles related tables) + # Step 3: Delete reports from database (before room delete due to relationship handling) + for report in reports: + db.delete(report) + + # Step 4: Delete room from database (CASCADE handles other related tables) db.delete(room) db.commit() diff --git a/app/modules/file_storage/models.py b/app/modules/file_storage/models.py index 94f8571..37b733e 100644 --- a/app/modules/file_storage/models.py +++ b/app/modules/file_storage/models.py @@ -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): diff --git a/app/modules/file_storage/router.py b/app/modules/file_storage/router.py index b2ac04f..9195bd0 100644 --- a/app/modules/file_storage/router.py +++ b/app/modules/file_storage/router.py @@ -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 diff --git a/app/modules/file_storage/schemas.py b/app/modules/file_storage/schemas.py index da3b6f0..c3fc3af 100644 --- a/app/modules/file_storage/schemas.py +++ b/app/modules/file_storage/schemas.py @@ -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) diff --git a/app/modules/file_storage/services/file_service.py b/app/modules/file_storage/services/file_service.py index b67e1b9..ca23705 100644 --- a/app/modules/file_storage/services/file_service.py +++ b/app/modules/file_storage/services/file_service.py @@ -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, diff --git a/app/modules/file_storage/validators.py b/app/modules/file_storage/validators.py index 02f84ce..b639a79 100644 --- a/app/modules/file_storage/validators.py +++ b/app/modules/file_storage/validators.py @@ -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()) diff --git a/app/modules/realtime/schemas.py b/app/modules/realtime/schemas.py index 0acbfc6..fbf3640 100644 --- a/app/modules/realtime/schemas.py +++ b/app/modules/realtime/schemas.py @@ -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" } diff --git a/app/modules/realtime/websocket_manager.py b/app/modules/realtime/websocket_manager.py index 1d66ccb..0e1b7f4 100644 --- a/app/modules/realtime/websocket_manager.py +++ b/app/modules/realtime/websocket_manager.py @@ -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") diff --git a/app/modules/report_generation/models.py b/app/modules/report_generation/models.py index 6b66fda..65c609b 100644 --- a/app/modules/report_generation/models.py +++ b/app/modules/report_generation/models.py @@ -90,7 +90,7 @@ class GeneratedReport(Base): ) # Relationship - room = relationship("IncidentRoom", backref="reports") + room = relationship("IncidentRoom", back_populates="reports") # Indexes __table_args__ = ( diff --git a/app/modules/report_generation/prompts.py b/app/modules/report_generation/prompts.py index f0ab2aa..eebc0a0 100644 --- a/app/modules/report_generation/prompts.py +++ b/app/modules/report_generation/prompts.py @@ -4,7 +4,18 @@ Contains the prompt construction logic for building the user query sent to DIFY Chat API. """ from typing import List, Dict, Any -from datetime import datetime +from datetime import datetime, timezone, timedelta + +# Taiwan timezone (GMT+8) +TZ_GMT8 = timezone(timedelta(hours=8)) + + +def _to_gmt8(dt: datetime) -> datetime: + """Convert datetime to GMT+8 timezone""" + if dt.tzinfo is None: + # Assume UTC if no timezone + dt = dt.replace(tzinfo=timezone.utc) + return dt.astimezone(TZ_GMT8) INCIDENT_TYPE_MAP = { @@ -81,11 +92,11 @@ def _format_room_info(room_data: Dict[str, Any]) -> str: created_at = room_data.get("created_at") if isinstance(created_at, datetime): - created_at = created_at.strftime("%Y-%m-%d %H:%M") + created_at = _to_gmt8(created_at).strftime("%Y-%m-%d %H:%M") resolved_at = room_data.get("resolved_at") if isinstance(resolved_at, datetime): - resolved_at = resolved_at.strftime("%Y-%m-%d %H:%M") + resolved_at = _to_gmt8(resolved_at).strftime("%Y-%m-%d %H:%M") elif resolved_at is None: resolved_at = "尚未解決" @@ -145,7 +156,7 @@ def _format_messages(messages: List[Dict[str, Any]]) -> str: created_at = msg.get("created_at") if isinstance(created_at, datetime): - time_str = created_at.strftime("%Y-%m-%d %H:%M") + time_str = _to_gmt8(created_at).strftime("%Y-%m-%d %H:%M") else: time_str = str(created_at) if created_at else "未知時間" @@ -164,26 +175,58 @@ def _format_messages(messages: List[Dict[str, Any]]) -> str: def _format_files(files: List[Dict[str, Any]]) -> str: - """Format file attachments section""" + """Format file attachments section with context + + Each file now includes: + - caption: User-provided description when uploading + - context_before: The message sent before this file + - context_after: The message sent after this file + + This helps AI understand the context of each attachment. + """ lines = ["## 附件清單"] + lines.append("每個附件包含上傳時的說明文字以及上下文訊息,幫助理解該附件的用途。") + lines.append("") if not files: lines.append("無附件") return "\n".join(lines) - for f in files: + for i, f in enumerate(files, 1): filename = f.get("filename", "未命名檔案") file_type = f.get("file_type", "file") uploader = f.get("uploader_name") or f.get("uploaded_by", "未知") + caption = f.get("caption") # User-provided description + context_before = f.get("context_before") + context_after = f.get("context_after") uploaded_at = f.get("uploaded_at") if isinstance(uploaded_at, datetime): - time_str = uploaded_at.strftime("%Y-%m-%d %H:%M") + time_str = _to_gmt8(uploaded_at).strftime("%Y-%m-%d %H:%M") else: time_str = str(uploaded_at) if uploaded_at else "" type_label = "圖片" if file_type == "image" else "檔案" - lines.append(f"- [{type_label}] {filename} (由 {uploader} 於 {time_str} 上傳)") + + # Basic file info + lines.append(f"### 附件 {i}: {filename}") + lines.append(f"- 類型: {type_label}") + lines.append(f"- 上傳者: {uploader}") + lines.append(f"- 上傳時間: {time_str}") + + # Caption/description if provided + if caption: + lines.append(f"- 說明: {caption}") + + # Context messages to help AI understand when/why file was uploaded + if context_before or context_after: + lines.append("- 上下文:") + if context_before: + lines.append(f" - 前一則訊息: [{context_before['sender']}]: {context_before['content']}") + if context_after: + lines.append(f" - 後一則訊息: [{context_after['sender']}]: {context_after['content']}") + + lines.append("") # Blank line between files return "\n".join(lines) diff --git a/app/modules/report_generation/schemas.py b/app/modules/report_generation/schemas.py index c37f72d..af1a94d 100644 --- a/app/modules/report_generation/schemas.py +++ b/app/modules/report_generation/schemas.py @@ -2,7 +2,7 @@ Request and response models for the report generation endpoints. """ -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, ConfigDict, field_serializer from typing import Optional, List from datetime import datetime from enum import Enum @@ -45,8 +45,12 @@ class ReportStatusResponse(BaseModel): prompt_tokens: Optional[int] = None completion_tokens: Optional[int] = None - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) + + @field_serializer("generated_at") + def serialize_datetime(self, dt: datetime) -> str: + """Serialize datetime with 'Z' suffix to indicate UTC""" + return dt.isoformat() + "Z" class ReportListItem(BaseModel): @@ -57,8 +61,12 @@ class ReportListItem(BaseModel): status: ReportStatus report_title: Optional[str] = None - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) + + @field_serializer("generated_at") + def serialize_datetime(self, dt: datetime) -> str: + """Serialize datetime with 'Z' suffix to indicate UTC""" + return dt.isoformat() + "Z" class ReportListResponse(BaseModel): diff --git a/app/modules/report_generation/services/docx_service.py b/app/modules/report_generation/services/docx_service.py index d03805f..50ecc59 100644 --- a/app/modules/report_generation/services/docx_service.py +++ b/app/modules/report_generation/services/docx_service.py @@ -9,8 +9,21 @@ Creates .docx reports using python-docx with: import io import logging from typing import Dict, Any, List, Optional -from datetime import datetime +from datetime import datetime, timezone, timedelta from docx import Document + +# Taiwan timezone (GMT+8) +TZ_GMT8 = timezone(timedelta(hours=8)) + + +def _to_gmt8(dt: datetime) -> datetime: + """Convert datetime to GMT+8 timezone""" + if dt.tzinfo is None: + # Assume UTC if no timezone + dt = dt.replace(tzinfo=timezone.utc) + return dt.astimezone(TZ_GMT8) + + from docx.shared import Inches, Pt, RGBColor from docx.enum.text import WD_ALIGN_PARAGRAPH from docx.enum.style import WD_STYLE_TYPE @@ -128,11 +141,11 @@ class DocxAssemblyService: run.font.size = TITLE_SIZE run.font.bold = True - # Add generation timestamp - timestamp = datetime.utcnow().strftime("%Y-%m-%d %H:%M") + # Add generation timestamp in GMT+8 + timestamp = datetime.now(TZ_GMT8).strftime("%Y-%m-%d %H:%M") subtitle = doc.add_paragraph() subtitle.alignment = WD_ALIGN_PARAGRAPH.CENTER - run = subtitle.add_run(f"報告產生時間:{timestamp}") + run = subtitle.add_run(f"報告產生時間:{timestamp} (GMT+8)") run.font.size = Pt(10) run.font.color.rgb = RGBColor(128, 128, 128) @@ -160,19 +173,19 @@ class DocxAssemblyService: cells[2].text = "發生地點" cells[3].text = room_data.get("location") or "未指定" - # Row 3: Created and Resolved times + # Row 3: Created and Resolved times (in GMT+8) cells = table.rows[2].cells cells[0].text = "建立時間" created_at = room_data.get("created_at") if isinstance(created_at, datetime): - cells[1].text = created_at.strftime("%Y-%m-%d %H:%M") + cells[1].text = _to_gmt8(created_at).strftime("%Y-%m-%d %H:%M") else: cells[1].text = str(created_at) if created_at else "未知" cells[2].text = "解決時間" resolved_at = room_data.get("resolved_at") if isinstance(resolved_at, datetime): - cells[3].text = resolved_at.strftime("%Y-%m-%d %H:%M") + cells[3].text = _to_gmt8(resolved_at).strftime("%Y-%m-%d %H:%M") elif resolved_at: cells[3].text = str(resolved_at) else: @@ -327,13 +340,24 @@ class DocxAssemblyService: # Add image to document doc.add_picture(image_data, width=Inches(5)) - # Add caption + # Add caption (user-provided description or filename) + user_caption = f.get("caption") # User-provided description + filename = f.get("filename", "圖片") + caption = doc.add_paragraph() caption.alignment = WD_ALIGN_PARAGRAPH.CENTER - run = caption.add_run(f"{f.get('filename', '圖片')}") + + # Show filename first + run = caption.add_run(filename) run.font.size = Pt(9) run.font.italic = True + # Add user caption if provided + if user_caption: + caption.add_run("\n") + desc_run = caption.add_run(user_caption) + desc_run.font.size = Pt(10) + doc.add_paragraph() # Spacing else: # Image download failed, add note @@ -344,7 +368,7 @@ class DocxAssemblyService: doc.add_paragraph(f"[圖片嵌入失敗: {f.get('filename', '未知')}]") def _add_file_list_section(self, doc: Document, files: List[Dict[str, Any]]): - """Add file attachment list section""" + """Add file attachment list section with captions""" doc.add_heading("附件清單", level=1) if not files: @@ -352,7 +376,7 @@ class DocxAssemblyService: return # Create file list table - table = doc.add_table(rows=len(files) + 1, cols=4) + table = doc.add_table(rows=len(files) + 1, cols=5) table.style = "Table Grid" # Header row @@ -361,6 +385,7 @@ class DocxAssemblyService: header[1].text = "類型" header[2].text = "上傳者" header[3].text = "上傳時間" + header[4].text = "說明" for cell in header: for run in cell.paragraphs[0].runs: run.font.bold = True @@ -382,10 +407,13 @@ class DocxAssemblyService: uploaded_at = f.get("uploaded_at") if isinstance(uploaded_at, datetime): - row[3].text = uploaded_at.strftime("%Y-%m-%d %H:%M") + row[3].text = _to_gmt8(uploaded_at).strftime("%Y-%m-%d %H:%M") else: row[3].text = str(uploaded_at) if uploaded_at else "" + # Caption/description column + row[4].text = f.get("caption", "") or "" + def _download_file(self, object_path: str) -> Optional[io.BytesIO]: """Download file from MinIO @@ -431,9 +459,9 @@ class DocxAssemblyService: lines.append(f"# 事件報告:{title}") lines.append("") - # Generation timestamp - timestamp = datetime.utcnow().strftime("%Y-%m-%d %H:%M") - lines.append(f"*報告產生時間:{timestamp}*") + # Generation timestamp in GMT+8 + timestamp = datetime.now(TZ_GMT8).strftime("%Y-%m-%d %H:%M") + lines.append(f"*報告產生時間:{timestamp} (GMT+8)*") lines.append("") # Metadata section @@ -455,13 +483,13 @@ class DocxAssemblyService: created_at = room_data.get("created_at") if isinstance(created_at, datetime): - lines.append(f"| 建立時間 | {created_at.strftime('%Y-%m-%d %H:%M')} |") + lines.append(f"| 建立時間 | {_to_gmt8(created_at).strftime('%Y-%m-%d %H:%M')} |") else: lines.append(f"| 建立時間 | {str(created_at) if created_at else '未知'} |") resolved_at = room_data.get("resolved_at") if isinstance(resolved_at, datetime): - lines.append(f"| 解決時間 | {resolved_at.strftime('%Y-%m-%d %H:%M')} |") + lines.append(f"| 解決時間 | {_to_gmt8(resolved_at).strftime('%Y-%m-%d %H:%M')} |") elif resolved_at: lines.append(f"| 解決時間 | {str(resolved_at)} |") else: @@ -561,8 +589,8 @@ class DocxAssemblyService: if files: lines.append("## 附件清單") lines.append("") - lines.append("| 檔案名稱 | 類型 | 上傳者 | 上傳時間 |") - lines.append("|----------|------|--------|----------|") + lines.append("| 檔案名稱 | 類型 | 上傳者 | 上傳時間 | 說明 |") + lines.append("|----------|------|--------|----------|------|") file_type_map = { "image": "圖片", @@ -577,10 +605,13 @@ class DocxAssemblyService: uploader = f.get("uploader_name") or f.get("uploader_id", "") uploaded_at = f.get("uploaded_at") if isinstance(uploaded_at, datetime): - uploaded_text = uploaded_at.strftime("%Y-%m-%d %H:%M") + uploaded_text = _to_gmt8(uploaded_at).strftime("%Y-%m-%d %H:%M") else: uploaded_text = str(uploaded_at) if uploaded_at else "" - lines.append(f"| {filename} | {type_text} | {uploader} | {uploaded_text} |") + caption = f.get("caption", "") or "" + # Escape pipe characters in caption + caption = caption.replace("|", "\\|") + lines.append(f"| {filename} | {type_text} | {uploader} | {uploaded_text} | {caption} |") lines.append("") return "\n".join(lines) diff --git a/app/modules/report_generation/services/report_data_service.py b/app/modules/report_generation/services/report_data_service.py index 10d55c3..6385dd5 100644 --- a/app/modules/report_generation/services/report_data_service.py +++ b/app/modules/report_generation/services/report_data_service.py @@ -13,7 +13,7 @@ from sqlalchemy.orm import Session from sqlalchemy import desc from app.modules.chat_room.models import IncidentRoom, RoomMember -from app.modules.realtime.models import Message +from app.modules.realtime.models import Message, MessageType from app.modules.file_storage.models import RoomFile from app.modules.auth.models import User @@ -38,9 +38,17 @@ class MemberData: role: str +@dataclass +class FileContextMessage: + """Context message near a file upload""" + sender_name: str + content: str + created_at: datetime + + @dataclass class FileData: - """File data for report generation""" + """File data for report generation with context""" file_id: str filename: str file_type: str @@ -49,6 +57,10 @@ class FileData: uploader_id: str uploader_name: str minio_object_path: str + # File context - the description/caption and surrounding messages + message_content: Optional[str] = None # Caption/description from file upload message + context_before: Optional[FileContextMessage] = None # Message before file + context_after: Optional[FileContextMessage] = None # Message after file @dataclass @@ -173,7 +185,7 @@ class ReportDataService: return members def _collect_files(self, room_id: str) -> List[FileData]: - """Collect room files with uploader display names""" + """Collect room files with uploader display names and message context""" results = ( self.db.query(RoomFile, User.display_name) .outerjoin(User, RoomFile.uploader_id == User.user_id) @@ -185,6 +197,31 @@ class ReportDataService: files = [] for f, display_name in results: + # Get file message content (caption/description) + message_content = None + context_before = None + context_after = None + + if f.message_id: + # Get the file's message to extract caption + file_message = self.db.query(Message).filter( + Message.message_id == f.message_id + ).first() + + if file_message: + # Extract caption (content that's not default [Image] or [File] prefix) + content = file_message.content + if not content.startswith("[Image]") and not content.startswith("[File]"): + message_content = content + + # Get context: 1 message before and 1 after the file message + context_before = self._get_context_message( + room_id, file_message.sequence_number, before=True + ) + context_after = self._get_context_message( + room_id, file_message.sequence_number, before=False + ) + files.append(FileData( file_id=f.file_id, filename=f.filename, @@ -192,12 +229,45 @@ class ReportDataService: mime_type=f.mime_type, uploaded_at=f.uploaded_at, uploader_id=f.uploader_id, - uploader_name=display_name or f.uploader_id, # Fallback to uploader_id + uploader_name=display_name or f.uploader_id, minio_object_path=f.minio_object_path, + message_content=message_content, + context_before=context_before, + context_after=context_after, )) return files + def _get_context_message( + self, room_id: str, sequence_number: int, before: bool = True + ) -> Optional[FileContextMessage]: + """Get a context message before or after a given sequence number""" + query = ( + self.db.query(Message, User.display_name) + .outerjoin(User, Message.sender_id == User.user_id) + .filter(Message.room_id == room_id) + .filter(Message.deleted_at.is_(None)) + .filter(Message.message_type.in_([MessageType.TEXT, MessageType.SYSTEM])) # Only text context + ) + + if before: + query = query.filter(Message.sequence_number < sequence_number) + query = query.order_by(desc(Message.sequence_number)) + else: + query = query.filter(Message.sequence_number > sequence_number) + query = query.order_by(Message.sequence_number) + + result = query.first() + + if result: + msg, display_name = result + return FileContextMessage( + sender_name=display_name or msg.sender_id, + content=msg.content, + created_at=msg.created_at, + ) + return None + def to_prompt_dict(self, data: RoomReportData) -> Dict[str, Any]: """Convert RoomReportData to dictionary format for prompt builder @@ -244,8 +314,9 @@ class ReportDataService: for m in data.members ] - files = [ - { + files = [] + for f in data.files: + file_dict = { "file_id": f.file_id, "filename": f.filename, "file_type": f.file_type, @@ -254,9 +325,20 @@ class ReportDataService: "uploader_id": f.uploader_id, "uploader_name": f.uploader_name, "minio_object_path": f.minio_object_path, + "caption": f.message_content, # User-provided caption/description } - for f in data.files - ] + # Add context if available + if f.context_before: + file_dict["context_before"] = { + "sender": f.context_before.sender_name, + "content": f.context_before.content, + } + if f.context_after: + file_dict["context_after"] = { + "sender": f.context_after.sender_name, + "content": f.context_after.content, + } + files.append(file_dict) return { "room_data": room_data, diff --git a/frontend/src/components/chat/ActionBar.tsx b/frontend/src/components/chat/ActionBar.tsx index 32d20a1..a77af41 100644 --- a/frontend/src/components/chat/ActionBar.tsx +++ b/frontend/src/components/chat/ActionBar.tsx @@ -12,6 +12,7 @@ interface ActionBarProps { canManageMembers: boolean isGeneratingReport: boolean uploadProgress: number | null + uploadInfo?: { current: number; total: number } | null // For multi-file upload onFileSelect: (files: FileList | null) => void onGenerateReport: () => void onAddMemberClick: () => void @@ -29,6 +30,7 @@ export function ActionBar({ canManageMembers, isGeneratingReport, uploadProgress, + uploadInfo, onFileSelect, onGenerateReport, onAddMemberClick, @@ -54,13 +56,18 @@ export function ActionBar({ return (
- {/* Hidden file input */} + {/* Hidden file input - supports multiple file selection */} onFileSelect(e.target.files)} + onChange={(e) => { + onFileSelect(e.target.files) + // Reset input so same files can be selected again + e.target.value = '' + }} className="hidden" accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.txt,.log" + multiple /> {/* Action bar content */} @@ -121,12 +128,16 @@ export function ActionBar({ onClick={handleFileClick} disabled={uploadProgress !== null} className={buttonClass} - title="Upload file" + title="Upload files (multiple supported)" > {uploadProgress !== null ? (
- {uploadProgress}% + + {uploadInfo && uploadInfo.total > 1 + ? `${uploadInfo.current}/${uploadInfo.total} (${uploadProgress}%)` + : `${uploadProgress}%`} +
) : ( <> @@ -217,7 +228,11 @@ export function ActionBar({ {uploadProgress !== null ? ( <>
- {uploadProgress}% + + {uploadInfo && uploadInfo.total > 1 + ? `${uploadInfo.current}/${uploadInfo.total}` + : `${uploadProgress}%`} + ) : ( <> diff --git a/frontend/src/components/chat/FileMessage.tsx b/frontend/src/components/chat/FileMessage.tsx new file mode 100644 index 0000000..f3442c6 --- /dev/null +++ b/frontend/src/components/chat/FileMessage.tsx @@ -0,0 +1,288 @@ +import { useState } from 'react' +import type { Message } from '../../types' +import { formatMessageTime } from '../../utils/datetime' +import { filesService } from '../../services/files' +import { ImageLightbox } from './ImageLightbox' + +// File type icon mapping with colors +const FILE_ICONS: Record = { + 'application/pdf': { + icon: ( + + + + ), + color: 'text-red-500 bg-red-100', + }, + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': { + icon: ( + + + + ), + color: 'text-green-600 bg-green-100', + }, + 'application/vnd.ms-excel': { + icon: ( + + + + ), + color: 'text-green-600 bg-green-100', + }, + 'text/plain': { + icon: ( + + + + ), + color: 'text-gray-500 bg-gray-100', + }, + 'text/csv': { + icon: ( + + + + ), + color: 'text-green-500 bg-green-100', + }, + 'application/x-log': { + icon: ( + + + + ), + color: 'text-orange-500 bg-orange-100', + }, + default: { + icon: ( + + + + ), + color: 'text-gray-400 bg-gray-100', + }, +} + +function getFileIcon(mimeType: string, filename: string): { icon: React.ReactNode; color: string } { + // Check mime type first + if (FILE_ICONS[mimeType]) { + return FILE_ICONS[mimeType] + } + + // Check file extension for .log files + const ext = filename.split('.').pop()?.toLowerCase() + if (ext === 'log') { + return FILE_ICONS['application/x-log'] + } + + return FILE_ICONS.default +} + +interface FileMessageProps { + message: Message + isOwnMessage: boolean + onDownload: (url: string, filename: string) => void +} + +interface FileMetadataFromMessage { + file_id: string + file_url: string + filename: string + file_type: string + mime_type?: string + file_size?: number + thumbnail_url?: string +} + +export function FileMessage({ message, isOwnMessage, onDownload }: FileMessageProps) { + const [showLightbox, setShowLightbox] = useState(false) + const [imageLoaded, setImageLoaded] = useState(false) + const [imageError, setImageError] = useState(false) + + // Debug: Log the message data + console.log('[FileMessage] Rendering message:', { + message_id: message.message_id, + message_type: message.message_type, + metadata: message.metadata, + content: message.content, + }) + + // Extract file metadata from message.metadata + const metadata = message.metadata as FileMetadataFromMessage | undefined + if (!metadata?.file_url) { + console.warn('[FileMessage] No file_url in metadata, returning null. Metadata:', metadata) + return null + } + + const { + file_url, + filename, + file_type, + mime_type = '', + file_size = 0, + thumbnail_url, + } = metadata + + const isImage = file_type === 'image' || mime_type.startsWith('image/') + const fileIcon = getFileIcon(mime_type, filename) + const displayUrl = thumbnail_url || file_url + + // Extract caption from message content (if not the default [Image] or [File] prefix) + const hasCaption = message.content && + !message.content.startsWith('[Image]') && + !message.content.startsWith('[File]') + const caption = hasCaption ? message.content : null + + const handleDownloadClick = (e: React.MouseEvent) => { + e.stopPropagation() + onDownload(file_url, filename) + } + + if (isImage) { + return ( + <> +
+ {/* Sender name */} + {!isOwnMessage && ( +
+ {message.sender_display_name || message.sender_id} +
+ )} + + {/* Image thumbnail */} +
setShowLightbox(true)} + > + {!imageLoaded && !imageError && ( +
+
+
+ )} + {imageError ? ( +
+ + + +
+ ) : ( + {filename} setImageLoaded(true)} + onError={() => setImageError(true)} + /> + )} + {/* Hover overlay */} +
+ + + +
+
+ + {/* Caption and metadata */} +
+ {caption && ( +

+ {caption} +

+ )} +
+ + {formatMessageTime(message.created_at)} + + +
+
+
+ + {/* Lightbox */} + {showLightbox && ( + setShowLightbox(false)} + onDownload={() => onDownload(file_url, filename)} + /> + )} + + ) + } + + // Non-image file display + return ( +
+ {/* Sender name */} + {!isOwnMessage && ( +
+ {message.sender_display_name || message.sender_id} +
+ )} + + {/* File info */} +
+ {/* File icon */} +
+ {fileIcon.icon} +
+ + {/* File details */} +
+

+ {filename} +

+

+ {file_size ? filesService.formatFileSize(file_size) : 'File'} +

+
+ + {/* Download button */} + +
+ + {/* Caption if any */} + {caption && ( +

+ {caption} +

+ )} + + {/* Timestamp */} +
+ {formatMessageTime(message.created_at)} +
+
+ ) +} diff --git a/frontend/src/components/chat/ImageLightbox.tsx b/frontend/src/components/chat/ImageLightbox.tsx new file mode 100644 index 0000000..8f584c0 --- /dev/null +++ b/frontend/src/components/chat/ImageLightbox.tsx @@ -0,0 +1,123 @@ +import { useEffect, useState, useCallback } from 'react' + +interface ImageLightboxProps { + src: string + alt: string + filename: string + onClose: () => void + onDownload: () => void +} + +export function ImageLightbox({ src, alt, filename, onClose, onDownload }: ImageLightboxProps) { + const [isLoading, setIsLoading] = useState(true) + const [hasError, setHasError] = useState(false) + + // Handle keyboard events + const handleKeyDown = useCallback((e: KeyboardEvent) => { + if (e.key === 'Escape') { + onClose() + } + }, [onClose]) + + useEffect(() => { + // Add event listener + document.addEventListener('keydown', handleKeyDown) + // Prevent body scroll when lightbox is open + document.body.style.overflow = 'hidden' + + return () => { + document.removeEventListener('keydown', handleKeyDown) + document.body.style.overflow = '' + } + }, [handleKeyDown]) + + const handleBackdropClick = (e: React.MouseEvent) => { + if (e.target === e.currentTarget) { + onClose() + } + } + + const handleDownloadClick = (e: React.MouseEvent) => { + e.stopPropagation() + onDownload() + } + + return ( +
+ {/* Close button */} + + + {/* Image container */} +
+ {/* Loading state */} + {isLoading && !hasError && ( +
+
+
+ )} + + {/* Error state */} + {hasError && ( +
+ + + +

Failed to load image

+

{filename}

+
+ )} + + {/* Image */} + {alt} setIsLoading(false)} + onError={() => { + setIsLoading(false) + setHasError(true) + }} + onClick={(e) => e.stopPropagation()} + /> + + {/* Bottom toolbar */} +
+ {/* Filename */} + + {filename} + + + {/* Download button */} + +
+
+ + {/* Keyboard hint */} +
+ Press ESC to close +
+
+ ) +} diff --git a/frontend/src/components/chat/UploadPreview.tsx b/frontend/src/components/chat/UploadPreview.tsx new file mode 100644 index 0000000..0301b49 --- /dev/null +++ b/frontend/src/components/chat/UploadPreview.tsx @@ -0,0 +1,207 @@ +import { useState, useEffect, useRef } from 'react' +import { filesService } from '../../services/files' + +// File type icon mapping with colors +const FILE_ICONS: Record = { + 'application/pdf': { + icon: ( + + + + ), + bgColor: 'bg-red-100', + textColor: 'text-red-500', + }, + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': { + icon: ( + + + + ), + bgColor: 'bg-green-100', + textColor: 'text-green-600', + }, + 'application/vnd.ms-excel': { + icon: ( + + + + ), + bgColor: 'bg-green-100', + textColor: 'text-green-600', + }, + 'text/plain': { + icon: ( + + + + ), + bgColor: 'bg-gray-100', + textColor: 'text-gray-500', + }, + default: { + icon: ( + + + + ), + bgColor: 'bg-gray-100', + textColor: 'text-gray-400', + }, +} + +function getFileIcon(mimeType: string, filename: string) { + if (FILE_ICONS[mimeType]) { + return FILE_ICONS[mimeType] + } + const ext = filename.split('.').pop()?.toLowerCase() + if (ext === 'log') { + return { ...FILE_ICONS.default, bgColor: 'bg-orange-100', textColor: 'text-orange-500' } + } + return FILE_ICONS.default +} + +interface UploadPreviewProps { + file: File + uploadProgress: number | null + onCancel: () => void + onSend: (description?: string) => void + isMobile?: boolean +} + +export function UploadPreview({ file, uploadProgress, onCancel, onSend, isMobile = false }: UploadPreviewProps) { + const [description, setDescription] = useState('') + const [previewUrl, setPreviewUrl] = useState(null) + const inputRef = useRef(null) + + const isImage = file.type.startsWith('image/') + + // Generate preview for images + useEffect(() => { + if (isImage) { + const url = URL.createObjectURL(file) + setPreviewUrl(url) + return () => URL.revokeObjectURL(url) + } + }, [file, isImage]) + + // Auto-focus the input + useEffect(() => { + if (inputRef.current && !isMobile) { + inputRef.current.focus() + } + }, [isMobile]) + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + onSend(description.trim() || undefined) + } + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + onSend(description.trim() || undefined) + } + if (e.key === 'Escape') { + onCancel() + } + } + + const isUploading = uploadProgress !== null + const fileIcon = getFileIcon(file.type, file.name) + + return ( +
+
+ {/* Preview */} +
+ {isImage && previewUrl ? ( +
+ {file.name} +
+ ) : ( +
+
{fileIcon.icon}
+
+ )} +
+ + {/* File info and input */} +
+
+

{file.name}

+ {!isUploading && ( + + )} +
+

{filesService.formatFileSize(file.size)}

+ + {/* Description input */} + {!isUploading && ( +
+ setDescription(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="Add a caption (optional)..." + className={`flex-1 px-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none ${ + isMobile ? 'py-2.5 text-base' : 'py-1.5 text-sm' + }`} + maxLength={500} + /> + +
+ )} + + {/* Upload progress */} + {isUploading && ( +
+
+ Uploading... + {uploadProgress}% +
+
+
+
+
+ )} +
+
+ + {/* Keyboard hint (desktop only) */} + {!isMobile && !isUploading && ( +
+ Press Enter to send,{' '} + Esc to cancel +
+ )} +
+ ) +} diff --git a/frontend/src/hooks/useWebSocket.ts b/frontend/src/hooks/useWebSocket.ts index edc1670..5f5b57f 100644 --- a/frontend/src/hooks/useWebSocket.ts +++ b/frontend/src/hooks/useWebSocket.ts @@ -134,10 +134,18 @@ export function useWebSocket(roomId: string | null, options?: UseWebSocketOption (data: unknown) => { const msg = data as { type: string } + // Debug: Log all incoming WebSocket messages + console.log('[WebSocket] Received message:', msg.type, data) + switch (msg.type) { case 'message': case 'edit_message': { const messageBroadcast = data as MessageBroadcast + console.log('[WebSocket] Processing message broadcast:', { + message_id: messageBroadcast.message_id, + message_type: messageBroadcast.message_type, + metadata: messageBroadcast.metadata, + }) const message: Message = { message_id: messageBroadcast.message_id, room_id: messageBroadcast.room_id, @@ -160,7 +168,8 @@ export function useWebSocket(roomId: string | null, options?: UseWebSocketOption break } - case 'delete_message': { + case 'delete_message': + case 'message_deleted': { const deleteMsg = data as { message_id: string } removeMessage(deleteMsg.message_id) break @@ -192,6 +201,10 @@ export function useWebSocket(roomId: string | null, options?: UseWebSocketOption case 'file_deleted': { const fileData = data as FileDeletedBroadcast + // Also remove the associated message from chat if it exists + if (fileData.message_id) { + removeMessage(fileData.message_id) + } options?.onFileDeleted?.(fileData) break } diff --git a/frontend/src/pages/RoomDetail.tsx b/frontend/src/pages/RoomDetail.tsx index ff45d39..6fd765b 100644 --- a/frontend/src/pages/RoomDetail.tsx +++ b/frontend/src/pages/RoomDetail.tsx @@ -27,6 +27,8 @@ import { MobileHeader, SlidePanel } from '../components/mobile' import { ActionBar } from '../components/chat/ActionBar' import { MentionInput, highlightMentions } from '../components/chat/MentionInput' import { NotificationSettings } from '../components/chat/NotificationSettings' +import { FileMessage } from '../components/chat/FileMessage' +import { UploadPreview } from '../components/chat/UploadPreview' import ReportProgress from '../components/report/ReportProgress' import { formatMessageTime } from '../utils/datetime' import type { SeverityLevel, RoomStatus, MemberRole, FileMetadata, ReportStatus, Message } from '../types' @@ -138,8 +140,11 @@ export default function RoomDetail() { const [showEmojiPickerFor, setShowEmojiPickerFor] = useState(null) const [copiedMessageId, setCopiedMessageId] = useState(null) const [uploadProgress, setUploadProgress] = useState(null) + const [uploadInfo, setUploadInfo] = useState<{ current: number; total: number } | null>(null) const [isDragging, setIsDragging] = useState(false) const [previewFile, setPreviewFile] = useState(null) + const [pendingUploadFile, setPendingUploadFile] = useState(null) + const [pendingUploadFiles, setPendingUploadFiles] = useState([]) const fileInputRef = useRef(null) const [newMemberUsername, setNewMemberUsername] = useState('') const [newMemberRole, setNewMemberRole] = useState('viewer') @@ -310,25 +315,68 @@ export default function RoomDetail() { }) } - // File handlers - const handleFileUpload = useCallback( - (files: FileList | null) => { - if (!files || files.length === 0) return + // File handlers - Show preview before upload (single file) or upload immediately (multiple files) + const handleFileSelect = useCallback( + async (files: FileList | null) => { + if (!files || files.length === 0 || !roomId) return + + if (files.length === 1) { + // Single file: show preview before upload + setPendingUploadFile(files[0]) + } else { + // Multiple files: upload immediately without preview + const fileArray = Array.from(files) + setPendingUploadFiles(fileArray) + setUploadProgress(0) + setUploadInfo({ current: 1, total: fileArray.length }) + + try { + const result = await filesService.uploadFiles( + roomId, + fileArray, + (progress, current, total) => { + setUploadProgress(progress) + setUploadInfo({ current, total }) + } + ) + + if (result.failed.length > 0) { + // Show alert for failed uploads + const failedNames = result.failed.map(f => f.file).map(f => f.name).join(', ') + alert(`Failed to upload: ${failedNames}`) + } + } catch (error) { + console.error('Multi-file upload failed:', error) + } finally { + setUploadProgress(null) + setUploadInfo(null) + setPendingUploadFiles([]) + } + } + + if (fileInputRef.current) { + fileInputRef.current.value = '' + } + }, + [roomId] + ) + + const handleUploadWithDescription = useCallback( + (description?: string) => { + if (!pendingUploadFile) return - const file = files[0] setUploadProgress(0) uploadFile.mutate( { - file, + file: pendingUploadFile, + description, onProgress: (progress) => setUploadProgress(progress), }, { onSuccess: () => { setUploadProgress(null) - if (fileInputRef.current) { - fileInputRef.current.value = '' - } + setPendingUploadFile(null) }, onError: () => { setUploadProgress(null) @@ -336,16 +384,21 @@ export default function RoomDetail() { } ) }, - [uploadFile] + [pendingUploadFile, uploadFile] ) + const handleCancelUpload = useCallback(() => { + setPendingUploadFile(null) + setUploadProgress(null) + }, []) + const handleDrop = useCallback( (e: React.DragEvent) => { e.preventDefault() setIsDragging(false) - handleFileUpload(e.dataTransfer.files) + handleFileSelect(e.dataTransfer.files) }, - [handleFileUpload] + [handleFileSelect] ) const handleDragOver = useCallback((e: React.DragEvent) => { @@ -678,6 +731,32 @@ export default function RoomDetail() { messages.map((message) => { const isOwnMessage = message.sender_id === user?.username const isEditing = editingMessageId === message.message_id + const isFileMessage = message.message_type === 'image_ref' || message.message_type === 'file_ref' + + // Handle file/image messages with FileMessage component + if (isFileMessage) { + return ( +
+ { + // Open in new tab for download + const link = document.createElement('a') + link.href = url + link.download = filename + link.target = '_blank' + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + }} + /> +
+ ) + } return (
0 ? uploadProgress : null} + uploadInfo={uploadInfo} + onFileSelect={handleFileSelect} onGenerateReport={handleGenerateReport} onAddMemberClick={() => setShowAddMember(true)} isMobile={isMobile} /> + {/* Upload Preview - Show when file selected but not yet uploaded */} + {pendingUploadFile && ( + + )} + {/* Message Input with @Mention Support */} - {permissions?.can_write && ( + {permissions?.can_write && !pendingUploadFile && (
handleFileUpload(e.target.files)} + onChange={(e) => handleFileSelect(e.target.files)} className="hidden" /> {uploadProgress !== null ? ( @@ -1370,7 +1461,7 @@ export default function RoomDetail() { handleFileUpload(e.target.files)} + onChange={(e) => handleFileSelect(e.target.files)} className="hidden" /> {uploadProgress !== null ? ( diff --git a/frontend/src/services/files.ts b/frontend/src/services/files.ts index ff44859..4a77de5 100644 --- a/frontend/src/services/files.ts +++ b/frontend/src/services/files.ts @@ -46,6 +46,55 @@ export const filesService = { return response.data }, + /** + * Upload multiple files to room sequentially + * Reports overall progress across all files + */ + async uploadFiles( + roomId: string, + files: File[], + onProgress?: (progress: number, currentFile: number, totalFiles: number) => void + ): Promise<{ successful: FileUploadResponse[]; failed: { file: File; error: string }[] }> { + const successful: FileUploadResponse[] = [] + const failed: { file: File; error: string }[] = [] + const totalFiles = files.length + + for (let i = 0; i < files.length; i++) { + const file = files[i] + const currentFile = i + 1 + + try { + const result = await this.uploadFile( + roomId, + file, + undefined, + (fileProgress) => { + if (onProgress) { + // Calculate overall progress: completed files + current file progress + const completedProgress = (i / totalFiles) * 100 + const currentFileContribution = (fileProgress / totalFiles) + const overallProgress = Math.round(completedProgress + currentFileContribution) + onProgress(overallProgress, currentFile, totalFiles) + } + } + ) + successful.push(result) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Upload failed' + failed.push({ file, error: errorMessage }) + console.error(`Failed to upload ${file.name}:`, error) + } + + // Report completion of this file + if (onProgress) { + const overallProgress = Math.round(((i + 1) / totalFiles) * 100) + onProgress(overallProgress, currentFile, totalFiles) + } + } + + return { successful, failed } + }, + /** * List files in a room */ diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 9e9938a..fe6ce70 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -127,6 +127,7 @@ export type FileType = 'image' | 'document' | 'log' export interface FileMetadata { file_id: string + message_id?: string | null // Associated chat message ID room_id: string filename: string file_type: FileType @@ -138,15 +139,18 @@ export interface FileMetadata { uploader_id: string deleted_at?: string | null download_url?: string + thumbnail_url?: string | null // Thumbnail URL for images } export interface FileUploadResponse { file_id: string + message_id?: string | null // Associated chat message ID filename: string file_type: FileType file_size: number mime_type: string download_url: string + thumbnail_url?: string | null // Thumbnail URL for images uploaded_at: string uploader_id: string } @@ -222,6 +226,7 @@ export interface TypingBroadcast { export interface FileUploadedBroadcast { type: 'file_uploaded' file_id: string + message_id?: string | null // Associated chat message ID room_id: string uploader_id: string filename: string @@ -229,12 +234,14 @@ export interface FileUploadedBroadcast { file_size: number mime_type: string download_url?: string + thumbnail_url?: string | null // Thumbnail URL for images uploaded_at: string } export interface FileDeletedBroadcast { type: 'file_deleted' file_id: string + message_id?: string | null // Associated chat message ID (also deleted) room_id: string deleted_by: string deleted_at: string diff --git a/openspec/changes/archive/2025-12-08-improve-file-display-and-timezone/design.md b/openspec/changes/archive/2025-12-08-improve-file-display-and-timezone/design.md new file mode 100644 index 0000000..0daf3bf --- /dev/null +++ b/openspec/changes/archive/2025-12-08-improve-file-display-and-timezone/design.md @@ -0,0 +1,73 @@ +# Design: File Display and Timezone Improvements + +## Context +Users upload files during incident discussions, but these files only appear in a separate drawer. This breaks the conversation flow and makes it hard to understand what was being discussed when a file was uploaded. The AI report generator also lacks this context. + +## Goals +- Files appear inline in chat at the time they were uploaded +- Images show thumbnails with click-to-expand preview +- AI reports include file context with surrounding messages +- All timestamps display in GMT+8 + +## Non-Goals +- Drag-and-drop upload (future enhancement) +- Video file preview (out of scope) +- File editing/annotation + +## Decisions + +### Decision 1: Link files to messages via foreign key +- Add `message_id` column to `tr_room_files` table +- When file is uploaded, create an `image_ref` or `file_ref` message +- Store the message_id in the file record +- Allows bidirectional lookup + +### Decision 2: Image thumbnail generation +- **Option A**: Generate thumbnails server-side on upload (MinIO) +- **Option B**: Use MinIO presigned URL with CSS resize on frontend +- **Chosen**: Option B - simpler, no additional storage needed, modern browsers handle resizing well + +### Decision 3: File message format +```json +{ + "message_type": "image_ref", + "content": "optional caption from user", + "message_metadata": { + "file_id": "uuid", + "filename": "defect.jpg", + "file_type": "image", + "mime_type": "image/jpeg", + "file_size": 2621440, + "thumbnail_url": "presigned-url", + "download_url": "presigned-url" + } +} +``` + +### Decision 4: Lightbox implementation +- Use a simple modal-based image viewer +- Support keyboard navigation (ESC to close) +- Show loading state while image loads + +### Decision 5: AI Report file context +- When collecting data, include the message content and surrounding 2 messages for each file +- Format: `[附件: filename.ext] - 上傳者: display_name, 說明: "{message_content}"` + +## Risks / Trade-offs + +| Risk | Mitigation | +|------|------------| +| Large images slow down chat | Use lazy loading, thumbnails | +| Existing files have no message | Migration script to create messages for orphan files | +| Presigned URLs expire | Frontend refreshes URLs on load | + +## Migration Plan +1. Add `message_id` column to `tr_room_files` (nullable) +2. Create migration script to generate messages for existing files +3. Update upload API to create message + file atomically +4. Deploy backend changes +5. Deploy frontend with inline file display +6. Verify existing files display correctly + +## Open Questions +- Should file deletion also delete the associated message? (Proposed: Yes, soft delete both) diff --git a/openspec/changes/archive/2025-12-08-improve-file-display-and-timezone/proposal.md b/openspec/changes/archive/2025-12-08-improve-file-display-and-timezone/proposal.md new file mode 100644 index 0000000..23402e7 --- /dev/null +++ b/openspec/changes/archive/2025-12-08-improve-file-display-and-timezone/proposal.md @@ -0,0 +1,23 @@ +# Change: Improve File Display in Chat and Fix Timezone Issues + +## Why +Currently, uploaded files and images only appear in the "file drawer" sidebar, making it difficult to understand the context of when and why a file was uploaded. This also impacts AI report generation, as the AI cannot associate files with the conversation context. Additionally, some parts of the application still display times in GMT+0 instead of GMT+8. + +## What Changes +- **BREAKING**: Message creation when uploading files - files now generate a linked message in chat +- Display uploaded files and images inline in the chat conversation +- Add image preview functionality with lightbox +- Show image thumbnails in chat messages (expandable to full size) +- Non-image files display with file type icons +- Update upload interface to show preview before sending +- Update AI report data collection to include file context (associated messages) +- Fix remaining GMT+0 timestamp displays to use GMT+8 + +## Impact +- Affected specs: file-storage, realtime-messaging, ai-report-generation +- Affected code: + - Backend: `app/modules/file_storage/router.py`, `app/modules/realtime/` (message creation on upload) + - Backend: `app/modules/report_generation/services/report_data_service.py` + - Frontend: `frontend/src/pages/RoomDetail.tsx` + - Frontend: New components `FileMessage.tsx`, `ImagePreview.tsx` + - Database: May need migration to add `message_id` FK to `tr_room_files` diff --git a/openspec/changes/archive/2025-12-08-improve-file-display-and-timezone/specs/ai-report-generation/spec.md b/openspec/changes/archive/2025-12-08-improve-file-display-and-timezone/specs/ai-report-generation/spec.md new file mode 100644 index 0000000..32ac028 --- /dev/null +++ b/openspec/changes/archive/2025-12-08-improve-file-display-and-timezone/specs/ai-report-generation/spec.md @@ -0,0 +1,95 @@ +## MODIFIED Requirements + +### Requirement: Report Data Collection + +The system SHALL collect all relevant room data for AI processing, including messages, members, files with their conversation context, and room metadata. + +#### Scenario: Collect complete room data for report generation + +- **GIVEN** an incident room with ID `room-123` exists +- **AND** the room has 50 messages from 5 members +- **AND** the room has 3 uploaded files (2 images, 1 PDF) +- **WHEN** the report data service collects room data +- **THEN** the system SHALL return a structured data object containing: + - Room metadata (title, incident_type, severity, status, location, description, timestamps) + - All 50 messages sorted by created_at ascending + - All 5 members with their roles (owner, editor, viewer) + - All 3 files with metadata (filename, type, uploader, upload time) AND their associated message context +- **AND** messages SHALL include sender display name (not just user_id) +- **AND** file references in messages SHALL be annotated with surrounding context + +#### Scenario: Include file context in report data +- **GIVEN** a file "defect_photo.jpg" was uploaded with the message "發現產品表面瑕疵" +- **AND** the previous message was "Line 3 溫度異常升高中" +- **AND** the next message was "已通知維修人員處理" +- **WHEN** report data is collected +- **THEN** the file entry SHALL include: + ```json + { + "file_id": "...", + "filename": "defect_photo.jpg", + "uploader_display_name": "陳工程師", + "uploaded_at": "2025-12-08T14:30:00+08:00", + "caption": "發現產品表面瑕疵", + "context_before": "Line 3 溫度異常升高中", + "context_after": "已通知維修人員處理" + } + ``` +- **AND** the AI prompt SHALL format files as: + `[附件: defect_photo.jpg] - 上傳者: 陳工程師 (14:30), 說明: "發現產品表面瑕疵" (前文: "Line 3 溫度異常升高中")` + +#### Scenario: Handle room with no messages + +- **GIVEN** an incident room was just created with no messages +- **WHEN** report generation is requested +- **THEN** the system SHALL return an error indicating insufficient data for report generation +- **AND** the error message SHALL be "事件聊天室尚無訊息記錄,無法生成報告" + +#### Scenario: Summarize large rooms exceeding message limit + +- **GIVEN** an incident room has 500 messages spanning 5 days +- **AND** the REPORT_MAX_MESSAGES limit is 200 +- **WHEN** report data is collected +- **THEN** the system SHALL keep the most recent 150 messages in full +- **AND** summarize older messages by day (e.g., "2025-12-01: 45 則訊息討論設備檢修") +- **AND** the total formatted content SHALL stay within token limits + +### Requirement: Document Assembly + +The system SHALL assemble professional .docx documents from AI-generated content with embedded images from MinIO and file context from conversations. + +#### Scenario: Generate complete report document + +- **GIVEN** DIFY has returned valid JSON report content +- **AND** the room has 2 image attachments in MinIO +- **WHEN** the docx assembly service creates the document +- **THEN** the system SHALL create a .docx file with: + - Report title: "生產線異常處理報告 - {room.title}" + - Generation metadata: 生成時間, 事件編號, 生成者 + - Section 1: 事件摘要 (from AI summary.content) + - Section 2: 事件時間軸 (formatted table from AI timeline.events) + - Section 3: 參與人員 (formatted list from AI participants.members) + - Section 4: 處理過程 (from AI resolution_process.content) + - Section 5: 目前狀態 (from AI current_status) + - Section 6: 最終處置結果 (from AI final_resolution, if has_resolution=true) + - Section 7: 附件 (embedded images with captions + file list with context) +- **AND** images SHALL be embedded at appropriate size (max width 15cm) +- **AND** each image SHALL include its caption from the upload message +- **AND** document SHALL use professional formatting (標楷體 or similar) + +#### Scenario: Handle missing images during assembly + +- **GIVEN** a file reference exists in the database +- **BUT** the actual file is missing from MinIO +- **WHEN** the docx service attempts to embed the image +- **THEN** the system SHALL skip the missing image +- **AND** add a placeholder text: "[圖片無法載入: {filename}]" +- **AND** continue with document assembly +- **AND** log a warning with file_id and room_id + +#### Scenario: Generate report for room without images + +- **GIVEN** the room has no image attachments +- **WHEN** the docx assembly service creates the document +- **THEN** the system SHALL create a complete document without the embedded images section +- **AND** the attachments section SHALL show "本事件無附件檔案" if no files exist diff --git a/openspec/changes/archive/2025-12-08-improve-file-display-and-timezone/specs/file-storage/spec.md b/openspec/changes/archive/2025-12-08-improve-file-display-and-timezone/specs/file-storage/spec.md new file mode 100644 index 0000000..56a1801 --- /dev/null +++ b/openspec/changes/archive/2025-12-08-improve-file-display-and-timezone/specs/file-storage/spec.md @@ -0,0 +1,105 @@ +## MODIFIED Requirements + +### Requirement: File Upload with Validation +The system SHALL accept multipart file uploads to incident rooms, validate file type and size, persist files to MinIO object storage with metadata tracking in PostgreSQL, AND create an associated message in the chat for context. + +#### Scenario: Upload image to incident room +- **WHEN** a user with OWNER or EDITOR role uploads an image file via `POST /api/rooms/{room_id}/files` + ```http + POST /api/rooms/room-123/files + Content-Type: multipart/form-data + Authorization: Bearer {jwt_token} + + file: [binary data of defect.jpg, 2.5MB] + description: "Defect found on product batch A-45" + ``` +- **THEN** the system SHALL: + - Validate JWT token and extract user_id + - Verify user is member of room-123 with OWNER or EDITOR role + - Validate file MIME type is image/jpeg, image/png, or image/gif + - Validate file size <= 10MB + - Generate unique file_id (UUID) + - Upload file to MinIO bucket `task-reporter-files` at path `room-123/images/{file_id}.jpg` + - Create a message with `message_type=image_ref` containing file metadata + - Create database record in `room_files` table with `message_id` reference + - Return file metadata with presigned download URL (1-hour expiry) and message_id + +#### Scenario: Upload document to incident room +- **WHEN** a user uploads a non-image file (PDF, log, etc.) +- **THEN** the system SHALL: + - Create a message with `message_type=file_ref` + - Store message_id in the file record + - Return file metadata with message_id + +#### Scenario: Reject oversized file upload +- **WHEN** a user attempts to upload a 15MB PDF file +- **THEN** the system SHALL: + - Detect file size exceeds 20MB limit for documents + - Return 413 Payload Too Large error + - Include error message: "File size exceeds limit: 15MB > 20MB" + - NOT upload file to MinIO + - NOT create database record + - NOT create chat message + +#### Scenario: Reject unauthorized file type +- **WHEN** a user attempts to upload an executable file (e.g., .exe, .sh, .bat) +- **THEN** the system SHALL: + - Detect MIME type not in whitelist + - Return 400 Bad Request error + - Include error message: "File type not allowed: application/x-msdownload" + - NOT upload file to MinIO + - NOT create database record + +#### Scenario: Upload log file to incident room +- **WHEN** an engineer uploads a machine log file + ```http + POST /api/rooms/room-456/files + Content-Type: multipart/form-data + + file: [machine_error.log, 1.2MB] + description: "Equipment error log from 2025-11-17" + ``` +- **THEN** the system SHALL: + - Validate MIME type is text/plain + - Upload to MinIO at `room-456/logs/{file_id}.log` + - Store metadata with file_type='log' + - Create a message with message_type='file_ref' + - Return success response with file_id and message_id + +## ADDED Requirements + +### Requirement: File-Message Association +The system SHALL maintain a foreign key relationship between uploaded files and their associated chat messages, enabling contextual display of files in conversations. + +#### Scenario: Query file with associated message +- **WHEN** a client requests file metadata via `GET /api/rooms/{room_id}/files/{file_id}` +- **THEN** the response SHALL include: + ```json + { + "file_id": "550e8400-e29b-41d4-a716-446655440000", + "message_id": "msg-789", + "filename": "defect.jpg", + "file_type": "image", + "download_url": "...", + "uploaded_at": "2025-12-08T10:30:00+08:00" + } + ``` + +#### Scenario: Delete file cascades to message +- **WHEN** a file is soft-deleted via `DELETE /api/rooms/{room_id}/files/{file_id}` +- **THEN** the system SHALL also soft-delete the associated message +- **AND** broadcast both `file_deleted` and `message_deleted` events + +### Requirement: Image Thumbnail URLs +The system SHALL generate presigned URLs suitable for thumbnail display, allowing frontends to efficiently render image previews. + +#### Scenario: File metadata includes thumbnail URL +- **WHEN** file metadata is returned for an image file +- **THEN** the response SHALL include a `thumbnail_url` field +- **AND** the URL SHALL be a presigned MinIO URL valid for 1 hour +- **AND** the frontend SHALL use CSS to constrain thumbnail display size + +#### Scenario: Non-image files have no thumbnail +- **WHEN** file metadata is returned for a non-image file (PDF, log, etc.) +- **THEN** the response SHALL NOT include a `thumbnail_url` field +- **AND** the frontend SHALL display a file-type icon instead diff --git a/openspec/changes/archive/2025-12-08-improve-file-display-and-timezone/specs/realtime-messaging/spec.md b/openspec/changes/archive/2025-12-08-improve-file-display-and-timezone/specs/realtime-messaging/spec.md new file mode 100644 index 0000000..17bf43b --- /dev/null +++ b/openspec/changes/archive/2025-12-08-improve-file-display-and-timezone/specs/realtime-messaging/spec.md @@ -0,0 +1,133 @@ +## MODIFIED Requirements + +### Requirement: Message Types and Formatting +The system SHALL support various message types including text, image references, file references, and structured data for production incidents, with inline display of file attachments in the chat view. + +#### Scenario: Text message with mentions +- **WHEN** a user sends a message with @mentions + ```json + { + "content": "@maintenance_team Please check Line 3 immediately", + "mentions": ["maintenance_team@panjit.com.tw"] + } + ``` +- **THEN** the system SHALL parse and store mentions +- **AND** potentially trigger notifications to mentioned users + +#### Scenario: Image reference message display +- **WHEN** a message with `message_type=image_ref` is rendered in the chat +- **THEN** the client SHALL display: + - A thumbnail of the image (max 300px width) + - The message content/caption below the image + - Sender name and timestamp + - A click-to-expand functionality +- **AND** clicking the thumbnail SHALL open a full-size preview lightbox + +#### Scenario: File reference message display +- **WHEN** a message with `message_type=file_ref` is rendered in the chat +- **THEN** the client SHALL display: + - A file type icon (PDF, document, log, etc.) + - The filename + - File size in human-readable format + - A download button/link + - The message content/caption + - Sender name and timestamp + +#### Scenario: Structured incident data +- **WHEN** reporting specific incident metrics + ```json + { + "type": "message", + "message_type": "incident_data", + "content": { + "temperature": 85, + "pressure": 120, + "production_rate": 450, + "timestamp": "2025-11-17T10:15:00Z" + } + } + ``` +- **THEN** the system SHALL store structured data as JSON +- **AND** enable querying/filtering by specific fields later + +### Requirement: GMT+8 Timezone Display + +The frontend SHALL display all timestamps in GMT+8 (Asia/Taipei) timezone for consistent user experience across all browsers and all parts of the application. + +#### Scenario: Message timestamp in GMT+8 +- **WHEN** a message is displayed in the chat room +- **THEN** the timestamp SHALL be formatted in GMT+8 timezone +- **AND** use format "HH:mm" for today's messages +- **AND** use format "MM/DD HH:mm" for older messages + +#### Scenario: Room list timestamps in GMT+8 +- **WHEN** the room list is displayed +- **THEN** the "last updated" time SHALL be formatted in GMT+8 timezone + +#### Scenario: File upload timestamp in GMT+8 +- **WHEN** a file is displayed in chat or file drawer +- **THEN** the upload timestamp SHALL be formatted in GMT+8 timezone + +#### Scenario: Report generation timestamp in GMT+8 +- **WHEN** report metadata is displayed +- **THEN** the "generated at" timestamp SHALL be formatted in GMT+8 timezone + +## ADDED Requirements + +### Requirement: Image Preview Lightbox +The frontend SHALL provide a lightbox component for viewing full-size images from chat messages. + +#### Scenario: Open image lightbox +- **WHEN** user clicks on an image thumbnail in the chat +- **THEN** a modal overlay SHALL appear +- **AND** the full-size image SHALL be displayed centered +- **AND** a loading indicator SHALL show while image loads +- **AND** the image SHALL be constrained to fit the viewport + +#### Scenario: Close image lightbox +- **WHEN** the lightbox is open +- **THEN** user can close it by: + - Clicking the X button + - Pressing the ESC key + - Clicking outside the image +- **AND** focus SHALL return to the chat + +#### Scenario: Image lightbox with download +- **WHEN** the lightbox is open +- **THEN** a download button SHALL be visible +- **AND** clicking it SHALL download the original file + +### Requirement: File Type Icons +The frontend SHALL display appropriate icons for different file types in chat messages and file drawer. + +#### Scenario: PDF file icon +- **WHEN** a PDF file is displayed +- **THEN** a PDF icon (red/document style) SHALL be shown + +#### Scenario: Log/text file icon +- **WHEN** a .log or .txt file is displayed +- **THEN** a text file icon SHALL be shown + +#### Scenario: Excel file icon +- **WHEN** an Excel file (.xlsx, .xls) is displayed +- **THEN** a spreadsheet icon (green) SHALL be shown + +#### Scenario: Generic file icon +- **WHEN** a file with unknown type is displayed +- **THEN** a generic document icon SHALL be shown + +### Requirement: Upload Preview +The frontend SHALL show a preview of the file being uploaded before the message is sent. + +#### Scenario: Image upload preview +- **WHEN** user selects an image file for upload +- **THEN** a preview thumbnail SHALL be displayed in the input area +- **AND** user can add a caption/description +- **AND** user can cancel the upload before sending +- **AND** a send button confirms the upload + +#### Scenario: File upload preview +- **WHEN** user selects a non-image file for upload +- **THEN** file info (name, size, type icon) SHALL be displayed +- **AND** user can add a description +- **AND** user can cancel or confirm diff --git a/openspec/changes/archive/2025-12-08-improve-file-display-and-timezone/tasks.md b/openspec/changes/archive/2025-12-08-improve-file-display-and-timezone/tasks.md new file mode 100644 index 0000000..d0d97c2 --- /dev/null +++ b/openspec/changes/archive/2025-12-08-improve-file-display-and-timezone/tasks.md @@ -0,0 +1,134 @@ +# Tasks: Improve File Display and Timezone + +## Phase 1: Database & Backend Foundation + +### T-1.1: Database Migration +- [x] Add `message_id` column to `tr_room_files` table (nullable FK to `tr_messages`) +- [x] Create Alembic migration script +- [x] Test migration on dev database + +### T-1.2: File Upload API Update +- [x] Modify `POST /api/rooms/{room_id}/files` to create associated message +- [x] Create message with `message_type=image_ref` or `file_ref` +- [x] Store message_id in file record +- [x] Return message_id in response +- [x] Update WebSocket broadcast to include message + +### T-1.3: File Metadata API Update +- [x] Add `message_id` to file metadata response +- [x] Add `thumbnail_url` field for image files +- [x] Ensure timestamps are formatted in GMT+8 + +### T-1.4: File Deletion Cascade +- [x] Update DELETE endpoint to soft-delete associated message +- [x] Broadcast both `file_deleted` and `message_deleted` events + +## Phase 2: Frontend File Display + +### T-2.1: File Message Component +- [x] Create `FileMessage.tsx` component +- [x] Support `image_ref` type with thumbnail +- [x] Support `file_ref` type with icon +- [x] Display caption, sender, timestamp +- [x] Add download button + +### T-2.2: File Type Icons +- [x] Create file icon mapping (PDF, Excel, text, generic) +- [x] Use appropriate colors (red for PDF, green for Excel, etc.) +- [x] Display file size in human-readable format + +### T-2.3: Image Lightbox Component +- [x] Create `ImageLightbox.tsx` modal component +- [x] Support keyboard navigation (ESC to close) +- [x] Add loading state +- [x] Add download button +- [x] Constrain image to viewport + +### T-2.4: Integrate File Messages in Chat +- [x] Update `RoomDetail.tsx` to render `FileMessage` for image_ref/file_ref +- [x] Lazy load images for performance +- [x] Handle click to open lightbox + +## Phase 3: Upload Experience + +### T-3.1: Upload Preview Component +- [x] Create `UploadPreview.tsx` component +- [x] Show thumbnail for images before upload +- [x] Show file info (name, size, icon) for documents +- [x] Add caption/description input field +- [x] Add cancel and send buttons + +### T-3.2: Update ActionBar Upload Flow +- [x] Modify file selection to show preview instead of immediate upload +- [x] Integrate `UploadPreview` into input area +- [x] Handle upload progress in preview +- [x] Clear preview after successful upload + +## Phase 4: AI Report Context + +### T-4.1: Report Data Service Update +- [x] Modify `report_data_service.py` to fetch file context +- [x] Join files with their associated messages +- [x] Include surrounding messages (1 before, 1 after) +- [x] Format file entries with context for AI prompt + +### T-4.2: Update AI Prompt +- [x] Modify prompt to explain file context format +- [x] Include file captions and context in prompt + +### T-4.3: Update DOCX Assembly +- [x] Add captions below embedded images +- [x] Include file context in attachments section + +## Phase 5: Timezone Fixes + +### T-5.1: Audit Timezone Usage +- [x] Review all timestamp displays in frontend +- [x] Identify any remaining GMT+0 usages +- [x] Document locations needing fixes + +### T-5.2: Apply GMT+8 Consistently +- [x] Update file drawer timestamps to use `formatDateTimeGMT8` +- [x] Update report metadata timestamps +- [x] Update any API responses returning timestamps +- [x] Verify backend returns ISO strings (UTC is fine, frontend converts) + +## Phase 6: Migration & Testing + +### T-6.1: Data Migration for Existing Files +- [x] Create script to generate messages for orphan files +- [x] Associate existing files with generated messages +- [x] Preserve original upload timestamps + +### T-6.2: Testing +- [x] Test file upload creates message (API verified) +- [x] Test image thumbnail display in chat (component verified) +- [x] Test lightbox open/close/download (component verified) +- [x] Test non-image file display with icons (component verified) +- [x] Test upload preview flow (component verified) +- [x] Test AI report includes file context (code verified) +- [x] Test all timestamps in GMT+8 (code verified) +- [x] Build verification (frontend builds successfully) + +## Implementation Summary + +### Files Modified/Created: + +**Backend:** +- `app/modules/file_storage/models.py` - Added `message_id` FK column +- `app/modules/file_storage/schemas.py` - Added `message_id`, `thumbnail_url` fields +- `app/modules/file_storage/services/file_service.py` - Updated upload/delete to create/cascade messages +- `app/modules/file_storage/router.py` - Updated broadcasts with message_id +- `app/modules/realtime/schemas.py` - Added `MessageDeletedBroadcast`, updated file broadcasts +- `app/modules/report_generation/services/report_data_service.py` - Added file context fetching +- `app/modules/report_generation/services/docx_service.py` - GMT+8 timestamps, captions +- `app/modules/report_generation/prompts.py` - GMT+8 timestamps, file context format +- `alembic/versions/a1b2c3d4e5f6_add_message_id_to_room_files.py` - Migration +- `scripts/migrate_orphan_files.py` - Data migration script + +**Frontend:** +- `frontend/src/types/index.ts` - Updated types with message_id, thumbnail_url +- `frontend/src/components/chat/FileMessage.tsx` - New component for file messages +- `frontend/src/components/chat/ImageLightbox.tsx` - New lightbox component +- `frontend/src/components/chat/UploadPreview.tsx` - New upload preview component +- `frontend/src/pages/RoomDetail.tsx` - Integrated FileMessage, UploadPreview diff --git a/openspec/specs/ai-report-generation/spec.md b/openspec/specs/ai-report-generation/spec.md index 58967cb..140c9ca 100644 --- a/openspec/specs/ai-report-generation/spec.md +++ b/openspec/specs/ai-report-generation/spec.md @@ -50,7 +50,7 @@ The system SHALL maintain a permanent `users` table to store user display names ### Requirement: Report Data Collection -The system SHALL collect all relevant room data for AI processing, including messages, members, files, and room metadata. +The system SHALL collect all relevant room data for AI processing, including messages, members, files with their conversation context, and room metadata. #### Scenario: Collect complete room data for report generation @@ -62,9 +62,29 @@ The system SHALL collect all relevant room data for AI processing, including mes - Room metadata (title, incident_type, severity, status, location, description, timestamps) - All 50 messages sorted by created_at ascending - All 5 members with their roles (owner, editor, viewer) - - All 3 files with metadata (filename, type, uploader, upload time) + - All 3 files with metadata (filename, type, uploader, upload time) AND their associated message context - **AND** messages SHALL include sender display name (not just user_id) -- **AND** file references in messages SHALL be annotated as "[附件: filename.ext]" +- **AND** file references in messages SHALL be annotated with surrounding context + +#### Scenario: Include file context in report data +- **GIVEN** a file "defect_photo.jpg" was uploaded with the message "發現產品表面瑕疵" +- **AND** the previous message was "Line 3 溫度異常升高中" +- **AND** the next message was "已通知維修人員處理" +- **WHEN** report data is collected +- **THEN** the file entry SHALL include: + ```json + { + "file_id": "...", + "filename": "defect_photo.jpg", + "uploader_display_name": "陳工程師", + "uploaded_at": "2025-12-08T14:30:00+08:00", + "caption": "發現產品表面瑕疵", + "context_before": "Line 3 溫度異常升高中", + "context_after": "已通知維修人員處理" + } + ``` +- **AND** the AI prompt SHALL format files as: + `[附件: defect_photo.jpg] - 上傳者: 陳工程師 (14:30), 說明: "發現產品表面瑕疵" (前文: "Line 3 溫度異常升高中")` #### Scenario: Handle room with no messages @@ -82,8 +102,6 @@ The system SHALL collect all relevant room data for AI processing, including mes - **AND** summarize older messages by day (e.g., "2025-12-01: 45 則訊息討論設備檢修") - **AND** the total formatted content SHALL stay within token limits ---- - ### Requirement: DIFY AI Integration The system SHALL integrate with DIFY Chat API to generate structured report content from collected room data. @@ -126,7 +144,7 @@ The system SHALL integrate with DIFY Chat API to generate structured report cont ### Requirement: Document Assembly -The system SHALL assemble professional .docx documents from AI-generated content with embedded images from MinIO. +The system SHALL assemble professional .docx documents from AI-generated content with embedded images from MinIO and file context from conversations. #### Scenario: Generate complete report document @@ -142,8 +160,9 @@ The system SHALL assemble professional .docx documents from AI-generated content - Section 4: 處理過程 (from AI resolution_process.content) - Section 5: 目前狀態 (from AI current_status) - Section 6: 最終處置結果 (from AI final_resolution, if has_resolution=true) - - Section 7: 附件 (embedded images + file list) + - Section 7: 附件 (embedded images with captions + file list with context) - **AND** images SHALL be embedded at appropriate size (max width 15cm) +- **AND** each image SHALL include its caption from the upload message - **AND** document SHALL use professional formatting (標楷體 or similar) #### Scenario: Handle missing images during assembly @@ -163,8 +182,6 @@ The system SHALL assemble professional .docx documents from AI-generated content - **THEN** the system SHALL create a complete document without the embedded images section - **AND** the attachments section SHALL show "本事件無附件檔案" if no files exist ---- - ### Requirement: Report Generation API The system SHALL provide REST API endpoints for triggering report generation and downloading generated reports. diff --git a/openspec/specs/file-storage/spec.md b/openspec/specs/file-storage/spec.md index b5eb3ad..b43b9e0 100644 --- a/openspec/specs/file-storage/spec.md +++ b/openspec/specs/file-storage/spec.md @@ -4,7 +4,7 @@ TBD - created by archiving change add-file-upload-minio. Update Purpose after archive. ## Requirements ### Requirement: File Upload with Validation -The system SHALL accept multipart file uploads to incident rooms, validate file type and size, and persist files to MinIO object storage with metadata tracking in PostgreSQL. +The system SHALL accept multipart file uploads to incident rooms, validate file type and size, persist files to MinIO object storage with metadata tracking in PostgreSQL, AND create an associated message in the chat for context. #### Scenario: Upload image to incident room - **WHEN** a user with OWNER or EDITOR role uploads an image file via `POST /api/rooms/{room_id}/files` @@ -20,11 +20,19 @@ The system SHALL accept multipart file uploads to incident rooms, validate file - Validate JWT token and extract user_id - Verify user is member of room-123 with OWNER or EDITOR role - Validate file MIME type is image/jpeg, image/png, or image/gif - - Validate file size ≤ 10MB + - Validate file size <= 10MB - Generate unique file_id (UUID) - Upload file to MinIO bucket `task-reporter-files` at path `room-123/images/{file_id}.jpg` - - Create database record in `room_files` table - - Return file metadata with presigned download URL (1-hour expiry) + - Create a message with `message_type=image_ref` containing file metadata + - Create database record in `room_files` table with `message_id` reference + - Return file metadata with presigned download URL (1-hour expiry) and message_id + +#### Scenario: Upload document to incident room +- **WHEN** a user uploads a non-image file (PDF, log, etc.) +- **THEN** the system SHALL: + - Create a message with `message_type=file_ref` + - Store message_id in the file record + - Return file metadata with message_id #### Scenario: Reject oversized file upload - **WHEN** a user attempts to upload a 15MB PDF file @@ -34,6 +42,7 @@ The system SHALL accept multipart file uploads to incident rooms, validate file - Include error message: "File size exceeds limit: 15MB > 20MB" - NOT upload file to MinIO - NOT create database record + - NOT create chat message #### Scenario: Reject unauthorized file type - **WHEN** a user attempts to upload an executable file (e.g., .exe, .sh, .bat) @@ -57,7 +66,8 @@ The system SHALL accept multipart file uploads to incident rooms, validate file - Validate MIME type is text/plain - Upload to MinIO at `room-456/logs/{file_id}.log` - Store metadata with file_type='log' - - Return success response with file_id + - Create a message with message_type='file_ref' + - Return success response with file_id and message_id ### Requirement: File Download with Access Control The system SHALL generate time-limited presigned download URLs for files, enforcing room membership-based access control. @@ -264,3 +274,39 @@ The system SHALL validate file types using MIME type detection (not just file ex - NOT upload to MinIO - Log security event +### Requirement: File-Message Association +The system SHALL maintain a foreign key relationship between uploaded files and their associated chat messages, enabling contextual display of files in conversations. + +#### Scenario: Query file with associated message +- **WHEN** a client requests file metadata via `GET /api/rooms/{room_id}/files/{file_id}` +- **THEN** the response SHALL include: + ```json + { + "file_id": "550e8400-e29b-41d4-a716-446655440000", + "message_id": "msg-789", + "filename": "defect.jpg", + "file_type": "image", + "download_url": "...", + "uploaded_at": "2025-12-08T10:30:00+08:00" + } + ``` + +#### Scenario: Delete file cascades to message +- **WHEN** a file is soft-deleted via `DELETE /api/rooms/{room_id}/files/{file_id}` +- **THEN** the system SHALL also soft-delete the associated message +- **AND** broadcast both `file_deleted` and `message_deleted` events + +### Requirement: Image Thumbnail URLs +The system SHALL generate presigned URLs suitable for thumbnail display, allowing frontends to efficiently render image previews. + +#### Scenario: File metadata includes thumbnail URL +- **WHEN** file metadata is returned for an image file +- **THEN** the response SHALL include a `thumbnail_url` field +- **AND** the URL SHALL be a presigned MinIO URL valid for 1 hour +- **AND** the frontend SHALL use CSS to constrain thumbnail display size + +#### Scenario: Non-image files have no thumbnail +- **WHEN** file metadata is returned for a non-image file (PDF, log, etc.) +- **THEN** the response SHALL NOT include a `thumbnail_url` field +- **AND** the frontend SHALL display a file-type icon instead + diff --git a/openspec/specs/realtime-messaging/spec.md b/openspec/specs/realtime-messaging/spec.md index edccb17..e5897e7 100644 --- a/openspec/specs/realtime-messaging/spec.md +++ b/openspec/specs/realtime-messaging/spec.md @@ -90,7 +90,7 @@ The system SHALL persist all messages to database for audit trail, report genera - **AND** maintain user's access control (only rooms they're members of) ### Requirement: Message Types and Formatting -The system SHALL support various message types including text, image references, file references, and structured data for production incidents. +The system SHALL support various message types including text, image references, file references, and structured data for production incidents, with inline display of file attachments in the chat view. #### Scenario: Text message with mentions - **WHEN** a user sends a message with @mentions @@ -103,19 +103,24 @@ The system SHALL support various message types including text, image references, - **THEN** the system SHALL parse and store mentions - **AND** potentially trigger notifications to mentioned users -#### Scenario: Image reference message -- **WHEN** a user uploads an image and sends reference - ```json - { - "type": "message", - "message_type": "image_ref", - "content": "Defect found on product", - "file_id": "550e8400-e29b-41d4-a716-446655440000", - "file_url": "http://localhost:9000/bucket/room-123/image.jpg" - } - ``` -- **THEN** the system SHALL store the file reference -- **AND** clients SHALL display image preview inline +#### Scenario: Image reference message display +- **WHEN** a message with `message_type=image_ref` is rendered in the chat +- **THEN** the client SHALL display: + - A thumbnail of the image (max 300px width) + - The message content/caption below the image + - Sender name and timestamp + - A click-to-expand functionality +- **AND** clicking the thumbnail SHALL open a full-size preview lightbox + +#### Scenario: File reference message display +- **WHEN** a message with `message_type=file_ref` is rendered in the chat +- **THEN** the client SHALL display: + - A file type icon (PDF, document, log, etc.) + - The filename + - File size in human-readable format + - A download button/link + - The message content/caption + - Sender name and timestamp #### Scenario: Structured incident data - **WHEN** reporting specific incident metrics @@ -265,7 +270,7 @@ The system SHALL include the sender's display name in message responses and broa ### Requirement: GMT+8 Timezone Display -The frontend SHALL display all timestamps in GMT+8 (Asia/Taipei) timezone for consistent user experience across all browsers. +The frontend SHALL display all timestamps in GMT+8 (Asia/Taipei) timezone for consistent user experience across all browsers and all parts of the application. #### Scenario: Message timestamp in GMT+8 - **WHEN** a message is displayed in the chat room @@ -277,6 +282,14 @@ The frontend SHALL display all timestamps in GMT+8 (Asia/Taipei) timezone for co - **WHEN** the room list is displayed - **THEN** the "last updated" time SHALL be formatted in GMT+8 timezone +#### Scenario: File upload timestamp in GMT+8 +- **WHEN** a file is displayed in chat or file drawer +- **THEN** the upload timestamp SHALL be formatted in GMT+8 timezone + +#### Scenario: Report generation timestamp in GMT+8 +- **WHEN** report metadata is displayed +- **THEN** the "generated at" timestamp SHALL be formatted in GMT+8 timezone + ### Requirement: @Mention Support The messaging system SHALL support @mention functionality to tag specific users in messages. @@ -337,3 +350,61 @@ Messages with @mentions SHALL store the mention metadata for querying. - **WHEN** fetching messages that mention a specific user - **THEN** messages with that user_id in `mentions` array are returned +### Requirement: Image Preview Lightbox +The frontend SHALL provide a lightbox component for viewing full-size images from chat messages. + +#### Scenario: Open image lightbox +- **WHEN** user clicks on an image thumbnail in the chat +- **THEN** a modal overlay SHALL appear +- **AND** the full-size image SHALL be displayed centered +- **AND** a loading indicator SHALL show while image loads +- **AND** the image SHALL be constrained to fit the viewport + +#### Scenario: Close image lightbox +- **WHEN** the lightbox is open +- **THEN** user can close it by: + - Clicking the X button + - Pressing the ESC key + - Clicking outside the image +- **AND** focus SHALL return to the chat + +#### Scenario: Image lightbox with download +- **WHEN** the lightbox is open +- **THEN** a download button SHALL be visible +- **AND** clicking it SHALL download the original file + +### Requirement: File Type Icons +The frontend SHALL display appropriate icons for different file types in chat messages and file drawer. + +#### Scenario: PDF file icon +- **WHEN** a PDF file is displayed +- **THEN** a PDF icon (red/document style) SHALL be shown + +#### Scenario: Log/text file icon +- **WHEN** a .log or .txt file is displayed +- **THEN** a text file icon SHALL be shown + +#### Scenario: Excel file icon +- **WHEN** an Excel file (.xlsx, .xls) is displayed +- **THEN** a spreadsheet icon (green) SHALL be shown + +#### Scenario: Generic file icon +- **WHEN** a file with unknown type is displayed +- **THEN** a generic document icon SHALL be shown + +### Requirement: Upload Preview +The frontend SHALL show a preview of the file being uploaded before the message is sent. + +#### Scenario: Image upload preview +- **WHEN** user selects an image file for upload +- **THEN** a preview thumbnail SHALL be displayed in the input area +- **AND** user can add a caption/description +- **AND** user can cancel the upload before sending +- **AND** a send button confirms the upload + +#### Scenario: File upload preview +- **WHEN** user selects a non-image file for upload +- **THEN** file info (name, size, type icon) SHALL be displayed +- **AND** user can add a description +- **AND** user can cancel or confirm + diff --git a/scripts/migrate_orphan_files.py b/scripts/migrate_orphan_files.py new file mode 100644 index 0000000..a56b5b5 --- /dev/null +++ b/scripts/migrate_orphan_files.py @@ -0,0 +1,134 @@ +"""Migration script to create messages for existing files without message_id + +This script: +1. Finds all files in tr_room_files that have no message_id +2. Creates an associated message (image_ref or file_ref) for each +3. Updates the file record with the new message_id + +Run from project root: + python scripts/migrate_orphan_files.py +""" +import sys +import os + +# Add project root to path +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from datetime import datetime +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from app.core.config import get_settings +from app.modules.file_storage.models import RoomFile +from app.modules.realtime.models import Message, MessageType +from app.modules.file_storage.services.minio_service import generate_presigned_url + +settings = get_settings() + + +def get_db_session(): + """Create database session""" + engine = create_engine(settings.DATABASE_URL) + Session = sessionmaker(bind=engine) + return Session() + + +def migrate_orphan_files(): + """Migrate orphan files by creating associated messages""" + db = get_db_session() + + try: + # Find files without message_id + orphan_files = db.query(RoomFile).filter( + RoomFile.message_id.is_(None), + RoomFile.deleted_at.is_(None) + ).all() + + if not orphan_files: + print("No orphan files found. Nothing to migrate.") + return + + print(f"Found {len(orphan_files)} orphan files to migrate.") + + migrated = 0 + failed = 0 + + for file in orphan_files: + try: + # Generate presigned URL for file + download_url = generate_presigned_url( + bucket=file.minio_bucket, + object_path=file.minio_object_path, + expiry_seconds=3600 + ) + + # Determine message type + if file.file_type == "image": + msg_type = MessageType.IMAGE_REF + content = f"[Image] {file.filename}" + else: + msg_type = MessageType.FILE_REF + content = f"[File] {file.filename}" + + # Get next sequence number for the room + max_seq = db.query(Message.sequence_number).filter( + Message.room_id == file.room_id + ).order_by(Message.sequence_number.desc()).first() + next_seq = (max_seq[0] + 1) if max_seq else 1 + + # Create metadata + metadata = { + "file_id": file.file_id, + "file_url": download_url, + "filename": file.filename, + "file_type": file.file_type, + "mime_type": file.mime_type, + "file_size": file.file_size, + } + + # Add thumbnail_url for images + if file.file_type == "image": + metadata["thumbnail_url"] = download_url + + # Create message + message = Message( + room_id=file.room_id, + sender_id=file.uploader_id, + content=content, + message_type=msg_type, + message_metadata=metadata, + created_at=file.uploaded_at, # Use original upload time + sequence_number=next_seq, + ) + + db.add(message) + db.flush() # Get the message_id + + # Update file with message_id + file.message_id = message.message_id + + migrated += 1 + print(f" Migrated: {file.filename} -> message {message.message_id}") + + except Exception as e: + failed += 1 + print(f" Failed: {file.filename} - {e}") + + # Commit all changes + db.commit() + print(f"\nMigration complete: {migrated} migrated, {failed} failed") + + except Exception as e: + db.rollback() + print(f"Migration failed: {e}") + raise + finally: + db.close() + + +if __name__ == "__main__": + print("Starting orphan files migration...") + print("=" * 50) + migrate_orphan_files() + print("=" * 50) + print("Done.")