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:
@@ -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')
|
||||
30
app/main.py
30
app/main.py
@@ -3,12 +3,37 @@
|
||||
生產線異常即時反應系統 (Task Reporter)
|
||||
"""
|
||||
import os
|
||||
import json
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from app.core.config import get_settings
|
||||
|
||||
|
||||
class UTCDateTimeEncoder(json.JSONEncoder):
|
||||
"""Custom JSON encoder that formats datetime with 'Z' suffix for UTC"""
|
||||
def default(self, obj):
|
||||
if isinstance(obj, datetime):
|
||||
return obj.isoformat() + 'Z'
|
||||
return super().default(obj)
|
||||
|
||||
|
||||
class UTCJSONResponse(JSONResponse):
|
||||
"""JSONResponse that uses UTCDateTimeEncoder"""
|
||||
def render(self, content) -> bytes:
|
||||
return json.dumps(
|
||||
content,
|
||||
ensure_ascii=False,
|
||||
allow_nan=False,
|
||||
indent=None,
|
||||
separators=(",", ":"),
|
||||
cls=UTCDateTimeEncoder,
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
from app.modules.auth import router as auth_router
|
||||
from app.modules.auth.users_router import router as users_router
|
||||
from app.modules.auth.middleware import auth_middleware
|
||||
@@ -26,12 +51,13 @@ settings = get_settings()
|
||||
# Database tables are managed by Alembic migrations
|
||||
# Run: alembic upgrade head
|
||||
|
||||
# Initialize FastAPI app
|
||||
# Initialize FastAPI app with custom JSON response for UTC datetime
|
||||
app = FastAPI(
|
||||
title="Task Reporter API",
|
||||
description="Production Line Incident Response System - 生產線異常即時反應系統",
|
||||
version="1.0.0",
|
||||
debug=settings.DEBUG,
|
||||
default_response_class=UTCJSONResponse,
|
||||
)
|
||||
|
||||
# CORS middleware - origins configured via CORS_ORIGINS environment variable
|
||||
|
||||
@@ -79,6 +79,7 @@ class IncidentRoom(Base):
|
||||
# Relationships
|
||||
members = relationship("RoomMember", back_populates="room", cascade="all, delete-orphan")
|
||||
files = relationship("RoomFile", back_populates="room", cascade="all, delete-orphan")
|
||||
reports = relationship("GeneratedReport", back_populates="room", cascade="all, delete-orphan")
|
||||
|
||||
# Indexes for common queries
|
||||
__table_args__ = (
|
||||
|
||||
@@ -193,7 +193,7 @@ async def permanent_delete_room(
|
||||
"type": "system",
|
||||
"event": "room_deleted",
|
||||
"room_id": room_id,
|
||||
"timestamp": datetime.utcnow().isoformat()
|
||||
"timestamp": datetime.utcnow().isoformat() + "Z"
|
||||
})
|
||||
|
||||
success, error = room_service.permanent_delete_room(db, room_id)
|
||||
@@ -246,7 +246,7 @@ async def join_room(
|
||||
detail={
|
||||
"message": "Already a member of this room",
|
||||
"current_role": existing.role.value,
|
||||
"added_at": existing.added_at.isoformat()
|
||||
"added_at": existing.added_at.isoformat() + "Z"
|
||||
}
|
||||
)
|
||||
|
||||
@@ -505,12 +505,12 @@ async def add_lot(
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Room not found")
|
||||
|
||||
# Get current lots or initialize empty list
|
||||
current_lots = room.lots or []
|
||||
current_lots = list(room.lots or []) # Create a new list to ensure change detection
|
||||
|
||||
# Prevent duplicates
|
||||
if request.lot not in current_lots:
|
||||
current_lots.append(request.lot)
|
||||
room.lots = current_lots
|
||||
room.lots = current_lots # Assign new list triggers SQLAlchemy change detection
|
||||
room.last_updated_at = datetime.utcnow()
|
||||
db.commit()
|
||||
db.refresh(room)
|
||||
@@ -532,11 +532,11 @@ async def remove_lot(
|
||||
if not room:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Room not found")
|
||||
|
||||
current_lots = room.lots or []
|
||||
current_lots = list(room.lots or []) # Create a new list to ensure change detection
|
||||
|
||||
if lot in current_lots:
|
||||
current_lots.remove(lot)
|
||||
room.lots = current_lots
|
||||
room.lots = current_lots # Assign new list triggers SQLAlchemy change detection
|
||||
room.last_updated_at = datetime.utcnow()
|
||||
db.commit()
|
||||
db.refresh(room)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Request and response models for API endpoints
|
||||
"""
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, ConfigDict, field_serializer
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
@@ -98,8 +98,14 @@ class MemberResponse(BaseModel):
|
||||
added_at: datetime
|
||||
removed_at: Optional[datetime] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@field_serializer("added_at", "removed_at")
|
||||
def serialize_datetime(self, dt: Optional[datetime]) -> Optional[str]:
|
||||
"""Serialize datetime with 'Z' suffix to indicate UTC"""
|
||||
if dt is None:
|
||||
return None
|
||||
return dt.isoformat() + "Z"
|
||||
|
||||
|
||||
class RoomResponse(BaseModel):
|
||||
@@ -127,8 +133,17 @@ class RoomResponse(BaseModel):
|
||||
is_member: bool = False
|
||||
is_admin_view: bool = False
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@field_serializer(
|
||||
"created_at", "resolved_at", "archived_at",
|
||||
"last_activity_at", "last_updated_at", "ownership_transferred_at"
|
||||
)
|
||||
def serialize_datetime(self, dt: Optional[datetime]) -> Optional[str]:
|
||||
"""Serialize datetime with 'Z' suffix to indicate UTC"""
|
||||
if dt is None:
|
||||
return None
|
||||
return dt.isoformat() + "Z"
|
||||
|
||||
|
||||
class RoomListResponse(BaseModel):
|
||||
|
||||
@@ -467,7 +467,11 @@ class RoomService:
|
||||
f"Failed to delete report file: {report.docx_storage_path}"
|
||||
)
|
||||
|
||||
# Step 3: Delete room from database (CASCADE handles related tables)
|
||||
# Step 3: Delete reports from database (before room delete due to relationship handling)
|
||||
for report in reports:
|
||||
db.delete(report)
|
||||
|
||||
# Step 4: Delete room from database (CASCADE handles other related tables)
|
||||
db.delete(room)
|
||||
db.commit()
|
||||
|
||||
|
||||
@@ -16,6 +16,9 @@ class RoomFile(Base):
|
||||
# Foreign key to incident room (CASCADE delete when room is permanently deleted)
|
||||
room_id = Column(String(36), ForeignKey("tr_incident_rooms.room_id", ondelete="CASCADE"), nullable=False)
|
||||
|
||||
# Foreign key to associated message (nullable for legacy files)
|
||||
message_id = Column(String(36), ForeignKey("tr_messages.message_id", ondelete="SET NULL"), nullable=True)
|
||||
|
||||
# File metadata
|
||||
uploader_id = Column(String(255), nullable=False)
|
||||
filename = Column(String(255), nullable=False)
|
||||
@@ -33,11 +36,13 @@ class RoomFile(Base):
|
||||
|
||||
# Relationships
|
||||
room = relationship("IncidentRoom", back_populates="files")
|
||||
message = relationship("Message", backref="file_attachment", uselist=False)
|
||||
|
||||
# Indexes
|
||||
__table_args__ = (
|
||||
Index("ix_tr_room_files_room_uploaded", "room_id", "uploaded_at"),
|
||||
Index("ix_tr_room_files_uploader", "uploader_id"),
|
||||
Index("ix_tr_room_files_message", "message_id"),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
|
||||
@@ -19,7 +19,8 @@ from app.modules.file_storage.schemas import FileUploadResponse, FileMetadata, F
|
||||
from app.modules.file_storage.services.file_service import FileService
|
||||
from app.modules.file_storage.services import minio_service
|
||||
from app.modules.realtime.websocket_manager import manager as websocket_manager
|
||||
from app.modules.realtime.schemas import FileUploadedBroadcast, FileDeletedBroadcast, FileUploadAck
|
||||
from app.modules.realtime.schemas import FileUploadedBroadcast, FileDeletedBroadcast, FileUploadAck, MessageDeletedBroadcast, MessageBroadcast, MessageTypeEnum
|
||||
from app.modules.realtime.services.message_service import MessageService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -58,11 +59,52 @@ async def upload_file(
|
||||
# Upload file
|
||||
result = FileService.upload_file(db, room_id, user_email, file, description)
|
||||
|
||||
# Fetch the message and display name for broadcasting (before background task)
|
||||
message_obj = MessageService.get_message(db, result.message_id) if result.message_id else None
|
||||
display_name = MessageService.get_display_name(db, user_email)
|
||||
|
||||
# Prepare message broadcast data (needed before db session closes)
|
||||
message_data = None
|
||||
if message_obj:
|
||||
message_data = {
|
||||
"message_id": message_obj.message_id,
|
||||
"room_id": message_obj.room_id,
|
||||
"sender_id": message_obj.sender_id,
|
||||
"sender_display_name": display_name,
|
||||
"content": message_obj.content,
|
||||
"message_type": message_obj.message_type.value,
|
||||
"metadata": message_obj.message_metadata,
|
||||
"created_at": message_obj.created_at,
|
||||
"sequence_number": message_obj.sequence_number,
|
||||
}
|
||||
|
||||
# Broadcast file upload event to room members via WebSocket
|
||||
async def broadcast_file_upload():
|
||||
try:
|
||||
# First, broadcast the message event so it appears in chat
|
||||
if message_data:
|
||||
logger.info(f"Broadcasting message for file upload. message_data: {message_data}")
|
||||
msg_broadcast = MessageBroadcast(
|
||||
type="message",
|
||||
message_id=message_data["message_id"],
|
||||
room_id=message_data["room_id"],
|
||||
sender_id=message_data["sender_id"],
|
||||
sender_display_name=message_data["sender_display_name"],
|
||||
content=message_data["content"],
|
||||
message_type=MessageTypeEnum(message_data["message_type"]),
|
||||
metadata=message_data["metadata"],
|
||||
created_at=message_data["created_at"],
|
||||
sequence_number=message_data["sequence_number"],
|
||||
)
|
||||
broadcast_dict = msg_broadcast.model_dump(mode='json')
|
||||
logger.info(f"Message broadcast dict: {broadcast_dict}")
|
||||
await websocket_manager.broadcast_to_room(room_id, broadcast_dict)
|
||||
logger.info(f"Broadcasted file message: {message_data['message_id']} to room {room_id}")
|
||||
|
||||
# Then broadcast file uploaded event (for file drawer updates)
|
||||
broadcast = FileUploadedBroadcast(
|
||||
file_id=result.file_id,
|
||||
message_id=result.message_id,
|
||||
room_id=room_id,
|
||||
uploader_id=user_email,
|
||||
filename=result.filename,
|
||||
@@ -70,10 +112,11 @@ async def upload_file(
|
||||
file_size=result.file_size,
|
||||
mime_type=result.mime_type,
|
||||
download_url=result.download_url,
|
||||
thumbnail_url=result.thumbnail_url,
|
||||
uploaded_at=result.uploaded_at
|
||||
)
|
||||
await websocket_manager.broadcast_to_room(room_id, broadcast.to_dict())
|
||||
logger.info(f"Broadcasted file upload event: {result.file_id} to room {room_id}")
|
||||
logger.info(f"Broadcasted file upload event: {result.file_id} (message: {result.message_id}) to room {room_id}")
|
||||
|
||||
# Send acknowledgment to uploader
|
||||
ack = FileUploadAck(
|
||||
@@ -86,7 +129,7 @@ async def upload_file(
|
||||
logger.error(f"Failed to broadcast file upload: {e}")
|
||||
|
||||
# Run broadcast in background
|
||||
background_tasks.add_task(asyncio.create_task, broadcast_file_upload())
|
||||
background_tasks.add_task(broadcast_file_upload)
|
||||
|
||||
return result
|
||||
|
||||
@@ -149,9 +192,13 @@ async def get_file(
|
||||
expiry_seconds=3600
|
||||
)
|
||||
|
||||
# For images, the download URL also serves as thumbnail (CSS resized on frontend)
|
||||
thumbnail_url = download_url if file_record.file_type == "image" else None
|
||||
|
||||
# Build response with download URL
|
||||
return FileMetadata(
|
||||
file_id=file_record.file_id,
|
||||
message_id=file_record.message_id,
|
||||
room_id=file_record.room_id,
|
||||
filename=file_record.filename,
|
||||
file_type=file_record.file_type,
|
||||
@@ -162,7 +209,8 @@ async def get_file(
|
||||
uploaded_at=file_record.uploaded_at,
|
||||
uploader_id=file_record.uploader_id,
|
||||
deleted_at=file_record.deleted_at,
|
||||
download_url=download_url
|
||||
download_url=download_url,
|
||||
thumbnail_url=thumbnail_url
|
||||
)
|
||||
|
||||
|
||||
@@ -204,25 +252,38 @@ async def delete_file(
|
||||
# Check if admin
|
||||
is_admin = membership_service.is_system_admin(user_email)
|
||||
|
||||
# Delete file (service will verify permissions)
|
||||
deleted_file = FileService.delete_file(db, file_id, user_email, is_room_owner or is_admin)
|
||||
# Delete file (service will verify permissions and cascade to message)
|
||||
deleted_file, deleted_message_id = FileService.delete_file(db, file_id, user_email, is_room_owner or is_admin)
|
||||
|
||||
# Broadcast file deletion event to room members via WebSocket
|
||||
# Broadcast file and message deletion events to room members via WebSocket
|
||||
if deleted_file:
|
||||
async def broadcast_file_delete():
|
||||
try:
|
||||
broadcast = FileDeletedBroadcast(
|
||||
# Broadcast file deleted event
|
||||
file_broadcast = FileDeletedBroadcast(
|
||||
file_id=file_id,
|
||||
message_id=deleted_message_id,
|
||||
room_id=room_id,
|
||||
deleted_by=user_email,
|
||||
deleted_at=deleted_file.deleted_at
|
||||
)
|
||||
await websocket_manager.broadcast_to_room(room_id, broadcast.to_dict())
|
||||
await websocket_manager.broadcast_to_room(room_id, file_broadcast.to_dict())
|
||||
logger.info(f"Broadcasted file deletion event: {file_id} from room {room_id}")
|
||||
|
||||
# Also broadcast message deleted event if there was an associated message
|
||||
if deleted_message_id:
|
||||
msg_broadcast = MessageDeletedBroadcast(
|
||||
message_id=deleted_message_id,
|
||||
room_id=room_id,
|
||||
deleted_by=user_email,
|
||||
deleted_at=deleted_file.deleted_at
|
||||
)
|
||||
await websocket_manager.broadcast_to_room(room_id, msg_broadcast.to_dict())
|
||||
logger.info(f"Broadcasted message deletion event: {deleted_message_id} from room {room_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to broadcast file deletion: {e}")
|
||||
logger.error(f"Failed to broadcast file/message deletion: {e}")
|
||||
|
||||
# Run broadcast in background
|
||||
background_tasks.add_task(asyncio.create_task, broadcast_file_delete())
|
||||
background_tasks.add_task(broadcast_file_delete)
|
||||
|
||||
return None
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""Pydantic schemas for file storage operations"""
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from pydantic import BaseModel, Field, field_validator, field_serializer, ConfigDict
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
@@ -15,21 +15,28 @@ class FileType(str, Enum):
|
||||
class FileUploadResponse(BaseModel):
|
||||
"""Response after successful file upload"""
|
||||
file_id: str
|
||||
message_id: Optional[str] = None # Associated chat message ID
|
||||
filename: str
|
||||
file_type: FileType
|
||||
file_size: int
|
||||
mime_type: str
|
||||
download_url: str # Presigned URL
|
||||
thumbnail_url: Optional[str] = None # Thumbnail URL for images
|
||||
uploaded_at: datetime
|
||||
uploader_id: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@field_serializer("uploaded_at")
|
||||
def serialize_datetime(self, dt: datetime) -> str:
|
||||
"""Serialize datetime with 'Z' suffix to indicate UTC"""
|
||||
return dt.isoformat() + "Z"
|
||||
|
||||
|
||||
class FileMetadata(BaseModel):
|
||||
"""File metadata response"""
|
||||
file_id: str
|
||||
message_id: Optional[str] = None # Associated chat message ID
|
||||
room_id: str
|
||||
filename: str
|
||||
file_type: FileType
|
||||
@@ -41,9 +48,9 @@ class FileMetadata(BaseModel):
|
||||
uploader_id: str
|
||||
deleted_at: Optional[datetime] = None
|
||||
download_url: Optional[str] = None # Presigned URL (only when requested)
|
||||
thumbnail_url: Optional[str] = None # Thumbnail URL for images
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@field_validator("file_size")
|
||||
@classmethod
|
||||
@@ -53,6 +60,13 @@ class FileMetadata(BaseModel):
|
||||
raise ValueError("File size must be positive")
|
||||
return v
|
||||
|
||||
@field_serializer("uploaded_at", "deleted_at")
|
||||
def serialize_datetime(self, dt: Optional[datetime]) -> Optional[str]:
|
||||
"""Serialize datetime with 'Z' suffix to indicate UTC"""
|
||||
if dt is None:
|
||||
return None
|
||||
return dt.isoformat() + "Z"
|
||||
|
||||
|
||||
class FileListResponse(BaseModel):
|
||||
"""Paginated file list response"""
|
||||
@@ -62,13 +76,11 @@ class FileListResponse(BaseModel):
|
||||
offset: int
|
||||
has_more: bool
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class FileUploadParams(BaseModel):
|
||||
"""Parameters for file upload (optional description)"""
|
||||
description: Optional[str] = Field(None, max_length=500)
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@@ -69,11 +69,38 @@ class FileService:
|
||||
detail="File storage service temporarily unavailable"
|
||||
)
|
||||
|
||||
# Create database record
|
||||
# Create database record and associated message
|
||||
try:
|
||||
# Generate presigned download URL
|
||||
download_url = minio_service.generate_presigned_url(
|
||||
bucket=settings.MINIO_BUCKET,
|
||||
object_path=object_path,
|
||||
expiry_seconds=3600
|
||||
)
|
||||
|
||||
# For images, the download URL also serves as thumbnail (CSS resized on frontend)
|
||||
thumbnail_url = download_url if file_type == "image" else None
|
||||
|
||||
# Create the associated chat message first
|
||||
message = FileService.create_file_reference_message(
|
||||
db=db,
|
||||
room_id=room_id,
|
||||
sender_id=uploader_id,
|
||||
file_id=file_id,
|
||||
filename=file.filename,
|
||||
file_type=file_type,
|
||||
mime_type=mime_type,
|
||||
file_size=file_size,
|
||||
file_url=download_url,
|
||||
thumbnail_url=thumbnail_url,
|
||||
description=description
|
||||
)
|
||||
|
||||
# Create file record with message_id reference
|
||||
room_file = RoomFile(
|
||||
file_id=file_id,
|
||||
room_id=room_id,
|
||||
message_id=message.message_id,
|
||||
uploader_id=uploader_id,
|
||||
filename=file.filename,
|
||||
file_type=file_type,
|
||||
@@ -88,20 +115,15 @@ class FileService:
|
||||
db.commit()
|
||||
db.refresh(room_file)
|
||||
|
||||
# Generate presigned download URL
|
||||
download_url = minio_service.generate_presigned_url(
|
||||
bucket=settings.MINIO_BUCKET,
|
||||
object_path=object_path,
|
||||
expiry_seconds=3600
|
||||
)
|
||||
|
||||
return FileUploadResponse(
|
||||
file_id=file_id,
|
||||
message_id=message.message_id,
|
||||
filename=file.filename,
|
||||
file_type=file_type,
|
||||
file_size=file_size,
|
||||
mime_type=mime_type,
|
||||
download_url=download_url,
|
||||
thumbnail_url=thumbnail_url,
|
||||
uploaded_at=room_file.uploaded_at,
|
||||
uploader_id=uploader_id
|
||||
)
|
||||
@@ -160,12 +182,17 @@ class FileService:
|
||||
file_id: str,
|
||||
user_id: str,
|
||||
is_room_owner: bool = False
|
||||
) -> Optional[RoomFile]:
|
||||
"""Soft delete file"""
|
||||
) -> tuple[Optional[RoomFile], Optional[str]]:
|
||||
"""
|
||||
Soft delete file and its associated message.
|
||||
|
||||
Returns:
|
||||
Tuple of (deleted_file, deleted_message_id) or (None, None) if not found
|
||||
"""
|
||||
file = db.query(RoomFile).filter(RoomFile.file_id == file_id).first()
|
||||
|
||||
if not file:
|
||||
return None
|
||||
return None, None
|
||||
|
||||
# Check permissions
|
||||
if not is_room_owner and file.uploader_id != user_id:
|
||||
@@ -174,12 +201,21 @@ class FileService:
|
||||
detail="Only file uploader or room owner can delete files"
|
||||
)
|
||||
|
||||
# Soft delete
|
||||
deleted_message_id = None
|
||||
|
||||
# Soft delete the associated message if it exists
|
||||
if file.message_id:
|
||||
message = db.query(Message).filter(Message.message_id == file.message_id).first()
|
||||
if message and message.deleted_at is None:
|
||||
message.deleted_at = datetime.utcnow()
|
||||
deleted_message_id = message.message_id
|
||||
|
||||
# Soft delete the file
|
||||
file.deleted_at = datetime.utcnow()
|
||||
db.commit()
|
||||
db.refresh(file)
|
||||
|
||||
return file
|
||||
return file, deleted_message_id
|
||||
|
||||
@staticmethod
|
||||
def check_room_membership(db: Session, room_id: str, user_id: str) -> Optional[RoomMember]:
|
||||
@@ -205,7 +241,10 @@ class FileService:
|
||||
file_id: str,
|
||||
filename: str,
|
||||
file_type: str,
|
||||
mime_type: str,
|
||||
file_size: int,
|
||||
file_url: str,
|
||||
thumbnail_url: Optional[str] = None,
|
||||
description: Optional[str] = None
|
||||
) -> Message:
|
||||
"""
|
||||
@@ -218,7 +257,10 @@ class FileService:
|
||||
file_id: File ID in room_files table
|
||||
filename: Original filename
|
||||
file_type: Type of file (image, document, log)
|
||||
mime_type: MIME type of the file
|
||||
file_size: File size in bytes
|
||||
file_url: Presigned download URL
|
||||
thumbnail_url: Presigned thumbnail URL for images
|
||||
description: Optional description for the file
|
||||
|
||||
Returns:
|
||||
@@ -237,9 +279,15 @@ class FileService:
|
||||
"file_id": file_id,
|
||||
"file_url": file_url,
|
||||
"filename": filename,
|
||||
"file_type": file_type
|
||||
"file_type": file_type,
|
||||
"mime_type": mime_type,
|
||||
"file_size": file_size
|
||||
}
|
||||
|
||||
# Add thumbnail URL for images
|
||||
if thumbnail_url:
|
||||
metadata["thumbnail_url"] = thumbnail_url
|
||||
|
||||
# Use MessageService to create the message
|
||||
return MessageService.create_message(
|
||||
db=db,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""File validation utilities"""
|
||||
import magic
|
||||
import os
|
||||
from fastapi import UploadFile, HTTPException
|
||||
from typing import Set
|
||||
from typing import Set, Dict
|
||||
import logging
|
||||
|
||||
from app.core.config import get_settings
|
||||
@@ -17,7 +18,15 @@ IMAGE_TYPES: Set[str] = {
|
||||
}
|
||||
|
||||
DOCUMENT_TYPES: Set[str] = {
|
||||
"application/pdf"
|
||||
"application/pdf",
|
||||
"application/x-pdf", # Some systems detect PDF as x-pdf
|
||||
}
|
||||
|
||||
# Extensions that can be accepted even if MIME detection fails
|
||||
EXTENSION_FALLBACK: Dict[str, str] = {
|
||||
".pdf": "application/pdf",
|
||||
".doc": "application/msword",
|
||||
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
}
|
||||
|
||||
LOG_TYPES: Set[str] = {
|
||||
@@ -67,6 +76,17 @@ def validate_file_type(file: UploadFile, allowed_types: Set[str]) -> str:
|
||||
detected_mime = detect_mime_type(header)
|
||||
|
||||
if detected_mime not in allowed_types:
|
||||
# Try extension fallback for known safe file types
|
||||
filename = file.filename or ""
|
||||
_, ext = os.path.splitext(filename.lower())
|
||||
|
||||
if ext in EXTENSION_FALLBACK:
|
||||
logger.info(
|
||||
f"MIME detection returned {detected_mime} for {filename}, "
|
||||
f"using extension fallback: {EXTENSION_FALLBACK[ext]}"
|
||||
)
|
||||
return EXTENSION_FALLBACK[ext]
|
||||
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"File type not allowed: {detected_mime}. Allowed types: {', '.join(allowed_types)}"
|
||||
@@ -115,9 +135,12 @@ def get_file_type_and_limits(mime_type: str) -> tuple[str, int]:
|
||||
Raises:
|
||||
HTTPException if MIME type not recognized
|
||||
"""
|
||||
# Include extension fallback types as documents
|
||||
document_types_extended = DOCUMENT_TYPES | set(EXTENSION_FALLBACK.values())
|
||||
|
||||
if mime_type in IMAGE_TYPES:
|
||||
return ("image", settings.get_image_max_size_bytes())
|
||||
elif mime_type in DOCUMENT_TYPES:
|
||||
elif mime_type in document_types_extended:
|
||||
return ("document", settings.get_document_max_size_bytes())
|
||||
elif mime_type in LOG_TYPES:
|
||||
return ("log", settings.get_log_max_size_bytes())
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
"""Pydantic schemas for WebSocket messages and REST API"""
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, ConfigDict, field_serializer
|
||||
from typing import Optional, Dict, Any, List
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
|
||||
|
||||
def serialize_datetime_utc(dt: datetime) -> str:
|
||||
"""Serialize datetime with 'Z' suffix to indicate UTC"""
|
||||
if dt is None:
|
||||
return None
|
||||
return dt.isoformat() + "Z"
|
||||
|
||||
|
||||
class MessageTypeEnum(str, Enum):
|
||||
"""Message type enumeration for validation"""
|
||||
TEXT = "text"
|
||||
@@ -89,6 +96,13 @@ class MessageBroadcast(BaseModel):
|
||||
deleted_at: Optional[datetime] = None
|
||||
sequence_number: int
|
||||
|
||||
@field_serializer("created_at", "edited_at", "deleted_at")
|
||||
def serialize_datetime(self, dt: Optional[datetime]) -> Optional[str]:
|
||||
"""Serialize datetime with 'Z' suffix to indicate UTC"""
|
||||
if dt is None:
|
||||
return None
|
||||
return dt.isoformat() + "Z"
|
||||
|
||||
|
||||
class SystemMessageBroadcast(BaseModel):
|
||||
"""System message broadcast"""
|
||||
@@ -99,6 +113,11 @@ class SystemMessageBroadcast(BaseModel):
|
||||
timestamp: datetime
|
||||
data: Optional[Dict[str, Any]] = None
|
||||
|
||||
@field_serializer("timestamp")
|
||||
def serialize_datetime(self, dt: datetime) -> str:
|
||||
"""Serialize datetime with 'Z' suffix to indicate UTC"""
|
||||
return dt.isoformat() + "Z"
|
||||
|
||||
|
||||
class TypingBroadcast(BaseModel):
|
||||
"""Typing indicator broadcast"""
|
||||
@@ -115,6 +134,11 @@ class MessageAck(BaseModel):
|
||||
sequence_number: int
|
||||
timestamp: datetime
|
||||
|
||||
@field_serializer("timestamp")
|
||||
def serialize_datetime(self, dt: datetime) -> str:
|
||||
"""Serialize datetime with 'Z' suffix to indicate UTC"""
|
||||
return dt.isoformat() + "Z"
|
||||
|
||||
|
||||
class ErrorMessage(BaseModel):
|
||||
"""Error message"""
|
||||
@@ -145,16 +169,25 @@ class MessageResponse(BaseModel):
|
||||
sender_display_name: Optional[str] = None # Display name from users table
|
||||
content: str
|
||||
message_type: MessageTypeEnum
|
||||
metadata: Optional[Dict[str, Any]] = Field(None, alias="message_metadata")
|
||||
# Use validation_alias to read from ORM's message_metadata, but serialize as "metadata"
|
||||
metadata: Optional[Dict[str, Any]] = Field(None, validation_alias="message_metadata")
|
||||
created_at: datetime
|
||||
edited_at: Optional[datetime] = None
|
||||
deleted_at: Optional[datetime] = None
|
||||
sequence_number: int
|
||||
reaction_counts: Optional[Dict[str, int]] = None # emoji -> count
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
populate_by_name = True # Allow both 'metadata' and 'message_metadata'
|
||||
model_config = ConfigDict(
|
||||
from_attributes=True,
|
||||
populate_by_name=True,
|
||||
)
|
||||
|
||||
@field_serializer("created_at", "edited_at", "deleted_at")
|
||||
def serialize_datetime(self, dt: Optional[datetime]) -> Optional[str]:
|
||||
"""Serialize datetime with 'Z' suffix to indicate UTC"""
|
||||
if dt is None:
|
||||
return None
|
||||
return dt.isoformat() + "Z"
|
||||
|
||||
|
||||
class MessageListResponse(BaseModel):
|
||||
@@ -179,8 +212,12 @@ class ReactionResponse(BaseModel):
|
||||
emoji: str
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@field_serializer("created_at")
|
||||
def serialize_datetime(self, dt: datetime) -> str:
|
||||
"""Serialize datetime with 'Z' suffix to indicate UTC"""
|
||||
return dt.isoformat() + "Z"
|
||||
|
||||
|
||||
class ReactionSummary(BaseModel):
|
||||
@@ -195,12 +232,18 @@ class OnlineUser(BaseModel):
|
||||
user_id: str
|
||||
connected_at: datetime
|
||||
|
||||
@field_serializer("connected_at")
|
||||
def serialize_datetime(self, dt: datetime) -> str:
|
||||
"""Serialize datetime with 'Z' suffix to indicate UTC"""
|
||||
return dt.isoformat() + "Z"
|
||||
|
||||
|
||||
# File Upload WebSocket Schemas
|
||||
class FileUploadedBroadcast(BaseModel):
|
||||
"""Broadcast when a file is uploaded to a room"""
|
||||
type: str = "file_uploaded"
|
||||
file_id: str
|
||||
message_id: Optional[str] = None # Associated chat message ID
|
||||
room_id: str
|
||||
uploader_id: str
|
||||
filename: str
|
||||
@@ -208,6 +251,7 @@ class FileUploadedBroadcast(BaseModel):
|
||||
file_size: int
|
||||
mime_type: str
|
||||
download_url: Optional[str] = None
|
||||
thumbnail_url: Optional[str] = None # Thumbnail URL for images
|
||||
uploaded_at: datetime
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
@@ -215,6 +259,7 @@ class FileUploadedBroadcast(BaseModel):
|
||||
return {
|
||||
"type": self.type,
|
||||
"file_id": self.file_id,
|
||||
"message_id": self.message_id,
|
||||
"room_id": self.room_id,
|
||||
"uploader_id": self.uploader_id,
|
||||
"filename": self.filename,
|
||||
@@ -222,7 +267,8 @@ class FileUploadedBroadcast(BaseModel):
|
||||
"file_size": self.file_size,
|
||||
"mime_type": self.mime_type,
|
||||
"download_url": self.download_url,
|
||||
"uploaded_at": self.uploaded_at.isoformat()
|
||||
"thumbnail_url": self.thumbnail_url,
|
||||
"uploaded_at": self.uploaded_at.isoformat() + "Z"
|
||||
}
|
||||
|
||||
|
||||
@@ -249,6 +295,7 @@ class FileDeletedBroadcast(BaseModel):
|
||||
"""Broadcast when a file is deleted from a room"""
|
||||
type: str = "file_deleted"
|
||||
file_id: str
|
||||
message_id: Optional[str] = None # Associated chat message ID (also deleted)
|
||||
room_id: str
|
||||
deleted_by: str
|
||||
deleted_at: datetime
|
||||
@@ -258,7 +305,27 @@ class FileDeletedBroadcast(BaseModel):
|
||||
return {
|
||||
"type": self.type,
|
||||
"file_id": self.file_id,
|
||||
"message_id": self.message_id,
|
||||
"room_id": self.room_id,
|
||||
"deleted_by": self.deleted_by,
|
||||
"deleted_at": self.deleted_at.isoformat()
|
||||
"deleted_at": self.deleted_at.isoformat() + "Z"
|
||||
}
|
||||
|
||||
|
||||
class MessageDeletedBroadcast(BaseModel):
|
||||
"""Broadcast when a message is deleted"""
|
||||
type: str = "message_deleted"
|
||||
message_id: str
|
||||
room_id: str
|
||||
deleted_by: str
|
||||
deleted_at: datetime
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert to dictionary for WebSocket broadcast"""
|
||||
return {
|
||||
"type": self.type,
|
||||
"message_id": self.message_id,
|
||||
"room_id": self.room_id,
|
||||
"deleted_by": self.deleted_by,
|
||||
"deleted_at": self.deleted_at.isoformat() + "Z"
|
||||
}
|
||||
|
||||
@@ -14,7 +14,8 @@ settings = get_settings()
|
||||
def json_serializer(obj: Any) -> str:
|
||||
"""Custom JSON serializer for objects not serializable by default json code"""
|
||||
if isinstance(obj, datetime):
|
||||
return obj.isoformat()
|
||||
# Append 'Z' to indicate UTC so JavaScript parses it correctly
|
||||
return obj.isoformat() + 'Z'
|
||||
raise TypeError(f"Object of type {type(obj).__name__} is not JSON serializable")
|
||||
|
||||
|
||||
|
||||
@@ -90,7 +90,7 @@ class GeneratedReport(Base):
|
||||
)
|
||||
|
||||
# Relationship
|
||||
room = relationship("IncidentRoom", backref="reports")
|
||||
room = relationship("IncidentRoom", back_populates="reports")
|
||||
|
||||
# Indexes
|
||||
__table_args__ = (
|
||||
|
||||
@@ -4,7 +4,18 @@ Contains the prompt construction logic for building the user query
|
||||
sent to DIFY Chat API.
|
||||
"""
|
||||
from typing import List, Dict, Any
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
# Taiwan timezone (GMT+8)
|
||||
TZ_GMT8 = timezone(timedelta(hours=8))
|
||||
|
||||
|
||||
def _to_gmt8(dt: datetime) -> datetime:
|
||||
"""Convert datetime to GMT+8 timezone"""
|
||||
if dt.tzinfo is None:
|
||||
# Assume UTC if no timezone
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
return dt.astimezone(TZ_GMT8)
|
||||
|
||||
|
||||
INCIDENT_TYPE_MAP = {
|
||||
@@ -81,11 +92,11 @@ def _format_room_info(room_data: Dict[str, Any]) -> str:
|
||||
|
||||
created_at = room_data.get("created_at")
|
||||
if isinstance(created_at, datetime):
|
||||
created_at = created_at.strftime("%Y-%m-%d %H:%M")
|
||||
created_at = _to_gmt8(created_at).strftime("%Y-%m-%d %H:%M")
|
||||
|
||||
resolved_at = room_data.get("resolved_at")
|
||||
if isinstance(resolved_at, datetime):
|
||||
resolved_at = resolved_at.strftime("%Y-%m-%d %H:%M")
|
||||
resolved_at = _to_gmt8(resolved_at).strftime("%Y-%m-%d %H:%M")
|
||||
elif resolved_at is None:
|
||||
resolved_at = "尚未解決"
|
||||
|
||||
@@ -145,7 +156,7 @@ def _format_messages(messages: List[Dict[str, Any]]) -> str:
|
||||
|
||||
created_at = msg.get("created_at")
|
||||
if isinstance(created_at, datetime):
|
||||
time_str = created_at.strftime("%Y-%m-%d %H:%M")
|
||||
time_str = _to_gmt8(created_at).strftime("%Y-%m-%d %H:%M")
|
||||
else:
|
||||
time_str = str(created_at) if created_at else "未知時間"
|
||||
|
||||
@@ -164,26 +175,58 @@ def _format_messages(messages: List[Dict[str, Any]]) -> str:
|
||||
|
||||
|
||||
def _format_files(files: List[Dict[str, Any]]) -> str:
|
||||
"""Format file attachments section"""
|
||||
"""Format file attachments section with context
|
||||
|
||||
Each file now includes:
|
||||
- caption: User-provided description when uploading
|
||||
- context_before: The message sent before this file
|
||||
- context_after: The message sent after this file
|
||||
|
||||
This helps AI understand the context of each attachment.
|
||||
"""
|
||||
lines = ["## 附件清單"]
|
||||
lines.append("每個附件包含上傳時的說明文字以及上下文訊息,幫助理解該附件的用途。")
|
||||
lines.append("")
|
||||
|
||||
if not files:
|
||||
lines.append("無附件")
|
||||
return "\n".join(lines)
|
||||
|
||||
for f in files:
|
||||
for i, f in enumerate(files, 1):
|
||||
filename = f.get("filename", "未命名檔案")
|
||||
file_type = f.get("file_type", "file")
|
||||
uploader = f.get("uploader_name") or f.get("uploaded_by", "未知")
|
||||
caption = f.get("caption") # User-provided description
|
||||
context_before = f.get("context_before")
|
||||
context_after = f.get("context_after")
|
||||
|
||||
uploaded_at = f.get("uploaded_at")
|
||||
if isinstance(uploaded_at, datetime):
|
||||
time_str = uploaded_at.strftime("%Y-%m-%d %H:%M")
|
||||
time_str = _to_gmt8(uploaded_at).strftime("%Y-%m-%d %H:%M")
|
||||
else:
|
||||
time_str = str(uploaded_at) if uploaded_at else ""
|
||||
|
||||
type_label = "圖片" if file_type == "image" else "檔案"
|
||||
lines.append(f"- [{type_label}] {filename} (由 {uploader} 於 {time_str} 上傳)")
|
||||
|
||||
# Basic file info
|
||||
lines.append(f"### 附件 {i}: {filename}")
|
||||
lines.append(f"- 類型: {type_label}")
|
||||
lines.append(f"- 上傳者: {uploader}")
|
||||
lines.append(f"- 上傳時間: {time_str}")
|
||||
|
||||
# Caption/description if provided
|
||||
if caption:
|
||||
lines.append(f"- 說明: {caption}")
|
||||
|
||||
# Context messages to help AI understand when/why file was uploaded
|
||||
if context_before or context_after:
|
||||
lines.append("- 上下文:")
|
||||
if context_before:
|
||||
lines.append(f" - 前一則訊息: [{context_before['sender']}]: {context_before['content']}")
|
||||
if context_after:
|
||||
lines.append(f" - 後一則訊息: [{context_after['sender']}]: {context_after['content']}")
|
||||
|
||||
lines.append("") # Blank line between files
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Request and response models for the report generation endpoints.
|
||||
"""
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, ConfigDict, field_serializer
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
@@ -45,8 +45,12 @@ class ReportStatusResponse(BaseModel):
|
||||
prompt_tokens: Optional[int] = None
|
||||
completion_tokens: Optional[int] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@field_serializer("generated_at")
|
||||
def serialize_datetime(self, dt: datetime) -> str:
|
||||
"""Serialize datetime with 'Z' suffix to indicate UTC"""
|
||||
return dt.isoformat() + "Z"
|
||||
|
||||
|
||||
class ReportListItem(BaseModel):
|
||||
@@ -57,8 +61,12 @@ class ReportListItem(BaseModel):
|
||||
status: ReportStatus
|
||||
report_title: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@field_serializer("generated_at")
|
||||
def serialize_datetime(self, dt: datetime) -> str:
|
||||
"""Serialize datetime with 'Z' suffix to indicate UTC"""
|
||||
return dt.isoformat() + "Z"
|
||||
|
||||
|
||||
class ReportListResponse(BaseModel):
|
||||
|
||||
@@ -9,8 +9,21 @@ Creates .docx reports using python-docx with:
|
||||
import io
|
||||
import logging
|
||||
from typing import Dict, Any, List, Optional
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from docx import Document
|
||||
|
||||
# Taiwan timezone (GMT+8)
|
||||
TZ_GMT8 = timezone(timedelta(hours=8))
|
||||
|
||||
|
||||
def _to_gmt8(dt: datetime) -> datetime:
|
||||
"""Convert datetime to GMT+8 timezone"""
|
||||
if dt.tzinfo is None:
|
||||
# Assume UTC if no timezone
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
return dt.astimezone(TZ_GMT8)
|
||||
|
||||
|
||||
from docx.shared import Inches, Pt, RGBColor
|
||||
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
||||
from docx.enum.style import WD_STYLE_TYPE
|
||||
@@ -128,11 +141,11 @@ class DocxAssemblyService:
|
||||
run.font.size = TITLE_SIZE
|
||||
run.font.bold = True
|
||||
|
||||
# Add generation timestamp
|
||||
timestamp = datetime.utcnow().strftime("%Y-%m-%d %H:%M")
|
||||
# Add generation timestamp in GMT+8
|
||||
timestamp = datetime.now(TZ_GMT8).strftime("%Y-%m-%d %H:%M")
|
||||
subtitle = doc.add_paragraph()
|
||||
subtitle.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||
run = subtitle.add_run(f"報告產生時間:{timestamp}")
|
||||
run = subtitle.add_run(f"報告產生時間:{timestamp} (GMT+8)")
|
||||
run.font.size = Pt(10)
|
||||
run.font.color.rgb = RGBColor(128, 128, 128)
|
||||
|
||||
@@ -160,19 +173,19 @@ class DocxAssemblyService:
|
||||
cells[2].text = "發生地點"
|
||||
cells[3].text = room_data.get("location") or "未指定"
|
||||
|
||||
# Row 3: Created and Resolved times
|
||||
# Row 3: Created and Resolved times (in GMT+8)
|
||||
cells = table.rows[2].cells
|
||||
cells[0].text = "建立時間"
|
||||
created_at = room_data.get("created_at")
|
||||
if isinstance(created_at, datetime):
|
||||
cells[1].text = created_at.strftime("%Y-%m-%d %H:%M")
|
||||
cells[1].text = _to_gmt8(created_at).strftime("%Y-%m-%d %H:%M")
|
||||
else:
|
||||
cells[1].text = str(created_at) if created_at else "未知"
|
||||
|
||||
cells[2].text = "解決時間"
|
||||
resolved_at = room_data.get("resolved_at")
|
||||
if isinstance(resolved_at, datetime):
|
||||
cells[3].text = resolved_at.strftime("%Y-%m-%d %H:%M")
|
||||
cells[3].text = _to_gmt8(resolved_at).strftime("%Y-%m-%d %H:%M")
|
||||
elif resolved_at:
|
||||
cells[3].text = str(resolved_at)
|
||||
else:
|
||||
@@ -327,13 +340,24 @@ class DocxAssemblyService:
|
||||
# Add image to document
|
||||
doc.add_picture(image_data, width=Inches(5))
|
||||
|
||||
# Add caption
|
||||
# Add caption (user-provided description or filename)
|
||||
user_caption = f.get("caption") # User-provided description
|
||||
filename = f.get("filename", "圖片")
|
||||
|
||||
caption = doc.add_paragraph()
|
||||
caption.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||
run = caption.add_run(f"{f.get('filename', '圖片')}")
|
||||
|
||||
# Show filename first
|
||||
run = caption.add_run(filename)
|
||||
run.font.size = Pt(9)
|
||||
run.font.italic = True
|
||||
|
||||
# Add user caption if provided
|
||||
if user_caption:
|
||||
caption.add_run("\n")
|
||||
desc_run = caption.add_run(user_caption)
|
||||
desc_run.font.size = Pt(10)
|
||||
|
||||
doc.add_paragraph() # Spacing
|
||||
else:
|
||||
# Image download failed, add note
|
||||
@@ -344,7 +368,7 @@ class DocxAssemblyService:
|
||||
doc.add_paragraph(f"[圖片嵌入失敗: {f.get('filename', '未知')}]")
|
||||
|
||||
def _add_file_list_section(self, doc: Document, files: List[Dict[str, Any]]):
|
||||
"""Add file attachment list section"""
|
||||
"""Add file attachment list section with captions"""
|
||||
doc.add_heading("附件清單", level=1)
|
||||
|
||||
if not files:
|
||||
@@ -352,7 +376,7 @@ class DocxAssemblyService:
|
||||
return
|
||||
|
||||
# Create file list table
|
||||
table = doc.add_table(rows=len(files) + 1, cols=4)
|
||||
table = doc.add_table(rows=len(files) + 1, cols=5)
|
||||
table.style = "Table Grid"
|
||||
|
||||
# Header row
|
||||
@@ -361,6 +385,7 @@ class DocxAssemblyService:
|
||||
header[1].text = "類型"
|
||||
header[2].text = "上傳者"
|
||||
header[3].text = "上傳時間"
|
||||
header[4].text = "說明"
|
||||
for cell in header:
|
||||
for run in cell.paragraphs[0].runs:
|
||||
run.font.bold = True
|
||||
@@ -382,10 +407,13 @@ class DocxAssemblyService:
|
||||
|
||||
uploaded_at = f.get("uploaded_at")
|
||||
if isinstance(uploaded_at, datetime):
|
||||
row[3].text = uploaded_at.strftime("%Y-%m-%d %H:%M")
|
||||
row[3].text = _to_gmt8(uploaded_at).strftime("%Y-%m-%d %H:%M")
|
||||
else:
|
||||
row[3].text = str(uploaded_at) if uploaded_at else ""
|
||||
|
||||
# Caption/description column
|
||||
row[4].text = f.get("caption", "") or ""
|
||||
|
||||
def _download_file(self, object_path: str) -> Optional[io.BytesIO]:
|
||||
"""Download file from MinIO
|
||||
|
||||
@@ -431,9 +459,9 @@ class DocxAssemblyService:
|
||||
lines.append(f"# 事件報告:{title}")
|
||||
lines.append("")
|
||||
|
||||
# Generation timestamp
|
||||
timestamp = datetime.utcnow().strftime("%Y-%m-%d %H:%M")
|
||||
lines.append(f"*報告產生時間:{timestamp}*")
|
||||
# Generation timestamp in GMT+8
|
||||
timestamp = datetime.now(TZ_GMT8).strftime("%Y-%m-%d %H:%M")
|
||||
lines.append(f"*報告產生時間:{timestamp} (GMT+8)*")
|
||||
lines.append("")
|
||||
|
||||
# Metadata section
|
||||
@@ -455,13 +483,13 @@ class DocxAssemblyService:
|
||||
|
||||
created_at = room_data.get("created_at")
|
||||
if isinstance(created_at, datetime):
|
||||
lines.append(f"| 建立時間 | {created_at.strftime('%Y-%m-%d %H:%M')} |")
|
||||
lines.append(f"| 建立時間 | {_to_gmt8(created_at).strftime('%Y-%m-%d %H:%M')} |")
|
||||
else:
|
||||
lines.append(f"| 建立時間 | {str(created_at) if created_at else '未知'} |")
|
||||
|
||||
resolved_at = room_data.get("resolved_at")
|
||||
if isinstance(resolved_at, datetime):
|
||||
lines.append(f"| 解決時間 | {resolved_at.strftime('%Y-%m-%d %H:%M')} |")
|
||||
lines.append(f"| 解決時間 | {_to_gmt8(resolved_at).strftime('%Y-%m-%d %H:%M')} |")
|
||||
elif resolved_at:
|
||||
lines.append(f"| 解決時間 | {str(resolved_at)} |")
|
||||
else:
|
||||
@@ -561,8 +589,8 @@ class DocxAssemblyService:
|
||||
if files:
|
||||
lines.append("## 附件清單")
|
||||
lines.append("")
|
||||
lines.append("| 檔案名稱 | 類型 | 上傳者 | 上傳時間 |")
|
||||
lines.append("|----------|------|--------|----------|")
|
||||
lines.append("| 檔案名稱 | 類型 | 上傳者 | 上傳時間 | 說明 |")
|
||||
lines.append("|----------|------|--------|----------|------|")
|
||||
|
||||
file_type_map = {
|
||||
"image": "圖片",
|
||||
@@ -577,10 +605,13 @@ class DocxAssemblyService:
|
||||
uploader = f.get("uploader_name") or f.get("uploader_id", "")
|
||||
uploaded_at = f.get("uploaded_at")
|
||||
if isinstance(uploaded_at, datetime):
|
||||
uploaded_text = uploaded_at.strftime("%Y-%m-%d %H:%M")
|
||||
uploaded_text = _to_gmt8(uploaded_at).strftime("%Y-%m-%d %H:%M")
|
||||
else:
|
||||
uploaded_text = str(uploaded_at) if uploaded_at else ""
|
||||
lines.append(f"| {filename} | {type_text} | {uploader} | {uploaded_text} |")
|
||||
caption = f.get("caption", "") or ""
|
||||
# Escape pipe characters in caption
|
||||
caption = caption.replace("|", "\\|")
|
||||
lines.append(f"| {filename} | {type_text} | {uploader} | {uploaded_text} | {caption} |")
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
@@ -13,7 +13,7 @@ from sqlalchemy.orm import Session
|
||||
from sqlalchemy import desc
|
||||
|
||||
from app.modules.chat_room.models import IncidentRoom, RoomMember
|
||||
from app.modules.realtime.models import Message
|
||||
from app.modules.realtime.models import Message, MessageType
|
||||
from app.modules.file_storage.models import RoomFile
|
||||
from app.modules.auth.models import User
|
||||
|
||||
@@ -38,9 +38,17 @@ class MemberData:
|
||||
role: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class FileContextMessage:
|
||||
"""Context message near a file upload"""
|
||||
sender_name: str
|
||||
content: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
@dataclass
|
||||
class FileData:
|
||||
"""File data for report generation"""
|
||||
"""File data for report generation with context"""
|
||||
file_id: str
|
||||
filename: str
|
||||
file_type: str
|
||||
@@ -49,6 +57,10 @@ class FileData:
|
||||
uploader_id: str
|
||||
uploader_name: str
|
||||
minio_object_path: str
|
||||
# File context - the description/caption and surrounding messages
|
||||
message_content: Optional[str] = None # Caption/description from file upload message
|
||||
context_before: Optional[FileContextMessage] = None # Message before file
|
||||
context_after: Optional[FileContextMessage] = None # Message after file
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -173,7 +185,7 @@ class ReportDataService:
|
||||
return members
|
||||
|
||||
def _collect_files(self, room_id: str) -> List[FileData]:
|
||||
"""Collect room files with uploader display names"""
|
||||
"""Collect room files with uploader display names and message context"""
|
||||
results = (
|
||||
self.db.query(RoomFile, User.display_name)
|
||||
.outerjoin(User, RoomFile.uploader_id == User.user_id)
|
||||
@@ -185,6 +197,31 @@ class ReportDataService:
|
||||
|
||||
files = []
|
||||
for f, display_name in results:
|
||||
# Get file message content (caption/description)
|
||||
message_content = None
|
||||
context_before = None
|
||||
context_after = None
|
||||
|
||||
if f.message_id:
|
||||
# Get the file's message to extract caption
|
||||
file_message = self.db.query(Message).filter(
|
||||
Message.message_id == f.message_id
|
||||
).first()
|
||||
|
||||
if file_message:
|
||||
# Extract caption (content that's not default [Image] or [File] prefix)
|
||||
content = file_message.content
|
||||
if not content.startswith("[Image]") and not content.startswith("[File]"):
|
||||
message_content = content
|
||||
|
||||
# Get context: 1 message before and 1 after the file message
|
||||
context_before = self._get_context_message(
|
||||
room_id, file_message.sequence_number, before=True
|
||||
)
|
||||
context_after = self._get_context_message(
|
||||
room_id, file_message.sequence_number, before=False
|
||||
)
|
||||
|
||||
files.append(FileData(
|
||||
file_id=f.file_id,
|
||||
filename=f.filename,
|
||||
@@ -192,12 +229,45 @@ class ReportDataService:
|
||||
mime_type=f.mime_type,
|
||||
uploaded_at=f.uploaded_at,
|
||||
uploader_id=f.uploader_id,
|
||||
uploader_name=display_name or f.uploader_id, # Fallback to uploader_id
|
||||
uploader_name=display_name or f.uploader_id,
|
||||
minio_object_path=f.minio_object_path,
|
||||
message_content=message_content,
|
||||
context_before=context_before,
|
||||
context_after=context_after,
|
||||
))
|
||||
|
||||
return files
|
||||
|
||||
def _get_context_message(
|
||||
self, room_id: str, sequence_number: int, before: bool = True
|
||||
) -> Optional[FileContextMessage]:
|
||||
"""Get a context message before or after a given sequence number"""
|
||||
query = (
|
||||
self.db.query(Message, User.display_name)
|
||||
.outerjoin(User, Message.sender_id == User.user_id)
|
||||
.filter(Message.room_id == room_id)
|
||||
.filter(Message.deleted_at.is_(None))
|
||||
.filter(Message.message_type.in_([MessageType.TEXT, MessageType.SYSTEM])) # Only text context
|
||||
)
|
||||
|
||||
if before:
|
||||
query = query.filter(Message.sequence_number < sequence_number)
|
||||
query = query.order_by(desc(Message.sequence_number))
|
||||
else:
|
||||
query = query.filter(Message.sequence_number > sequence_number)
|
||||
query = query.order_by(Message.sequence_number)
|
||||
|
||||
result = query.first()
|
||||
|
||||
if result:
|
||||
msg, display_name = result
|
||||
return FileContextMessage(
|
||||
sender_name=display_name or msg.sender_id,
|
||||
content=msg.content,
|
||||
created_at=msg.created_at,
|
||||
)
|
||||
return None
|
||||
|
||||
def to_prompt_dict(self, data: RoomReportData) -> Dict[str, Any]:
|
||||
"""Convert RoomReportData to dictionary format for prompt builder
|
||||
|
||||
@@ -244,8 +314,9 @@ class ReportDataService:
|
||||
for m in data.members
|
||||
]
|
||||
|
||||
files = [
|
||||
{
|
||||
files = []
|
||||
for f in data.files:
|
||||
file_dict = {
|
||||
"file_id": f.file_id,
|
||||
"filename": f.filename,
|
||||
"file_type": f.file_type,
|
||||
@@ -254,9 +325,20 @@ class ReportDataService:
|
||||
"uploader_id": f.uploader_id,
|
||||
"uploader_name": f.uploader_name,
|
||||
"minio_object_path": f.minio_object_path,
|
||||
"caption": f.message_content, # User-provided caption/description
|
||||
}
|
||||
for f in data.files
|
||||
]
|
||||
# Add context if available
|
||||
if f.context_before:
|
||||
file_dict["context_before"] = {
|
||||
"sender": f.context_before.sender_name,
|
||||
"content": f.context_before.content,
|
||||
}
|
||||
if f.context_after:
|
||||
file_dict["context_after"] = {
|
||||
"sender": f.context_after.sender_name,
|
||||
"content": f.context_after.content,
|
||||
}
|
||||
files.append(file_dict)
|
||||
|
||||
return {
|
||||
"room_data": room_data,
|
||||
|
||||
@@ -12,6 +12,7 @@ interface ActionBarProps {
|
||||
canManageMembers: boolean
|
||||
isGeneratingReport: boolean
|
||||
uploadProgress: number | null
|
||||
uploadInfo?: { current: number; total: number } | null // For multi-file upload
|
||||
onFileSelect: (files: FileList | null) => void
|
||||
onGenerateReport: () => void
|
||||
onAddMemberClick: () => void
|
||||
@@ -29,6 +30,7 @@ export function ActionBar({
|
||||
canManageMembers,
|
||||
isGeneratingReport,
|
||||
uploadProgress,
|
||||
uploadInfo,
|
||||
onFileSelect,
|
||||
onGenerateReport,
|
||||
onAddMemberClick,
|
||||
@@ -54,13 +56,18 @@ export function ActionBar({
|
||||
|
||||
return (
|
||||
<div className="bg-white border-t border-gray-200">
|
||||
{/* Hidden file input */}
|
||||
{/* Hidden file input - supports multiple file selection */}
|
||||
<input
|
||||
type="file"
|
||||
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"
|
||||
accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.txt,.log"
|
||||
multiple
|
||||
/>
|
||||
|
||||
{/* Action bar content */}
|
||||
@@ -121,12 +128,16 @@ export function ActionBar({
|
||||
onClick={handleFileClick}
|
||||
disabled={uploadProgress !== null}
|
||||
className={buttonClass}
|
||||
title="Upload file"
|
||||
title="Upload files (multiple supported)"
|
||||
>
|
||||
{uploadProgress !== null ? (
|
||||
<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" />
|
||||
<span>{uploadProgress}%</span>
|
||||
<span>
|
||||
{uploadInfo && uploadInfo.total > 1
|
||||
? `${uploadInfo.current}/${uploadInfo.total} (${uploadProgress}%)`
|
||||
: `${uploadProgress}%`}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
@@ -217,7 +228,11 @@ export function ActionBar({
|
||||
{uploadProgress !== null ? (
|
||||
<>
|
||||
<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>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
|
||||
288
frontend/src/components/chat/FileMessage.tsx
Normal file
288
frontend/src/components/chat/FileMessage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
123
frontend/src/components/chat/ImageLightbox.tsx
Normal file
123
frontend/src/components/chat/ImageLightbox.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
207
frontend/src/components/chat/UploadPreview.tsx
Normal file
207
frontend/src/components/chat/UploadPreview.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -134,10 +134,18 @@ export function useWebSocket(roomId: string | null, options?: UseWebSocketOption
|
||||
(data: unknown) => {
|
||||
const msg = data as { type: string }
|
||||
|
||||
// Debug: Log all incoming WebSocket messages
|
||||
console.log('[WebSocket] Received message:', msg.type, data)
|
||||
|
||||
switch (msg.type) {
|
||||
case 'message':
|
||||
case 'edit_message': {
|
||||
const messageBroadcast = data as MessageBroadcast
|
||||
console.log('[WebSocket] Processing message broadcast:', {
|
||||
message_id: messageBroadcast.message_id,
|
||||
message_type: messageBroadcast.message_type,
|
||||
metadata: messageBroadcast.metadata,
|
||||
})
|
||||
const message: Message = {
|
||||
message_id: messageBroadcast.message_id,
|
||||
room_id: messageBroadcast.room_id,
|
||||
@@ -160,7 +168,8 @@ export function useWebSocket(roomId: string | null, options?: UseWebSocketOption
|
||||
break
|
||||
}
|
||||
|
||||
case 'delete_message': {
|
||||
case 'delete_message':
|
||||
case 'message_deleted': {
|
||||
const deleteMsg = data as { message_id: string }
|
||||
removeMessage(deleteMsg.message_id)
|
||||
break
|
||||
@@ -192,6 +201,10 @@ export function useWebSocket(roomId: string | null, options?: UseWebSocketOption
|
||||
|
||||
case 'file_deleted': {
|
||||
const fileData = data as FileDeletedBroadcast
|
||||
// Also remove the associated message from chat if it exists
|
||||
if (fileData.message_id) {
|
||||
removeMessage(fileData.message_id)
|
||||
}
|
||||
options?.onFileDeleted?.(fileData)
|
||||
break
|
||||
}
|
||||
|
||||
@@ -27,6 +27,8 @@ import { MobileHeader, SlidePanel } from '../components/mobile'
|
||||
import { ActionBar } from '../components/chat/ActionBar'
|
||||
import { MentionInput, highlightMentions } from '../components/chat/MentionInput'
|
||||
import { NotificationSettings } from '../components/chat/NotificationSettings'
|
||||
import { FileMessage } from '../components/chat/FileMessage'
|
||||
import { UploadPreview } from '../components/chat/UploadPreview'
|
||||
import ReportProgress from '../components/report/ReportProgress'
|
||||
import { formatMessageTime } from '../utils/datetime'
|
||||
import type { SeverityLevel, RoomStatus, MemberRole, FileMetadata, ReportStatus, Message } from '../types'
|
||||
@@ -138,8 +140,11 @@ export default function RoomDetail() {
|
||||
const [showEmojiPickerFor, setShowEmojiPickerFor] = useState<string | null>(null)
|
||||
const [copiedMessageId, setCopiedMessageId] = useState<string | 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 [previewFile, setPreviewFile] = useState<FileMetadata | null>(null)
|
||||
const [pendingUploadFile, setPendingUploadFile] = useState<File | null>(null)
|
||||
const [pendingUploadFiles, setPendingUploadFiles] = useState<File[]>([])
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [newMemberUsername, setNewMemberUsername] = useState('')
|
||||
const [newMemberRole, setNewMemberRole] = useState<MemberRole>('viewer')
|
||||
@@ -310,25 +315,68 @@ export default function RoomDetail() {
|
||||
})
|
||||
}
|
||||
|
||||
// File handlers
|
||||
const handleFileUpload = useCallback(
|
||||
(files: FileList | null) => {
|
||||
if (!files || files.length === 0) return
|
||||
// File handlers - Show preview before upload (single file) or upload immediately (multiple files)
|
||||
const handleFileSelect = useCallback(
|
||||
async (files: FileList | null) => {
|
||||
if (!files || files.length === 0 || !roomId) return
|
||||
|
||||
if (files.length === 1) {
|
||||
// Single file: show preview before upload
|
||||
setPendingUploadFile(files[0])
|
||||
} else {
|
||||
// Multiple files: upload immediately without preview
|
||||
const fileArray = Array.from(files)
|
||||
setPendingUploadFiles(fileArray)
|
||||
setUploadProgress(0)
|
||||
setUploadInfo({ current: 1, total: fileArray.length })
|
||||
|
||||
try {
|
||||
const result = await filesService.uploadFiles(
|
||||
roomId,
|
||||
fileArray,
|
||||
(progress, current, total) => {
|
||||
setUploadProgress(progress)
|
||||
setUploadInfo({ current, total })
|
||||
}
|
||||
)
|
||||
|
||||
if (result.failed.length > 0) {
|
||||
// Show alert for failed uploads
|
||||
const failedNames = result.failed.map(f => f.file).map(f => f.name).join(', ')
|
||||
alert(`Failed to upload: ${failedNames}`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Multi-file upload failed:', error)
|
||||
} finally {
|
||||
setUploadProgress(null)
|
||||
setUploadInfo(null)
|
||||
setPendingUploadFiles([])
|
||||
}
|
||||
}
|
||||
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = ''
|
||||
}
|
||||
},
|
||||
[roomId]
|
||||
)
|
||||
|
||||
const handleUploadWithDescription = useCallback(
|
||||
(description?: string) => {
|
||||
if (!pendingUploadFile) return
|
||||
|
||||
const file = files[0]
|
||||
setUploadProgress(0)
|
||||
|
||||
uploadFile.mutate(
|
||||
{
|
||||
file,
|
||||
file: pendingUploadFile,
|
||||
description,
|
||||
onProgress: (progress) => setUploadProgress(progress),
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setUploadProgress(null)
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = ''
|
||||
}
|
||||
setPendingUploadFile(null)
|
||||
},
|
||||
onError: () => {
|
||||
setUploadProgress(null)
|
||||
@@ -336,16 +384,21 @@ export default function RoomDetail() {
|
||||
}
|
||||
)
|
||||
},
|
||||
[uploadFile]
|
||||
[pendingUploadFile, uploadFile]
|
||||
)
|
||||
|
||||
const handleCancelUpload = useCallback(() => {
|
||||
setPendingUploadFile(null)
|
||||
setUploadProgress(null)
|
||||
}, [])
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(false)
|
||||
handleFileUpload(e.dataTransfer.files)
|
||||
handleFileSelect(e.dataTransfer.files)
|
||||
},
|
||||
[handleFileUpload]
|
||||
[handleFileSelect]
|
||||
)
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
@@ -678,6 +731,32 @@ export default function RoomDetail() {
|
||||
messages.map((message) => {
|
||||
const isOwnMessage = message.sender_id === user?.username
|
||||
const isEditing = editingMessageId === message.message_id
|
||||
const isFileMessage = message.message_type === 'image_ref' || message.message_type === 'file_ref'
|
||||
|
||||
// Handle file/image messages with FileMessage component
|
||||
if (isFileMessage) {
|
||||
return (
|
||||
<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 (
|
||||
<div
|
||||
@@ -858,15 +937,27 @@ export default function RoomDetail() {
|
||||
canWrite={permissions?.can_write || false}
|
||||
canManageMembers={permissions?.can_manage_members || false}
|
||||
isGeneratingReport={generateReport.isPending}
|
||||
uploadProgress={uploadProgress}
|
||||
onFileSelect={handleFileUpload}
|
||||
uploadProgress={pendingUploadFile || pendingUploadFiles.length > 0 ? uploadProgress : null}
|
||||
uploadInfo={uploadInfo}
|
||||
onFileSelect={handleFileSelect}
|
||||
onGenerateReport={handleGenerateReport}
|
||||
onAddMemberClick={() => setShowAddMember(true)}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
|
||||
{/* Upload Preview - Show when file selected but not yet uploaded */}
|
||||
{pendingUploadFile && (
|
||||
<UploadPreview
|
||||
file={pendingUploadFile}
|
||||
uploadProgress={uploadProgress}
|
||||
onCancel={handleCancelUpload}
|
||||
onSend={handleUploadWithDescription}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 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' : ''}`}>
|
||||
<div className="flex gap-2">
|
||||
<MentionInput
|
||||
@@ -1091,7 +1182,7 @@ export default function RoomDetail() {
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
onChange={(e) => handleFileUpload(e.target.files)}
|
||||
onChange={(e) => handleFileSelect(e.target.files)}
|
||||
className="hidden"
|
||||
/>
|
||||
{uploadProgress !== null ? (
|
||||
@@ -1370,7 +1461,7 @@ export default function RoomDetail() {
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
onChange={(e) => handleFileUpload(e.target.files)}
|
||||
onChange={(e) => handleFileSelect(e.target.files)}
|
||||
className="hidden"
|
||||
/>
|
||||
{uploadProgress !== null ? (
|
||||
|
||||
@@ -46,6 +46,55 @@ export const filesService = {
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* Upload multiple files to room sequentially
|
||||
* Reports overall progress across all files
|
||||
*/
|
||||
async uploadFiles(
|
||||
roomId: string,
|
||||
files: File[],
|
||||
onProgress?: (progress: number, currentFile: number, totalFiles: number) => void
|
||||
): Promise<{ successful: FileUploadResponse[]; failed: { file: File; error: string }[] }> {
|
||||
const successful: FileUploadResponse[] = []
|
||||
const failed: { file: File; error: string }[] = []
|
||||
const totalFiles = files.length
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i]
|
||||
const currentFile = i + 1
|
||||
|
||||
try {
|
||||
const result = await this.uploadFile(
|
||||
roomId,
|
||||
file,
|
||||
undefined,
|
||||
(fileProgress) => {
|
||||
if (onProgress) {
|
||||
// Calculate overall progress: completed files + current file progress
|
||||
const completedProgress = (i / totalFiles) * 100
|
||||
const currentFileContribution = (fileProgress / totalFiles)
|
||||
const overallProgress = Math.round(completedProgress + currentFileContribution)
|
||||
onProgress(overallProgress, currentFile, totalFiles)
|
||||
}
|
||||
}
|
||||
)
|
||||
successful.push(result)
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Upload failed'
|
||||
failed.push({ file, error: errorMessage })
|
||||
console.error(`Failed to upload ${file.name}:`, error)
|
||||
}
|
||||
|
||||
// Report completion of this file
|
||||
if (onProgress) {
|
||||
const overallProgress = Math.round(((i + 1) / totalFiles) * 100)
|
||||
onProgress(overallProgress, currentFile, totalFiles)
|
||||
}
|
||||
}
|
||||
|
||||
return { successful, failed }
|
||||
},
|
||||
|
||||
/**
|
||||
* List files in a room
|
||||
*/
|
||||
|
||||
@@ -127,6 +127,7 @@ export type FileType = 'image' | 'document' | 'log'
|
||||
|
||||
export interface FileMetadata {
|
||||
file_id: string
|
||||
message_id?: string | null // Associated chat message ID
|
||||
room_id: string
|
||||
filename: string
|
||||
file_type: FileType
|
||||
@@ -138,15 +139,18 @@ export interface FileMetadata {
|
||||
uploader_id: string
|
||||
deleted_at?: string | null
|
||||
download_url?: string
|
||||
thumbnail_url?: string | null // Thumbnail URL for images
|
||||
}
|
||||
|
||||
export interface FileUploadResponse {
|
||||
file_id: string
|
||||
message_id?: string | null // Associated chat message ID
|
||||
filename: string
|
||||
file_type: FileType
|
||||
file_size: number
|
||||
mime_type: string
|
||||
download_url: string
|
||||
thumbnail_url?: string | null // Thumbnail URL for images
|
||||
uploaded_at: string
|
||||
uploader_id: string
|
||||
}
|
||||
@@ -222,6 +226,7 @@ export interface TypingBroadcast {
|
||||
export interface FileUploadedBroadcast {
|
||||
type: 'file_uploaded'
|
||||
file_id: string
|
||||
message_id?: string | null // Associated chat message ID
|
||||
room_id: string
|
||||
uploader_id: string
|
||||
filename: string
|
||||
@@ -229,12 +234,14 @@ export interface FileUploadedBroadcast {
|
||||
file_size: number
|
||||
mime_type: string
|
||||
download_url?: string
|
||||
thumbnail_url?: string | null // Thumbnail URL for images
|
||||
uploaded_at: string
|
||||
}
|
||||
|
||||
export interface FileDeletedBroadcast {
|
||||
type: 'file_deleted'
|
||||
file_id: string
|
||||
message_id?: string | null // Associated chat message ID (also deleted)
|
||||
room_id: string
|
||||
deleted_by: string
|
||||
deleted_at: string
|
||||
|
||||
@@ -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)
|
||||
@@ -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`
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -50,7 +50,7 @@ The system SHALL maintain a permanent `users` table to store user display names
|
||||
|
||||
### Requirement: Report Data Collection
|
||||
|
||||
The system SHALL collect all relevant room data for AI processing, including messages, members, files, and room metadata.
|
||||
The system SHALL collect all relevant room data for AI processing, including messages, members, files with their conversation context, and room metadata.
|
||||
|
||||
#### Scenario: Collect complete room data for report generation
|
||||
|
||||
@@ -62,9 +62,29 @@ The system SHALL collect all relevant room data for AI processing, including mes
|
||||
- Room metadata (title, incident_type, severity, status, location, description, timestamps)
|
||||
- All 50 messages sorted by created_at ascending
|
||||
- All 5 members with their roles (owner, editor, viewer)
|
||||
- All 3 files with metadata (filename, type, uploader, upload time)
|
||||
- All 3 files with metadata (filename, type, uploader, upload time) AND their associated message context
|
||||
- **AND** messages SHALL include sender display name (not just user_id)
|
||||
- **AND** file references in messages SHALL be annotated as "[附件: filename.ext]"
|
||||
- **AND** file references in messages SHALL be annotated with surrounding context
|
||||
|
||||
#### Scenario: Include file context in report data
|
||||
- **GIVEN** a file "defect_photo.jpg" was uploaded with the message "發現產品表面瑕疵"
|
||||
- **AND** the previous message was "Line 3 溫度異常升高中"
|
||||
- **AND** the next message was "已通知維修人員處理"
|
||||
- **WHEN** report data is collected
|
||||
- **THEN** the file entry SHALL include:
|
||||
```json
|
||||
{
|
||||
"file_id": "...",
|
||||
"filename": "defect_photo.jpg",
|
||||
"uploader_display_name": "陳工程師",
|
||||
"uploaded_at": "2025-12-08T14:30:00+08:00",
|
||||
"caption": "發現產品表面瑕疵",
|
||||
"context_before": "Line 3 溫度異常升高中",
|
||||
"context_after": "已通知維修人員處理"
|
||||
}
|
||||
```
|
||||
- **AND** the AI prompt SHALL format files as:
|
||||
`[附件: defect_photo.jpg] - 上傳者: 陳工程師 (14:30), 說明: "發現產品表面瑕疵" (前文: "Line 3 溫度異常升高中")`
|
||||
|
||||
#### Scenario: Handle room with no messages
|
||||
|
||||
@@ -82,8 +102,6 @@ The system SHALL collect all relevant room data for AI processing, including mes
|
||||
- **AND** summarize older messages by day (e.g., "2025-12-01: 45 則訊息討論設備檢修")
|
||||
- **AND** the total formatted content SHALL stay within token limits
|
||||
|
||||
---
|
||||
|
||||
### Requirement: DIFY AI Integration
|
||||
|
||||
The system SHALL integrate with DIFY Chat API to generate structured report content from collected room data.
|
||||
@@ -126,7 +144,7 @@ The system SHALL integrate with DIFY Chat API to generate structured report cont
|
||||
|
||||
### Requirement: Document Assembly
|
||||
|
||||
The system SHALL assemble professional .docx documents from AI-generated content with embedded images from MinIO.
|
||||
The system SHALL assemble professional .docx documents from AI-generated content with embedded images from MinIO and file context from conversations.
|
||||
|
||||
#### Scenario: Generate complete report document
|
||||
|
||||
@@ -142,8 +160,9 @@ The system SHALL assemble professional .docx documents from AI-generated content
|
||||
- Section 4: 處理過程 (from AI resolution_process.content)
|
||||
- Section 5: 目前狀態 (from AI current_status)
|
||||
- Section 6: 最終處置結果 (from AI final_resolution, if has_resolution=true)
|
||||
- Section 7: 附件 (embedded images + file list)
|
||||
- Section 7: 附件 (embedded images with captions + file list with context)
|
||||
- **AND** images SHALL be embedded at appropriate size (max width 15cm)
|
||||
- **AND** each image SHALL include its caption from the upload message
|
||||
- **AND** document SHALL use professional formatting (標楷體 or similar)
|
||||
|
||||
#### Scenario: Handle missing images during assembly
|
||||
@@ -163,8 +182,6 @@ The system SHALL assemble professional .docx documents from AI-generated content
|
||||
- **THEN** the system SHALL create a complete document without the embedded images section
|
||||
- **AND** the attachments section SHALL show "本事件無附件檔案" if no files exist
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Report Generation API
|
||||
|
||||
The system SHALL provide REST API endpoints for triggering report generation and downloading generated reports.
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
TBD - created by archiving change add-file-upload-minio. Update Purpose after archive.
|
||||
## Requirements
|
||||
### Requirement: File Upload with Validation
|
||||
The system SHALL accept multipart file uploads to incident rooms, validate file type and size, and persist files to MinIO object storage with metadata tracking in PostgreSQL.
|
||||
The system SHALL accept multipart file uploads to incident rooms, validate file type and size, persist files to MinIO object storage with metadata tracking in PostgreSQL, AND create an associated message in the chat for context.
|
||||
|
||||
#### Scenario: Upload image to incident room
|
||||
- **WHEN** a user with OWNER or EDITOR role uploads an image file via `POST /api/rooms/{room_id}/files`
|
||||
@@ -20,11 +20,19 @@ The system SHALL accept multipart file uploads to incident rooms, validate file
|
||||
- Validate JWT token and extract user_id
|
||||
- Verify user is member of room-123 with OWNER or EDITOR role
|
||||
- Validate file MIME type is image/jpeg, image/png, or image/gif
|
||||
- Validate file size ≤ 10MB
|
||||
- Validate file size <= 10MB
|
||||
- Generate unique file_id (UUID)
|
||||
- Upload file to MinIO bucket `task-reporter-files` at path `room-123/images/{file_id}.jpg`
|
||||
- Create database record in `room_files` table
|
||||
- Return file metadata with presigned download URL (1-hour expiry)
|
||||
- Create a message with `message_type=image_ref` containing file metadata
|
||||
- Create database record in `room_files` table with `message_id` reference
|
||||
- Return file metadata with presigned download URL (1-hour expiry) and message_id
|
||||
|
||||
#### Scenario: Upload document to incident room
|
||||
- **WHEN** a user uploads a non-image file (PDF, log, etc.)
|
||||
- **THEN** the system SHALL:
|
||||
- Create a message with `message_type=file_ref`
|
||||
- Store message_id in the file record
|
||||
- Return file metadata with message_id
|
||||
|
||||
#### Scenario: Reject oversized file upload
|
||||
- **WHEN** a user attempts to upload a 15MB PDF file
|
||||
@@ -34,6 +42,7 @@ The system SHALL accept multipart file uploads to incident rooms, validate file
|
||||
- Include error message: "File size exceeds limit: 15MB > 20MB"
|
||||
- NOT upload file to MinIO
|
||||
- NOT create database record
|
||||
- NOT create chat message
|
||||
|
||||
#### Scenario: Reject unauthorized file type
|
||||
- **WHEN** a user attempts to upload an executable file (e.g., .exe, .sh, .bat)
|
||||
@@ -57,7 +66,8 @@ The system SHALL accept multipart file uploads to incident rooms, validate file
|
||||
- Validate MIME type is text/plain
|
||||
- Upload to MinIO at `room-456/logs/{file_id}.log`
|
||||
- Store metadata with file_type='log'
|
||||
- Return success response with file_id
|
||||
- Create a message with message_type='file_ref'
|
||||
- Return success response with file_id and message_id
|
||||
|
||||
### Requirement: File Download with Access Control
|
||||
The system SHALL generate time-limited presigned download URLs for files, enforcing room membership-based access control.
|
||||
@@ -264,3 +274,39 @@ The system SHALL validate file types using MIME type detection (not just file ex
|
||||
- NOT upload to MinIO
|
||||
- Log security event
|
||||
|
||||
### Requirement: File-Message Association
|
||||
The system SHALL maintain a foreign key relationship between uploaded files and their associated chat messages, enabling contextual display of files in conversations.
|
||||
|
||||
#### Scenario: Query file with associated message
|
||||
- **WHEN** a client requests file metadata via `GET /api/rooms/{room_id}/files/{file_id}`
|
||||
- **THEN** the response SHALL include:
|
||||
```json
|
||||
{
|
||||
"file_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"message_id": "msg-789",
|
||||
"filename": "defect.jpg",
|
||||
"file_type": "image",
|
||||
"download_url": "...",
|
||||
"uploaded_at": "2025-12-08T10:30:00+08:00"
|
||||
}
|
||||
```
|
||||
|
||||
#### Scenario: Delete file cascades to message
|
||||
- **WHEN** a file is soft-deleted via `DELETE /api/rooms/{room_id}/files/{file_id}`
|
||||
- **THEN** the system SHALL also soft-delete the associated message
|
||||
- **AND** broadcast both `file_deleted` and `message_deleted` events
|
||||
|
||||
### Requirement: Image Thumbnail URLs
|
||||
The system SHALL generate presigned URLs suitable for thumbnail display, allowing frontends to efficiently render image previews.
|
||||
|
||||
#### Scenario: File metadata includes thumbnail URL
|
||||
- **WHEN** file metadata is returned for an image file
|
||||
- **THEN** the response SHALL include a `thumbnail_url` field
|
||||
- **AND** the URL SHALL be a presigned MinIO URL valid for 1 hour
|
||||
- **AND** the frontend SHALL use CSS to constrain thumbnail display size
|
||||
|
||||
#### Scenario: Non-image files have no thumbnail
|
||||
- **WHEN** file metadata is returned for a non-image file (PDF, log, etc.)
|
||||
- **THEN** the response SHALL NOT include a `thumbnail_url` field
|
||||
- **AND** the frontend SHALL display a file-type icon instead
|
||||
|
||||
|
||||
@@ -90,7 +90,7 @@ The system SHALL persist all messages to database for audit trail, report genera
|
||||
- **AND** maintain user's access control (only rooms they're members of)
|
||||
|
||||
### Requirement: Message Types and Formatting
|
||||
The system SHALL support various message types including text, image references, file references, and structured data for production incidents.
|
||||
The system SHALL support various message types including text, image references, file references, and structured data for production incidents, with inline display of file attachments in the chat view.
|
||||
|
||||
#### Scenario: Text message with mentions
|
||||
- **WHEN** a user sends a message with @mentions
|
||||
@@ -103,19 +103,24 @@ The system SHALL support various message types including text, image references,
|
||||
- **THEN** the system SHALL parse and store mentions
|
||||
- **AND** potentially trigger notifications to mentioned users
|
||||
|
||||
#### Scenario: Image reference message
|
||||
- **WHEN** a user uploads an image and sends reference
|
||||
```json
|
||||
{
|
||||
"type": "message",
|
||||
"message_type": "image_ref",
|
||||
"content": "Defect found on product",
|
||||
"file_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"file_url": "http://localhost:9000/bucket/room-123/image.jpg"
|
||||
}
|
||||
```
|
||||
- **THEN** the system SHALL store the file reference
|
||||
- **AND** clients SHALL display image preview inline
|
||||
#### Scenario: Image reference message display
|
||||
- **WHEN** a message with `message_type=image_ref` is rendered in the chat
|
||||
- **THEN** the client SHALL display:
|
||||
- A thumbnail of the image (max 300px width)
|
||||
- The message content/caption below the image
|
||||
- Sender name and timestamp
|
||||
- A click-to-expand functionality
|
||||
- **AND** clicking the thumbnail SHALL open a full-size preview lightbox
|
||||
|
||||
#### Scenario: File reference message display
|
||||
- **WHEN** a message with `message_type=file_ref` is rendered in the chat
|
||||
- **THEN** the client SHALL display:
|
||||
- A file type icon (PDF, document, log, etc.)
|
||||
- The filename
|
||||
- File size in human-readable format
|
||||
- A download button/link
|
||||
- The message content/caption
|
||||
- Sender name and timestamp
|
||||
|
||||
#### Scenario: Structured incident data
|
||||
- **WHEN** reporting specific incident metrics
|
||||
@@ -265,7 +270,7 @@ The system SHALL include the sender's display name in message responses and broa
|
||||
|
||||
### Requirement: GMT+8 Timezone Display
|
||||
|
||||
The frontend SHALL display all timestamps in GMT+8 (Asia/Taipei) timezone for consistent user experience across all browsers.
|
||||
The frontend SHALL display all timestamps in GMT+8 (Asia/Taipei) timezone for consistent user experience across all browsers and all parts of the application.
|
||||
|
||||
#### Scenario: Message timestamp in GMT+8
|
||||
- **WHEN** a message is displayed in the chat room
|
||||
@@ -277,6 +282,14 @@ The frontend SHALL display all timestamps in GMT+8 (Asia/Taipei) timezone for co
|
||||
- **WHEN** the room list is displayed
|
||||
- **THEN** the "last updated" time SHALL be formatted in GMT+8 timezone
|
||||
|
||||
#### Scenario: File upload timestamp in GMT+8
|
||||
- **WHEN** a file is displayed in chat or file drawer
|
||||
- **THEN** the upload timestamp SHALL be formatted in GMT+8 timezone
|
||||
|
||||
#### Scenario: Report generation timestamp in GMT+8
|
||||
- **WHEN** report metadata is displayed
|
||||
- **THEN** the "generated at" timestamp SHALL be formatted in GMT+8 timezone
|
||||
|
||||
### Requirement: @Mention Support
|
||||
The messaging system SHALL support @mention functionality to tag specific users in messages.
|
||||
|
||||
@@ -337,3 +350,61 @@ Messages with @mentions SHALL store the mention metadata for querying.
|
||||
- **WHEN** fetching messages that mention a specific user
|
||||
- **THEN** messages with that user_id in `mentions` array are returned
|
||||
|
||||
### Requirement: Image Preview Lightbox
|
||||
The frontend SHALL provide a lightbox component for viewing full-size images from chat messages.
|
||||
|
||||
#### Scenario: Open image lightbox
|
||||
- **WHEN** user clicks on an image thumbnail in the chat
|
||||
- **THEN** a modal overlay SHALL appear
|
||||
- **AND** the full-size image SHALL be displayed centered
|
||||
- **AND** a loading indicator SHALL show while image loads
|
||||
- **AND** the image SHALL be constrained to fit the viewport
|
||||
|
||||
#### Scenario: Close image lightbox
|
||||
- **WHEN** the lightbox is open
|
||||
- **THEN** user can close it by:
|
||||
- Clicking the X button
|
||||
- Pressing the ESC key
|
||||
- Clicking outside the image
|
||||
- **AND** focus SHALL return to the chat
|
||||
|
||||
#### Scenario: Image lightbox with download
|
||||
- **WHEN** the lightbox is open
|
||||
- **THEN** a download button SHALL be visible
|
||||
- **AND** clicking it SHALL download the original file
|
||||
|
||||
### Requirement: File Type Icons
|
||||
The frontend SHALL display appropriate icons for different file types in chat messages and file drawer.
|
||||
|
||||
#### Scenario: PDF file icon
|
||||
- **WHEN** a PDF file is displayed
|
||||
- **THEN** a PDF icon (red/document style) SHALL be shown
|
||||
|
||||
#### Scenario: Log/text file icon
|
||||
- **WHEN** a .log or .txt file is displayed
|
||||
- **THEN** a text file icon SHALL be shown
|
||||
|
||||
#### Scenario: Excel file icon
|
||||
- **WHEN** an Excel file (.xlsx, .xls) is displayed
|
||||
- **THEN** a spreadsheet icon (green) SHALL be shown
|
||||
|
||||
#### Scenario: Generic file icon
|
||||
- **WHEN** a file with unknown type is displayed
|
||||
- **THEN** a generic document icon SHALL be shown
|
||||
|
||||
### Requirement: Upload Preview
|
||||
The frontend SHALL show a preview of the file being uploaded before the message is sent.
|
||||
|
||||
#### Scenario: Image upload preview
|
||||
- **WHEN** user selects an image file for upload
|
||||
- **THEN** a preview thumbnail SHALL be displayed in the input area
|
||||
- **AND** user can add a caption/description
|
||||
- **AND** user can cancel the upload before sending
|
||||
- **AND** a send button confirms the upload
|
||||
|
||||
#### Scenario: File upload preview
|
||||
- **WHEN** user selects a non-image file for upload
|
||||
- **THEN** file info (name, size, type icon) SHALL be displayed
|
||||
- **AND** user can add a description
|
||||
- **AND** user can cancel or confirm
|
||||
|
||||
|
||||
134
scripts/migrate_orphan_files.py
Normal file
134
scripts/migrate_orphan_files.py
Normal 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.")
|
||||
Reference in New Issue
Block a user