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

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

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

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

View File

@@ -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')

View File

@@ -3,12 +3,37 @@
生產線異常即時反應系統 (Task Reporter) 生產線異常即時反應系統 (Task Reporter)
""" """
import os import os
import json
from pathlib import Path from pathlib import Path
from datetime import datetime
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse from fastapi.responses import FileResponse, JSONResponse
from app.core.config import get_settings 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 import router as auth_router
from app.modules.auth.users_router import router as users_router from app.modules.auth.users_router import router as users_router
from app.modules.auth.middleware import auth_middleware from app.modules.auth.middleware import auth_middleware
@@ -26,12 +51,13 @@ settings = get_settings()
# Database tables are managed by Alembic migrations # Database tables are managed by Alembic migrations
# Run: alembic upgrade head # Run: alembic upgrade head
# Initialize FastAPI app # Initialize FastAPI app with custom JSON response for UTC datetime
app = FastAPI( app = FastAPI(
title="Task Reporter API", title="Task Reporter API",
description="Production Line Incident Response System - 生產線異常即時反應系統", description="Production Line Incident Response System - 生產線異常即時反應系統",
version="1.0.0", version="1.0.0",
debug=settings.DEBUG, debug=settings.DEBUG,
default_response_class=UTCJSONResponse,
) )
# CORS middleware - origins configured via CORS_ORIGINS environment variable # CORS middleware - origins configured via CORS_ORIGINS environment variable

View File

@@ -79,6 +79,7 @@ class IncidentRoom(Base):
# Relationships # Relationships
members = relationship("RoomMember", back_populates="room", cascade="all, delete-orphan") members = relationship("RoomMember", back_populates="room", cascade="all, delete-orphan")
files = relationship("RoomFile", 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 # Indexes for common queries
__table_args__ = ( __table_args__ = (

View File

@@ -193,7 +193,7 @@ async def permanent_delete_room(
"type": "system", "type": "system",
"event": "room_deleted", "event": "room_deleted",
"room_id": room_id, "room_id": room_id,
"timestamp": datetime.utcnow().isoformat() "timestamp": datetime.utcnow().isoformat() + "Z"
}) })
success, error = room_service.permanent_delete_room(db, room_id) success, error = room_service.permanent_delete_room(db, room_id)
@@ -246,7 +246,7 @@ async def join_room(
detail={ detail={
"message": "Already a member of this room", "message": "Already a member of this room",
"current_role": existing.role.value, "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") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Room not found")
# Get current lots or initialize empty list # 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 # Prevent duplicates
if request.lot not in current_lots: if request.lot not in current_lots:
current_lots.append(request.lot) 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() room.last_updated_at = datetime.utcnow()
db.commit() db.commit()
db.refresh(room) db.refresh(room)
@@ -532,11 +532,11 @@ async def remove_lot(
if not room: if not room:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Room not found") 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: if lot in current_lots:
current_lots.remove(lot) 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() room.last_updated_at = datetime.utcnow()
db.commit() db.commit()
db.refresh(room) db.refresh(room)

View File

@@ -2,7 +2,7 @@
Request and response models for API endpoints 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 typing import Optional, List
from datetime import datetime from datetime import datetime
from enum import Enum from enum import Enum
@@ -98,8 +98,14 @@ class MemberResponse(BaseModel):
added_at: datetime added_at: datetime
removed_at: Optional[datetime] = None removed_at: Optional[datetime] = None
class Config: model_config = ConfigDict(from_attributes=True)
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): class RoomResponse(BaseModel):
@@ -127,8 +133,17 @@ class RoomResponse(BaseModel):
is_member: bool = False is_member: bool = False
is_admin_view: bool = False is_admin_view: bool = False
class Config: model_config = ConfigDict(from_attributes=True)
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): class RoomListResponse(BaseModel):

View File

@@ -467,7 +467,11 @@ class RoomService:
f"Failed to delete report file: {report.docx_storage_path}" 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.delete(room)
db.commit() db.commit()

View File

@@ -16,6 +16,9 @@ class RoomFile(Base):
# Foreign key to incident room (CASCADE delete when room is permanently deleted) # 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) 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 # File metadata
uploader_id = Column(String(255), nullable=False) uploader_id = Column(String(255), nullable=False)
filename = Column(String(255), nullable=False) filename = Column(String(255), nullable=False)
@@ -33,11 +36,13 @@ class RoomFile(Base):
# Relationships # Relationships
room = relationship("IncidentRoom", back_populates="files") room = relationship("IncidentRoom", back_populates="files")
message = relationship("Message", backref="file_attachment", uselist=False)
# Indexes # Indexes
__table_args__ = ( __table_args__ = (
Index("ix_tr_room_files_room_uploaded", "room_id", "uploaded_at"), Index("ix_tr_room_files_room_uploaded", "room_id", "uploaded_at"),
Index("ix_tr_room_files_uploader", "uploader_id"), Index("ix_tr_room_files_uploader", "uploader_id"),
Index("ix_tr_room_files_message", "message_id"),
) )
def __repr__(self): def __repr__(self):

View File

@@ -19,7 +19,8 @@ from app.modules.file_storage.schemas import FileUploadResponse, FileMetadata, F
from app.modules.file_storage.services.file_service import FileService from app.modules.file_storage.services.file_service import FileService
from app.modules.file_storage.services import minio_service from app.modules.file_storage.services import minio_service
from app.modules.realtime.websocket_manager import manager as websocket_manager 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__) logger = logging.getLogger(__name__)
@@ -58,11 +59,52 @@ async def upload_file(
# Upload file # Upload file
result = FileService.upload_file(db, room_id, user_email, file, description) 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 # Broadcast file upload event to room members via WebSocket
async def broadcast_file_upload(): async def broadcast_file_upload():
try: 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( broadcast = FileUploadedBroadcast(
file_id=result.file_id, file_id=result.file_id,
message_id=result.message_id,
room_id=room_id, room_id=room_id,
uploader_id=user_email, uploader_id=user_email,
filename=result.filename, filename=result.filename,
@@ -70,10 +112,11 @@ async def upload_file(
file_size=result.file_size, file_size=result.file_size,
mime_type=result.mime_type, mime_type=result.mime_type,
download_url=result.download_url, download_url=result.download_url,
thumbnail_url=result.thumbnail_url,
uploaded_at=result.uploaded_at uploaded_at=result.uploaded_at
) )
await websocket_manager.broadcast_to_room(room_id, broadcast.to_dict()) 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 # Send acknowledgment to uploader
ack = FileUploadAck( ack = FileUploadAck(
@@ -86,7 +129,7 @@ async def upload_file(
logger.error(f"Failed to broadcast file upload: {e}") logger.error(f"Failed to broadcast file upload: {e}")
# Run broadcast in background # Run broadcast in background
background_tasks.add_task(asyncio.create_task, broadcast_file_upload()) background_tasks.add_task(broadcast_file_upload)
return result return result
@@ -149,9 +192,13 @@ async def get_file(
expiry_seconds=3600 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 # Build response with download URL
return FileMetadata( return FileMetadata(
file_id=file_record.file_id, file_id=file_record.file_id,
message_id=file_record.message_id,
room_id=file_record.room_id, room_id=file_record.room_id,
filename=file_record.filename, filename=file_record.filename,
file_type=file_record.file_type, file_type=file_record.file_type,
@@ -162,7 +209,8 @@ async def get_file(
uploaded_at=file_record.uploaded_at, uploaded_at=file_record.uploaded_at,
uploader_id=file_record.uploader_id, uploader_id=file_record.uploader_id,
deleted_at=file_record.deleted_at, 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 # Check if admin
is_admin = membership_service.is_system_admin(user_email) is_admin = membership_service.is_system_admin(user_email)
# Delete file (service will verify permissions) # Delete file (service will verify permissions and cascade to message)
deleted_file = FileService.delete_file(db, file_id, user_email, is_room_owner or is_admin) 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: if deleted_file:
async def broadcast_file_delete(): async def broadcast_file_delete():
try: try:
broadcast = FileDeletedBroadcast( # Broadcast file deleted event
file_broadcast = FileDeletedBroadcast(
file_id=file_id, file_id=file_id,
message_id=deleted_message_id,
room_id=room_id, room_id=room_id,
deleted_by=user_email, deleted_by=user_email,
deleted_at=deleted_file.deleted_at 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}") 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: 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 # Run broadcast in background
background_tasks.add_task(asyncio.create_task, broadcast_file_delete()) background_tasks.add_task(broadcast_file_delete)
return None return None

View File

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

View File

@@ -69,11 +69,38 @@ class FileService:
detail="File storage service temporarily unavailable" detail="File storage service temporarily unavailable"
) )
# Create database record # Create database record and associated message
try: 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( room_file = RoomFile(
file_id=file_id, file_id=file_id,
room_id=room_id, room_id=room_id,
message_id=message.message_id,
uploader_id=uploader_id, uploader_id=uploader_id,
filename=file.filename, filename=file.filename,
file_type=file_type, file_type=file_type,
@@ -88,20 +115,15 @@ class FileService:
db.commit() db.commit()
db.refresh(room_file) 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( return FileUploadResponse(
file_id=file_id, file_id=file_id,
message_id=message.message_id,
filename=file.filename, filename=file.filename,
file_type=file_type, file_type=file_type,
file_size=file_size, file_size=file_size,
mime_type=mime_type, mime_type=mime_type,
download_url=download_url, download_url=download_url,
thumbnail_url=thumbnail_url,
uploaded_at=room_file.uploaded_at, uploaded_at=room_file.uploaded_at,
uploader_id=uploader_id uploader_id=uploader_id
) )
@@ -160,12 +182,17 @@ class FileService:
file_id: str, file_id: str,
user_id: str, user_id: str,
is_room_owner: bool = False is_room_owner: bool = False
) -> Optional[RoomFile]: ) -> tuple[Optional[RoomFile], Optional[str]]:
"""Soft delete file""" """
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() file = db.query(RoomFile).filter(RoomFile.file_id == file_id).first()
if not file: if not file:
return None return None, None
# Check permissions # Check permissions
if not is_room_owner and file.uploader_id != user_id: 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" 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() file.deleted_at = datetime.utcnow()
db.commit() db.commit()
db.refresh(file) db.refresh(file)
return file return file, deleted_message_id
@staticmethod @staticmethod
def check_room_membership(db: Session, room_id: str, user_id: str) -> Optional[RoomMember]: def check_room_membership(db: Session, room_id: str, user_id: str) -> Optional[RoomMember]:
@@ -205,7 +241,10 @@ class FileService:
file_id: str, file_id: str,
filename: str, filename: str,
file_type: str, file_type: str,
mime_type: str,
file_size: int,
file_url: str, file_url: str,
thumbnail_url: Optional[str] = None,
description: Optional[str] = None description: Optional[str] = None
) -> Message: ) -> Message:
""" """
@@ -218,7 +257,10 @@ class FileService:
file_id: File ID in room_files table file_id: File ID in room_files table
filename: Original filename filename: Original filename
file_type: Type of file (image, document, log) 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 file_url: Presigned download URL
thumbnail_url: Presigned thumbnail URL for images
description: Optional description for the file description: Optional description for the file
Returns: Returns:
@@ -237,9 +279,15 @@ class FileService:
"file_id": file_id, "file_id": file_id,
"file_url": file_url, "file_url": file_url,
"filename": filename, "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 # Use MessageService to create the message
return MessageService.create_message( return MessageService.create_message(
db=db, db=db,

View File

@@ -1,7 +1,8 @@
"""File validation utilities""" """File validation utilities"""
import magic import magic
import os
from fastapi import UploadFile, HTTPException from fastapi import UploadFile, HTTPException
from typing import Set from typing import Set, Dict
import logging import logging
from app.core.config import get_settings from app.core.config import get_settings
@@ -17,7 +18,15 @@ IMAGE_TYPES: Set[str] = {
} }
DOCUMENT_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] = { 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) detected_mime = detect_mime_type(header)
if detected_mime not in allowed_types: 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( raise HTTPException(
status_code=400, status_code=400,
detail=f"File type not allowed: {detected_mime}. Allowed types: {', '.join(allowed_types)}" 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: Raises:
HTTPException if MIME type not recognized 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: if mime_type in IMAGE_TYPES:
return ("image", settings.get_image_max_size_bytes()) 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()) return ("document", settings.get_document_max_size_bytes())
elif mime_type in LOG_TYPES: elif mime_type in LOG_TYPES:
return ("log", settings.get_log_max_size_bytes()) return ("log", settings.get_log_max_size_bytes())

View File

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

View File

@@ -14,7 +14,8 @@ settings = get_settings()
def json_serializer(obj: Any) -> str: def json_serializer(obj: Any) -> str:
"""Custom JSON serializer for objects not serializable by default json code""" """Custom JSON serializer for objects not serializable by default json code"""
if isinstance(obj, datetime): 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") raise TypeError(f"Object of type {type(obj).__name__} is not JSON serializable")

View File

@@ -90,7 +90,7 @@ class GeneratedReport(Base):
) )
# Relationship # Relationship
room = relationship("IncidentRoom", backref="reports") room = relationship("IncidentRoom", back_populates="reports")
# Indexes # Indexes
__table_args__ = ( __table_args__ = (

View File

@@ -4,7 +4,18 @@ Contains the prompt construction logic for building the user query
sent to DIFY Chat API. sent to DIFY Chat API.
""" """
from typing import List, Dict, Any 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 = { INCIDENT_TYPE_MAP = {
@@ -81,11 +92,11 @@ def _format_room_info(room_data: Dict[str, Any]) -> str:
created_at = room_data.get("created_at") created_at = room_data.get("created_at")
if isinstance(created_at, datetime): 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") resolved_at = room_data.get("resolved_at")
if isinstance(resolved_at, datetime): 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: elif resolved_at is None:
resolved_at = "尚未解決" resolved_at = "尚未解決"
@@ -145,7 +156,7 @@ def _format_messages(messages: List[Dict[str, Any]]) -> str:
created_at = msg.get("created_at") created_at = msg.get("created_at")
if isinstance(created_at, datetime): 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: else:
time_str = str(created_at) if created_at 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: 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 = ["## 附件清單"]
lines.append("每個附件包含上傳時的說明文字以及上下文訊息,幫助理解該附件的用途。")
lines.append("")
if not files: if not files:
lines.append("無附件") lines.append("無附件")
return "\n".join(lines) return "\n".join(lines)
for f in files: for i, f in enumerate(files, 1):
filename = f.get("filename", "未命名檔案") filename = f.get("filename", "未命名檔案")
file_type = f.get("file_type", "file") file_type = f.get("file_type", "file")
uploader = f.get("uploader_name") or f.get("uploaded_by", "未知") 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") uploaded_at = f.get("uploaded_at")
if isinstance(uploaded_at, datetime): 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: else:
time_str = str(uploaded_at) if uploaded_at else "" time_str = str(uploaded_at) if uploaded_at else ""
type_label = "圖片" if file_type == "image" 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) return "\n".join(lines)

View File

@@ -2,7 +2,7 @@
Request and response models for the report generation endpoints. 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 typing import Optional, List
from datetime import datetime from datetime import datetime
from enum import Enum from enum import Enum
@@ -45,8 +45,12 @@ class ReportStatusResponse(BaseModel):
prompt_tokens: Optional[int] = None prompt_tokens: Optional[int] = None
completion_tokens: Optional[int] = None completion_tokens: Optional[int] = None
class Config: model_config = ConfigDict(from_attributes=True)
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): class ReportListItem(BaseModel):
@@ -57,8 +61,12 @@ class ReportListItem(BaseModel):
status: ReportStatus status: ReportStatus
report_title: Optional[str] = None report_title: Optional[str] = None
class Config: model_config = ConfigDict(from_attributes=True)
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): class ReportListResponse(BaseModel):

View File

@@ -9,8 +9,21 @@ Creates .docx reports using python-docx with:
import io import io
import logging import logging
from typing import Dict, Any, List, Optional from typing import Dict, Any, List, Optional
from datetime import datetime from datetime import datetime, timezone, timedelta
from docx import Document 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.shared import Inches, Pt, RGBColor
from docx.enum.text import WD_ALIGN_PARAGRAPH from docx.enum.text import WD_ALIGN_PARAGRAPH
from docx.enum.style import WD_STYLE_TYPE from docx.enum.style import WD_STYLE_TYPE
@@ -128,11 +141,11 @@ class DocxAssemblyService:
run.font.size = TITLE_SIZE run.font.size = TITLE_SIZE
run.font.bold = True run.font.bold = True
# Add generation timestamp # Add generation timestamp in GMT+8
timestamp = datetime.utcnow().strftime("%Y-%m-%d %H:%M") timestamp = datetime.now(TZ_GMT8).strftime("%Y-%m-%d %H:%M")
subtitle = doc.add_paragraph() subtitle = doc.add_paragraph()
subtitle.alignment = WD_ALIGN_PARAGRAPH.CENTER 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.size = Pt(10)
run.font.color.rgb = RGBColor(128, 128, 128) run.font.color.rgb = RGBColor(128, 128, 128)
@@ -160,19 +173,19 @@ class DocxAssemblyService:
cells[2].text = "發生地點" cells[2].text = "發生地點"
cells[3].text = room_data.get("location") or "未指定" 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 = table.rows[2].cells
cells[0].text = "建立時間" cells[0].text = "建立時間"
created_at = room_data.get("created_at") created_at = room_data.get("created_at")
if isinstance(created_at, datetime): 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: else:
cells[1].text = str(created_at) if created_at else "未知" cells[1].text = str(created_at) if created_at else "未知"
cells[2].text = "解決時間" cells[2].text = "解決時間"
resolved_at = room_data.get("resolved_at") resolved_at = room_data.get("resolved_at")
if isinstance(resolved_at, datetime): 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: elif resolved_at:
cells[3].text = str(resolved_at) cells[3].text = str(resolved_at)
else: else:
@@ -327,13 +340,24 @@ class DocxAssemblyService:
# Add image to document # Add image to document
doc.add_picture(image_data, width=Inches(5)) 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 = doc.add_paragraph()
caption.alignment = WD_ALIGN_PARAGRAPH.CENTER 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.size = Pt(9)
run.font.italic = True 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 doc.add_paragraph() # Spacing
else: else:
# Image download failed, add note # Image download failed, add note
@@ -344,7 +368,7 @@ class DocxAssemblyService:
doc.add_paragraph(f"[圖片嵌入失敗: {f.get('filename', '未知')}]") doc.add_paragraph(f"[圖片嵌入失敗: {f.get('filename', '未知')}]")
def _add_file_list_section(self, doc: Document, files: List[Dict[str, Any]]): 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) doc.add_heading("附件清單", level=1)
if not files: if not files:
@@ -352,7 +376,7 @@ class DocxAssemblyService:
return return
# Create file list table # 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" table.style = "Table Grid"
# Header row # Header row
@@ -361,6 +385,7 @@ class DocxAssemblyService:
header[1].text = "類型" header[1].text = "類型"
header[2].text = "上傳者" header[2].text = "上傳者"
header[3].text = "上傳時間" header[3].text = "上傳時間"
header[4].text = "說明"
for cell in header: for cell in header:
for run in cell.paragraphs[0].runs: for run in cell.paragraphs[0].runs:
run.font.bold = True run.font.bold = True
@@ -382,10 +407,13 @@ class DocxAssemblyService:
uploaded_at = f.get("uploaded_at") uploaded_at = f.get("uploaded_at")
if isinstance(uploaded_at, datetime): 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: else:
row[3].text = str(uploaded_at) if uploaded_at 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]: def _download_file(self, object_path: str) -> Optional[io.BytesIO]:
"""Download file from MinIO """Download file from MinIO
@@ -431,9 +459,9 @@ class DocxAssemblyService:
lines.append(f"# 事件報告:{title}") lines.append(f"# 事件報告:{title}")
lines.append("") lines.append("")
# Generation timestamp # Generation timestamp in GMT+8
timestamp = datetime.utcnow().strftime("%Y-%m-%d %H:%M") timestamp = datetime.now(TZ_GMT8).strftime("%Y-%m-%d %H:%M")
lines.append(f"*報告產生時間:{timestamp}*") lines.append(f"*報告產生時間:{timestamp} (GMT+8)*")
lines.append("") lines.append("")
# Metadata section # Metadata section
@@ -455,13 +483,13 @@ class DocxAssemblyService:
created_at = room_data.get("created_at") created_at = room_data.get("created_at")
if isinstance(created_at, datetime): 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: else:
lines.append(f"| 建立時間 | {str(created_at) if created_at else '未知'} |") lines.append(f"| 建立時間 | {str(created_at) if created_at else '未知'} |")
resolved_at = room_data.get("resolved_at") resolved_at = room_data.get("resolved_at")
if isinstance(resolved_at, datetime): 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: elif resolved_at:
lines.append(f"| 解決時間 | {str(resolved_at)} |") lines.append(f"| 解決時間 | {str(resolved_at)} |")
else: else:
@@ -561,8 +589,8 @@ class DocxAssemblyService:
if files: if files:
lines.append("## 附件清單") lines.append("## 附件清單")
lines.append("") lines.append("")
lines.append("| 檔案名稱 | 類型 | 上傳者 | 上傳時間 |") lines.append("| 檔案名稱 | 類型 | 上傳者 | 上傳時間 | 說明 |")
lines.append("|----------|------|--------|----------|") lines.append("|----------|------|--------|----------|------|")
file_type_map = { file_type_map = {
"image": "圖片", "image": "圖片",
@@ -577,10 +605,13 @@ class DocxAssemblyService:
uploader = f.get("uploader_name") or f.get("uploader_id", "") uploader = f.get("uploader_name") or f.get("uploader_id", "")
uploaded_at = f.get("uploaded_at") uploaded_at = f.get("uploaded_at")
if isinstance(uploaded_at, datetime): 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: else:
uploaded_text = str(uploaded_at) if uploaded_at 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("") lines.append("")
return "\n".join(lines) return "\n".join(lines)

View File

@@ -13,7 +13,7 @@ from sqlalchemy.orm import Session
from sqlalchemy import desc from sqlalchemy import desc
from app.modules.chat_room.models import IncidentRoom, RoomMember 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.file_storage.models import RoomFile
from app.modules.auth.models import User from app.modules.auth.models import User
@@ -38,9 +38,17 @@ class MemberData:
role: str role: str
@dataclass
class FileContextMessage:
"""Context message near a file upload"""
sender_name: str
content: str
created_at: datetime
@dataclass @dataclass
class FileData: class FileData:
"""File data for report generation""" """File data for report generation with context"""
file_id: str file_id: str
filename: str filename: str
file_type: str file_type: str
@@ -49,6 +57,10 @@ class FileData:
uploader_id: str uploader_id: str
uploader_name: str uploader_name: str
minio_object_path: 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 @dataclass
@@ -173,7 +185,7 @@ class ReportDataService:
return members return members
def _collect_files(self, room_id: str) -> List[FileData]: 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 = ( results = (
self.db.query(RoomFile, User.display_name) self.db.query(RoomFile, User.display_name)
.outerjoin(User, RoomFile.uploader_id == User.user_id) .outerjoin(User, RoomFile.uploader_id == User.user_id)
@@ -185,6 +197,31 @@ class ReportDataService:
files = [] files = []
for f, display_name in results: 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( files.append(FileData(
file_id=f.file_id, file_id=f.file_id,
filename=f.filename, filename=f.filename,
@@ -192,12 +229,45 @@ class ReportDataService:
mime_type=f.mime_type, mime_type=f.mime_type,
uploaded_at=f.uploaded_at, uploaded_at=f.uploaded_at,
uploader_id=f.uploader_id, 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, minio_object_path=f.minio_object_path,
message_content=message_content,
context_before=context_before,
context_after=context_after,
)) ))
return files 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]: def to_prompt_dict(self, data: RoomReportData) -> Dict[str, Any]:
"""Convert RoomReportData to dictionary format for prompt builder """Convert RoomReportData to dictionary format for prompt builder
@@ -244,8 +314,9 @@ class ReportDataService:
for m in data.members for m in data.members
] ]
files = [ files = []
{ for f in data.files:
file_dict = {
"file_id": f.file_id, "file_id": f.file_id,
"filename": f.filename, "filename": f.filename,
"file_type": f.file_type, "file_type": f.file_type,
@@ -254,9 +325,20 @@ class ReportDataService:
"uploader_id": f.uploader_id, "uploader_id": f.uploader_id,
"uploader_name": f.uploader_name, "uploader_name": f.uploader_name,
"minio_object_path": f.minio_object_path, "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 { return {
"room_data": room_data, "room_data": room_data,

View File

@@ -12,6 +12,7 @@ interface ActionBarProps {
canManageMembers: boolean canManageMembers: boolean
isGeneratingReport: boolean isGeneratingReport: boolean
uploadProgress: number | null uploadProgress: number | null
uploadInfo?: { current: number; total: number } | null // For multi-file upload
onFileSelect: (files: FileList | null) => void onFileSelect: (files: FileList | null) => void
onGenerateReport: () => void onGenerateReport: () => void
onAddMemberClick: () => void onAddMemberClick: () => void
@@ -29,6 +30,7 @@ export function ActionBar({
canManageMembers, canManageMembers,
isGeneratingReport, isGeneratingReport,
uploadProgress, uploadProgress,
uploadInfo,
onFileSelect, onFileSelect,
onGenerateReport, onGenerateReport,
onAddMemberClick, onAddMemberClick,
@@ -54,13 +56,18 @@ export function ActionBar({
return ( return (
<div className="bg-white border-t border-gray-200"> <div className="bg-white border-t border-gray-200">
{/* Hidden file input */} {/* Hidden file input - supports multiple file selection */}
<input <input
type="file" type="file"
ref={fileInputRef} ref={fileInputRef}
onChange={(e) => onFileSelect(e.target.files)} onChange={(e) => {
onFileSelect(e.target.files)
// Reset input so same files can be selected again
e.target.value = ''
}}
className="hidden" className="hidden"
accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.txt,.log" accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.txt,.log"
multiple
/> />
{/* Action bar content */} {/* Action bar content */}
@@ -121,12 +128,16 @@ export function ActionBar({
onClick={handleFileClick} onClick={handleFileClick}
disabled={uploadProgress !== null} disabled={uploadProgress !== null}
className={buttonClass} className={buttonClass}
title="Upload file" title="Upload files (multiple supported)"
> >
{uploadProgress !== null ? ( {uploadProgress !== null ? (
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<div className="w-4 h-4 border-2 border-blue-600 border-t-transparent rounded-full animate-spin" /> <div className="w-4 h-4 border-2 border-blue-600 border-t-transparent rounded-full animate-spin" />
<span>{uploadProgress}%</span> <span>
{uploadInfo && uploadInfo.total > 1
? `${uploadInfo.current}/${uploadInfo.total} (${uploadProgress}%)`
: `${uploadProgress}%`}
</span>
</div> </div>
) : ( ) : (
<> <>
@@ -217,7 +228,11 @@ export function ActionBar({
{uploadProgress !== null ? ( {uploadProgress !== null ? (
<> <>
<div className="w-5 h-5 border-2 border-blue-600 border-t-transparent rounded-full animate-spin" /> <div className="w-5 h-5 border-2 border-blue-600 border-t-transparent rounded-full animate-spin" />
<span className="text-xs">{uploadProgress}%</span> <span className="text-xs">
{uploadInfo && uploadInfo.total > 1
? `${uploadInfo.current}/${uploadInfo.total}`
: `${uploadProgress}%`}
</span>
</> </>
) : ( ) : (
<> <>

View File

@@ -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<string, { icon: React.ReactNode; color: string }> = {
'application/pdf': {
icon: (
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6zm-1 2l5 5h-5V4zM9 15h6v1H9v-1zm0-2h6v1H9v-1zm0-2h6v1H9v-1z"/>
</svg>
),
color: 'text-red-500 bg-red-100',
},
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': {
icon: (
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6zm-1 2l5 5h-5V4zM8 17h3v-1H8v1zm0-2h3v-1H8v1zm0-2h3v-1H8v1zm5 4h3v-1h-3v1zm0-2h3v-1h-3v1zm0-2h3v-1h-3v1z"/>
</svg>
),
color: 'text-green-600 bg-green-100',
},
'application/vnd.ms-excel': {
icon: (
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6zm-1 2l5 5h-5V4zM8 17h3v-1H8v1zm0-2h3v-1H8v1zm0-2h3v-1H8v1zm5 4h3v-1h-3v1zm0-2h3v-1h-3v1zm0-2h3v-1h-3v1z"/>
</svg>
),
color: 'text-green-600 bg-green-100',
},
'text/plain': {
icon: (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
),
color: 'text-gray-500 bg-gray-100',
},
'text/csv': {
icon: (
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6zm-1 2l5 5h-5V4zM7 13h2v2H7v-2zm0 3h2v2H7v-2zm3-3h2v2h-2v-2zm0 3h2v2h-2v-2zm3-3h2v2h-2v-2zm0 3h2v2h-2v-2z"/>
</svg>
),
color: 'text-green-500 bg-green-100',
},
'application/x-log': {
icon: (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
),
color: 'text-orange-500 bg-orange-100',
},
default: {
icon: (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
),
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 (
<>
<div
className={`max-w-[70%] rounded-lg overflow-hidden ${
isOwnMessage ? 'bg-blue-600' : 'bg-white shadow-sm'
}`}
>
{/* Sender name */}
{!isOwnMessage && (
<div className="px-3 pt-2 text-xs font-medium text-gray-500">
{message.sender_display_name || message.sender_id}
</div>
)}
{/* Image thumbnail */}
<div
className="relative cursor-pointer group"
onClick={() => setShowLightbox(true)}
>
{!imageLoaded && !imageError && (
<div className="w-48 h-48 flex items-center justify-center bg-gray-100">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
)}
{imageError ? (
<div className="w-48 h-32 flex items-center justify-center bg-gray-100 text-gray-400">
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
) : (
<img
src={displayUrl}
alt={filename}
className={`max-w-[300px] max-h-[300px] object-contain ${!imageLoaded ? 'hidden' : ''}`}
onLoad={() => setImageLoaded(true)}
onError={() => setImageError(true)}
/>
)}
{/* Hover overlay */}
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors flex items-center justify-center opacity-0 group-hover:opacity-100">
<svg className="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0zM10 7v3m0 0v3m0-3h3m-3 0H7" />
</svg>
</div>
</div>
{/* Caption and metadata */}
<div className="px-3 py-2">
{caption && (
<p className={`text-sm mb-1 ${isOwnMessage ? 'text-white' : 'text-gray-900'}`}>
{caption}
</p>
)}
<div className="flex items-center justify-between">
<span className={`text-xs ${isOwnMessage ? 'text-blue-200' : 'text-gray-400'}`}>
{formatMessageTime(message.created_at)}
</span>
<button
onClick={handleDownloadClick}
className={`p-1 rounded hover:bg-black/10 ${
isOwnMessage ? 'text-blue-200 hover:text-white' : 'text-gray-400 hover:text-gray-600'
}`}
title="Download"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
</button>
</div>
</div>
</div>
{/* Lightbox */}
{showLightbox && (
<ImageLightbox
src={file_url}
alt={filename}
filename={filename}
onClose={() => setShowLightbox(false)}
onDownload={() => onDownload(file_url, filename)}
/>
)}
</>
)
}
// Non-image file display
return (
<div
className={`max-w-[70%] rounded-lg px-4 py-3 ${
isOwnMessage ? 'bg-blue-600 text-white' : 'bg-white shadow-sm'
}`}
>
{/* Sender name */}
{!isOwnMessage && (
<div className="text-xs font-medium text-gray-500 mb-2">
{message.sender_display_name || message.sender_id}
</div>
)}
{/* File info */}
<div className="flex items-center gap-3">
{/* File icon */}
<div className={`w-12 h-12 rounded-lg flex items-center justify-center ${fileIcon.color}`}>
{fileIcon.icon}
</div>
{/* File details */}
<div className="flex-1 min-w-0">
<p className={`text-sm font-medium truncate ${isOwnMessage ? 'text-white' : 'text-gray-900'}`}>
{filename}
</p>
<p className={`text-xs ${isOwnMessage ? 'text-blue-200' : 'text-gray-500'}`}>
{file_size ? filesService.formatFileSize(file_size) : 'File'}
</p>
</div>
{/* Download button */}
<button
onClick={handleDownloadClick}
className={`p-2 rounded-full hover:bg-black/10 ${
isOwnMessage ? 'text-blue-200 hover:text-white' : 'text-gray-400 hover:text-blue-600'
}`}
title="Download"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
</button>
</div>
{/* Caption if any */}
{caption && (
<p className={`text-sm mt-2 ${isOwnMessage ? 'text-white' : 'text-gray-900'}`}>
{caption}
</p>
)}
{/* Timestamp */}
<div className={`text-xs mt-2 ${isOwnMessage ? 'text-blue-200' : 'text-gray-400'}`}>
{formatMessageTime(message.created_at)}
</div>
</div>
)
}

View File

@@ -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 (
<div
className="fixed inset-0 bg-black/90 flex items-center justify-center z-50"
onClick={handleBackdropClick}
role="dialog"
aria-modal="true"
aria-label={`Image preview: ${filename}`}
>
{/* Close button */}
<button
onClick={onClose}
className="absolute top-4 right-4 text-white/80 hover:text-white p-2 rounded-full hover:bg-white/10 transition-colors"
aria-label="Close preview (Escape)"
>
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
{/* Image container */}
<div className="relative max-w-[90vw] max-h-[85vh] flex flex-col items-center">
{/* Loading state */}
{isLoading && !hasError && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-4 border-white/30 border-t-white"></div>
</div>
)}
{/* Error state */}
{hasError && (
<div className="flex flex-col items-center justify-center text-white/70 py-16">
<svg className="w-16 h-16 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<p className="text-lg">Failed to load image</p>
<p className="text-sm text-white/50 mt-1">{filename}</p>
</div>
)}
{/* Image */}
<img
src={src}
alt={alt}
className={`max-w-full max-h-[85vh] object-contain ${isLoading || hasError ? 'invisible' : ''}`}
onLoad={() => setIsLoading(false)}
onError={() => {
setIsLoading(false)
setHasError(true)
}}
onClick={(e) => e.stopPropagation()}
/>
{/* Bottom toolbar */}
<div className="absolute -bottom-12 left-0 right-0 flex items-center justify-center gap-6">
{/* Filename */}
<span className="text-white/80 text-sm truncate max-w-[50vw]">
{filename}
</span>
{/* Download button */}
<button
onClick={handleDownloadClick}
className="flex items-center gap-2 text-white/80 hover:text-white text-sm px-3 py-1.5 rounded-full hover:bg-white/10 transition-colors"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
Download
</button>
</div>
</div>
{/* Keyboard hint */}
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 text-white/40 text-xs">
Press <kbd className="px-1.5 py-0.5 bg-white/10 rounded text-white/60">ESC</kbd> to close
</div>
</div>
)
}

View File

@@ -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<string, { icon: React.ReactNode; bgColor: string; textColor: string }> = {
'application/pdf': {
icon: (
<svg className="w-8 h-8" fill="currentColor" viewBox="0 0 24 24">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6zm-1 2l5 5h-5V4zM9 15h6v1H9v-1zm0-2h6v1H9v-1zm0-2h6v1H9v-1z"/>
</svg>
),
bgColor: 'bg-red-100',
textColor: 'text-red-500',
},
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': {
icon: (
<svg className="w-8 h-8" fill="currentColor" viewBox="0 0 24 24">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6zm-1 2l5 5h-5V4z"/>
</svg>
),
bgColor: 'bg-green-100',
textColor: 'text-green-600',
},
'application/vnd.ms-excel': {
icon: (
<svg className="w-8 h-8" fill="currentColor" viewBox="0 0 24 24">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6zm-1 2l5 5h-5V4z"/>
</svg>
),
bgColor: 'bg-green-100',
textColor: 'text-green-600',
},
'text/plain': {
icon: (
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
),
bgColor: 'bg-gray-100',
textColor: 'text-gray-500',
},
default: {
icon: (
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
),
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<string | null>(null)
const inputRef = useRef<HTMLInputElement>(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 (
<div className={`bg-gray-50 border-t border-gray-200 ${isMobile ? 'p-3' : 'p-4'}`}>
<div className="flex items-start gap-3">
{/* Preview */}
<div className="flex-shrink-0">
{isImage && previewUrl ? (
<div className="relative w-20 h-20 rounded-lg overflow-hidden bg-gray-200">
<img
src={previewUrl}
alt={file.name}
className="w-full h-full object-cover"
/>
</div>
) : (
<div className={`w-20 h-20 rounded-lg flex items-center justify-center ${fileIcon.bgColor}`}>
<div className={fileIcon.textColor}>{fileIcon.icon}</div>
</div>
)}
</div>
{/* File info and input */}
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-1">
<p className="text-sm font-medium text-gray-900 truncate pr-2">{file.name}</p>
{!isUploading && (
<button
onClick={onCancel}
className="flex-shrink-0 p-1 text-gray-400 hover:text-gray-600 rounded"
title="Cancel"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
<p className="text-xs text-gray-500 mb-2">{filesService.formatFileSize(file.size)}</p>
{/* Description input */}
{!isUploading && (
<form onSubmit={handleSubmit} className="flex gap-2">
<input
ref={inputRef}
type="text"
value={description}
onChange={(e) => 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}
/>
<button
type="submit"
className={`flex-shrink-0 bg-blue-600 text-white rounded-lg hover:bg-blue-700 flex items-center gap-1 ${
isMobile ? 'px-4 py-2.5' : 'px-3 py-1.5'
}`}
>
<svg className={`${isMobile ? 'w-5 h-5' : 'w-4 h-4'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
</svg>
{!isMobile && <span className="text-sm">Send</span>}
</button>
</form>
)}
{/* Upload progress */}
{isUploading && (
<div>
<div className="flex items-center justify-between text-sm text-gray-600 mb-1">
<span>Uploading...</span>
<span>{uploadProgress}%</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
style={{ width: `${uploadProgress}%` }}
/>
</div>
</div>
)}
</div>
</div>
{/* Keyboard hint (desktop only) */}
{!isMobile && !isUploading && (
<div className="mt-2 text-xs text-gray-400 text-right">
Press <kbd className="px-1 py-0.5 bg-gray-200 rounded text-gray-500">Enter</kbd> to send,{' '}
<kbd className="px-1 py-0.5 bg-gray-200 rounded text-gray-500">Esc</kbd> to cancel
</div>
)}
</div>
)
}

View File

@@ -134,10 +134,18 @@ export function useWebSocket(roomId: string | null, options?: UseWebSocketOption
(data: unknown) => { (data: unknown) => {
const msg = data as { type: string } const msg = data as { type: string }
// Debug: Log all incoming WebSocket messages
console.log('[WebSocket] Received message:', msg.type, data)
switch (msg.type) { switch (msg.type) {
case 'message': case 'message':
case 'edit_message': { case 'edit_message': {
const messageBroadcast = data as MessageBroadcast 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 = { const message: Message = {
message_id: messageBroadcast.message_id, message_id: messageBroadcast.message_id,
room_id: messageBroadcast.room_id, room_id: messageBroadcast.room_id,
@@ -160,7 +168,8 @@ export function useWebSocket(roomId: string | null, options?: UseWebSocketOption
break break
} }
case 'delete_message': { case 'delete_message':
case 'message_deleted': {
const deleteMsg = data as { message_id: string } const deleteMsg = data as { message_id: string }
removeMessage(deleteMsg.message_id) removeMessage(deleteMsg.message_id)
break break
@@ -192,6 +201,10 @@ export function useWebSocket(roomId: string | null, options?: UseWebSocketOption
case 'file_deleted': { case 'file_deleted': {
const fileData = data as FileDeletedBroadcast 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) options?.onFileDeleted?.(fileData)
break break
} }

View File

@@ -27,6 +27,8 @@ import { MobileHeader, SlidePanel } from '../components/mobile'
import { ActionBar } from '../components/chat/ActionBar' import { ActionBar } from '../components/chat/ActionBar'
import { MentionInput, highlightMentions } from '../components/chat/MentionInput' import { MentionInput, highlightMentions } from '../components/chat/MentionInput'
import { NotificationSettings } from '../components/chat/NotificationSettings' 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 ReportProgress from '../components/report/ReportProgress'
import { formatMessageTime } from '../utils/datetime' import { formatMessageTime } from '../utils/datetime'
import type { SeverityLevel, RoomStatus, MemberRole, FileMetadata, ReportStatus, Message } from '../types' import type { SeverityLevel, RoomStatus, MemberRole, FileMetadata, ReportStatus, Message } from '../types'
@@ -138,8 +140,11 @@ export default function RoomDetail() {
const [showEmojiPickerFor, setShowEmojiPickerFor] = useState<string | null>(null) const [showEmojiPickerFor, setShowEmojiPickerFor] = useState<string | null>(null)
const [copiedMessageId, setCopiedMessageId] = useState<string | null>(null) const [copiedMessageId, setCopiedMessageId] = useState<string | null>(null)
const [uploadProgress, setUploadProgress] = useState<number | null>(null) const [uploadProgress, setUploadProgress] = useState<number | null>(null)
const [uploadInfo, setUploadInfo] = useState<{ current: number; total: number } | null>(null)
const [isDragging, setIsDragging] = useState(false) const [isDragging, setIsDragging] = useState(false)
const [previewFile, setPreviewFile] = useState<FileMetadata | null>(null) const [previewFile, setPreviewFile] = useState<FileMetadata | null>(null)
const [pendingUploadFile, setPendingUploadFile] = useState<File | null>(null)
const [pendingUploadFiles, setPendingUploadFiles] = useState<File[]>([])
const fileInputRef = useRef<HTMLInputElement>(null) const fileInputRef = useRef<HTMLInputElement>(null)
const [newMemberUsername, setNewMemberUsername] = useState('') const [newMemberUsername, setNewMemberUsername] = useState('')
const [newMemberRole, setNewMemberRole] = useState<MemberRole>('viewer') const [newMemberRole, setNewMemberRole] = useState<MemberRole>('viewer')
@@ -310,25 +315,68 @@ export default function RoomDetail() {
}) })
} }
// File handlers // File handlers - Show preview before upload (single file) or upload immediately (multiple files)
const handleFileUpload = useCallback( const handleFileSelect = useCallback(
(files: FileList | null) => { async (files: FileList | null) => {
if (!files || files.length === 0) return 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) setUploadProgress(0)
uploadFile.mutate( uploadFile.mutate(
{ {
file, file: pendingUploadFile,
description,
onProgress: (progress) => setUploadProgress(progress), onProgress: (progress) => setUploadProgress(progress),
}, },
{ {
onSuccess: () => { onSuccess: () => {
setUploadProgress(null) setUploadProgress(null)
if (fileInputRef.current) { setPendingUploadFile(null)
fileInputRef.current.value = ''
}
}, },
onError: () => { onError: () => {
setUploadProgress(null) setUploadProgress(null)
@@ -336,16 +384,21 @@ export default function RoomDetail() {
} }
) )
}, },
[uploadFile] [pendingUploadFile, uploadFile]
) )
const handleCancelUpload = useCallback(() => {
setPendingUploadFile(null)
setUploadProgress(null)
}, [])
const handleDrop = useCallback( const handleDrop = useCallback(
(e: React.DragEvent) => { (e: React.DragEvent) => {
e.preventDefault() e.preventDefault()
setIsDragging(false) setIsDragging(false)
handleFileUpload(e.dataTransfer.files) handleFileSelect(e.dataTransfer.files)
}, },
[handleFileUpload] [handleFileSelect]
) )
const handleDragOver = useCallback((e: React.DragEvent) => { const handleDragOver = useCallback((e: React.DragEvent) => {
@@ -678,6 +731,32 @@ export default function RoomDetail() {
messages.map((message) => { messages.map((message) => {
const isOwnMessage = message.sender_id === user?.username const isOwnMessage = message.sender_id === user?.username
const isEditing = editingMessageId === message.message_id 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 (
<div
key={message.message_id}
className={`flex ${isOwnMessage ? 'justify-end' : 'justify-start'} group`}
>
<FileMessage
message={message}
isOwnMessage={isOwnMessage}
onDownload={(url, filename) => {
// 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)
}}
/>
</div>
)
}
return ( return (
<div <div
@@ -858,15 +937,27 @@ export default function RoomDetail() {
canWrite={permissions?.can_write || false} canWrite={permissions?.can_write || false}
canManageMembers={permissions?.can_manage_members || false} canManageMembers={permissions?.can_manage_members || false}
isGeneratingReport={generateReport.isPending} isGeneratingReport={generateReport.isPending}
uploadProgress={uploadProgress} uploadProgress={pendingUploadFile || pendingUploadFiles.length > 0 ? uploadProgress : null}
onFileSelect={handleFileUpload} uploadInfo={uploadInfo}
onFileSelect={handleFileSelect}
onGenerateReport={handleGenerateReport} onGenerateReport={handleGenerateReport}
onAddMemberClick={() => setShowAddMember(true)} onAddMemberClick={() => setShowAddMember(true)}
isMobile={isMobile} isMobile={isMobile}
/> />
{/* Upload Preview - Show when file selected but not yet uploaded */}
{pendingUploadFile && (
<UploadPreview
file={pendingUploadFile}
uploadProgress={uploadProgress}
onCancel={handleCancelUpload}
onSend={handleUploadWithDescription}
isMobile={isMobile}
/>
)}
{/* Message Input with @Mention Support */} {/* Message Input with @Mention Support */}
{permissions?.can_write && ( {permissions?.can_write && !pendingUploadFile && (
<form onSubmit={handleSendMessage} className={`p-4 bg-white border-t ${isMobile ? 'pb-2' : ''}`}> <form onSubmit={handleSendMessage} className={`p-4 bg-white border-t ${isMobile ? 'pb-2' : ''}`}>
<div className="flex gap-2"> <div className="flex gap-2">
<MentionInput <MentionInput
@@ -1091,7 +1182,7 @@ export default function RoomDetail() {
<input <input
type="file" type="file"
ref={fileInputRef} ref={fileInputRef}
onChange={(e) => handleFileUpload(e.target.files)} onChange={(e) => handleFileSelect(e.target.files)}
className="hidden" className="hidden"
/> />
{uploadProgress !== null ? ( {uploadProgress !== null ? (
@@ -1370,7 +1461,7 @@ export default function RoomDetail() {
<input <input
type="file" type="file"
ref={fileInputRef} ref={fileInputRef}
onChange={(e) => handleFileUpload(e.target.files)} onChange={(e) => handleFileSelect(e.target.files)}
className="hidden" className="hidden"
/> />
{uploadProgress !== null ? ( {uploadProgress !== null ? (

View File

@@ -46,6 +46,55 @@ export const filesService = {
return response.data 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 * List files in a room
*/ */

View File

@@ -127,6 +127,7 @@ export type FileType = 'image' | 'document' | 'log'
export interface FileMetadata { export interface FileMetadata {
file_id: string file_id: string
message_id?: string | null // Associated chat message ID
room_id: string room_id: string
filename: string filename: string
file_type: FileType file_type: FileType
@@ -138,15 +139,18 @@ export interface FileMetadata {
uploader_id: string uploader_id: string
deleted_at?: string | null deleted_at?: string | null
download_url?: string download_url?: string
thumbnail_url?: string | null // Thumbnail URL for images
} }
export interface FileUploadResponse { export interface FileUploadResponse {
file_id: string file_id: string
message_id?: string | null // Associated chat message ID
filename: string filename: string
file_type: FileType file_type: FileType
file_size: number file_size: number
mime_type: string mime_type: string
download_url: string download_url: string
thumbnail_url?: string | null // Thumbnail URL for images
uploaded_at: string uploaded_at: string
uploader_id: string uploader_id: string
} }
@@ -222,6 +226,7 @@ export interface TypingBroadcast {
export interface FileUploadedBroadcast { export interface FileUploadedBroadcast {
type: 'file_uploaded' type: 'file_uploaded'
file_id: string file_id: string
message_id?: string | null // Associated chat message ID
room_id: string room_id: string
uploader_id: string uploader_id: string
filename: string filename: string
@@ -229,12 +234,14 @@ export interface FileUploadedBroadcast {
file_size: number file_size: number
mime_type: string mime_type: string
download_url?: string download_url?: string
thumbnail_url?: string | null // Thumbnail URL for images
uploaded_at: string uploaded_at: string
} }
export interface FileDeletedBroadcast { export interface FileDeletedBroadcast {
type: 'file_deleted' type: 'file_deleted'
file_id: string file_id: string
message_id?: string | null // Associated chat message ID (also deleted)
room_id: string room_id: string
deleted_by: string deleted_by: string
deleted_at: string deleted_at: string

View File

@@ -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)

View File

@@ -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`

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -50,7 +50,7 @@ The system SHALL maintain a permanent `users` table to store user display names
### Requirement: Report Data Collection ### 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 #### 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) - Room metadata (title, incident_type, severity, status, location, description, timestamps)
- All 50 messages sorted by created_at ascending - All 50 messages sorted by created_at ascending
- All 5 members with their roles (owner, editor, viewer) - 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** 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 #### 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** summarize older messages by day (e.g., "2025-12-01: 45 則訊息討論設備檢修")
- **AND** the total formatted content SHALL stay within token limits - **AND** the total formatted content SHALL stay within token limits
---
### Requirement: DIFY AI Integration ### Requirement: DIFY AI Integration
The system SHALL integrate with DIFY Chat API to generate structured report content from collected room data. 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 ### 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 #### 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 4: 處理過程 (from AI resolution_process.content)
- Section 5: 目前狀態 (from AI current_status) - Section 5: 目前狀態 (from AI current_status)
- Section 6: 最終處置結果 (from AI final_resolution, if has_resolution=true) - 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** 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) - **AND** document SHALL use professional formatting (標楷體 or similar)
#### Scenario: Handle missing images during assembly #### 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 - **THEN** the system SHALL create a complete document without the embedded images section
- **AND** the attachments section SHALL show "本事件無附件檔案" if no files exist - **AND** the attachments section SHALL show "本事件無附件檔案" if no files exist
---
### Requirement: Report Generation API ### Requirement: Report Generation API
The system SHALL provide REST API endpoints for triggering report generation and downloading generated reports. The system SHALL provide REST API endpoints for triggering report generation and downloading generated reports.

View File

@@ -4,7 +4,7 @@
TBD - created by archiving change add-file-upload-minio. Update Purpose after archive. TBD - created by archiving change add-file-upload-minio. Update Purpose after archive.
## Requirements ## Requirements
### Requirement: File Upload with Validation ### 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 #### 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` - **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 - Validate JWT token and extract user_id
- Verify user is member of room-123 with OWNER or EDITOR role - 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 MIME type is image/jpeg, image/png, or image/gif
- Validate file size 10MB - Validate file size <= 10MB
- Generate unique file_id (UUID) - Generate unique file_id (UUID)
- Upload file to MinIO bucket `task-reporter-files` at path `room-123/images/{file_id}.jpg` - Upload file to MinIO bucket `task-reporter-files` at path `room-123/images/{file_id}.jpg`
- Create database record in `room_files` table - Create a message with `message_type=image_ref` containing file metadata
- Return file metadata with presigned download URL (1-hour expiry) - 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 #### Scenario: Reject oversized file upload
- **WHEN** a user attempts to upload a 15MB PDF file - **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" - Include error message: "File size exceeds limit: 15MB > 20MB"
- NOT upload file to MinIO - NOT upload file to MinIO
- NOT create database record - NOT create database record
- NOT create chat message
#### Scenario: Reject unauthorized file type #### Scenario: Reject unauthorized file type
- **WHEN** a user attempts to upload an executable file (e.g., .exe, .sh, .bat) - **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 - Validate MIME type is text/plain
- Upload to MinIO at `room-456/logs/{file_id}.log` - Upload to MinIO at `room-456/logs/{file_id}.log`
- Store metadata with file_type='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 ### Requirement: File Download with Access Control
The system SHALL generate time-limited presigned download URLs for files, enforcing room membership-based 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 - NOT upload to MinIO
- Log security event - 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

View File

@@ -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) - **AND** maintain user's access control (only rooms they're members of)
### Requirement: Message Types and Formatting ### 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 #### Scenario: Text message with mentions
- **WHEN** a user sends a 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 - **THEN** the system SHALL parse and store mentions
- **AND** potentially trigger notifications to mentioned users - **AND** potentially trigger notifications to mentioned users
#### Scenario: Image reference message #### Scenario: Image reference message display
- **WHEN** a user uploads an image and sends reference - **WHEN** a message with `message_type=image_ref` is rendered in the chat
```json - **THEN** the client SHALL display:
{ - A thumbnail of the image (max 300px width)
"type": "message", - The message content/caption below the image
"message_type": "image_ref", - Sender name and timestamp
"content": "Defect found on product", - A click-to-expand functionality
"file_id": "550e8400-e29b-41d4-a716-446655440000", - **AND** clicking the thumbnail SHALL open a full-size preview lightbox
"file_url": "http://localhost:9000/bucket/room-123/image.jpg"
} #### Scenario: File reference message display
``` - **WHEN** a message with `message_type=file_ref` is rendered in the chat
- **THEN** the system SHALL store the file reference - **THEN** the client SHALL display:
- **AND** clients SHALL display image preview inline - 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 #### Scenario: Structured incident data
- **WHEN** reporting specific incident metrics - **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 ### 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 #### Scenario: Message timestamp in GMT+8
- **WHEN** a message is displayed in the chat room - **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 - **WHEN** the room list is displayed
- **THEN** the "last updated" time SHALL be formatted in GMT+8 timezone - **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 ### Requirement: @Mention Support
The messaging system SHALL support @mention functionality to tag specific users in messages. 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 - **WHEN** fetching messages that mention a specific user
- **THEN** messages with that user_id in `mentions` array are returned - **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

View File

@@ -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.")