feat: Initial commit - Task Reporter incident response system

Complete implementation of the production line incident response system (生產線異常即時反應系統) including:

Backend (FastAPI):
- User authentication with AD integration and session management
- Chat room management (create, list, update, members, roles)
- Real-time messaging via WebSocket (typing indicators, reactions)
- File storage with MinIO (upload, download, image preview)

Frontend (React + Vite):
- Authentication flow with token management
- Room list with filtering, search, and pagination
- Real-time chat interface with WebSocket
- File upload with drag-and-drop and image preview
- Member management and room settings
- Breadcrumb navigation
- 53 unit tests (Vitest)

Specifications:
- authentication: AD auth, sessions, JWT tokens
- chat-room: rooms, members, templates
- realtime-messaging: WebSocket, messages, reactions
- file-storage: MinIO integration, file management
- frontend-core: React SPA structure

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
egg
2025-12-01 17:42:52 +08:00
commit c8966477b9
135 changed files with 23269 additions and 0 deletions

View File

@@ -0,0 +1,228 @@
"""API routes for file storage operations
FastAPI router with file upload, download, listing, and delete endpoints
"""
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form, Query, status, BackgroundTasks
from sqlalchemy.orm import Session
from typing import Optional
from datetime import datetime
import asyncio
import logging
from app.core.database import get_db
from app.core.config import get_settings
from app.modules.auth import get_current_user
from app.modules.chat_room.dependencies import get_current_room
from app.modules.chat_room.models import MemberRole
from app.modules.chat_room.services.membership_service import membership_service
from app.modules.file_storage.schemas import FileUploadResponse, FileMetadata, FileListResponse, FileType
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
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/rooms", tags=["Files"])
@router.post("/{room_id}/files", response_model=FileUploadResponse, status_code=status.HTTP_201_CREATED)
async def upload_file(
room_id: str,
background_tasks: BackgroundTasks,
file: UploadFile = File(...),
description: Optional[str] = Form(None),
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user),
_room = Depends(get_current_room) # Validates room exists and user has access
):
"""Upload a file to an incident room
Requires OWNER or EDITOR role in the room.
Supported file types:
- Images: jpg, jpeg, png, gif (max 10MB)
- Documents: pdf (max 20MB)
- Logs: txt, log, csv (max 5MB)
"""
user_email = current_user["username"]
# Check write permission (OWNER or EDITOR)
member = FileService.check_room_membership(db, room_id, user_email)
if not FileService.check_write_permission(member):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only OWNER or EDITOR can upload files"
)
# Upload file
result = FileService.upload_file(db, room_id, user_email, file, description)
# Broadcast file upload event to room members via WebSocket
async def broadcast_file_upload():
try:
broadcast = FileUploadedBroadcast(
file_id=result.file_id,
room_id=room_id,
uploader_id=user_email,
filename=result.filename,
file_type=result.file_type.value,
file_size=result.file_size,
mime_type=result.mime_type,
download_url=result.download_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}")
# Send acknowledgment to uploader
ack = FileUploadAck(
file_id=result.file_id,
status="success",
download_url=result.download_url
)
await websocket_manager.send_personal(user_email, ack.to_dict())
except Exception as e:
logger.error(f"Failed to broadcast file upload: {e}")
# Run broadcast in background
background_tasks.add_task(asyncio.create_task, broadcast_file_upload())
return result
@router.get("/{room_id}/files", response_model=FileListResponse)
async def list_files(
room_id: str,
file_type: Optional[FileType] = Query(None, description="Filter by file type"),
limit: int = Query(50, ge=1, le=100, description="Number of files to return"),
offset: int = Query(0, ge=0, description="Number of files to skip"),
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user),
_room = Depends(get_current_room) # Validates room exists and user has access
):
"""List files in an incident room with pagination
All room members can list files.
"""
# Convert enum to string value if provided
file_type_str = file_type.value if file_type else None
return FileService.get_files(db, room_id, limit, offset, file_type_str)
@router.get("/{room_id}/files/{file_id}", response_model=FileMetadata)
async def get_file(
room_id: str,
file_id: str,
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user),
_room = Depends(get_current_room) # Validates room exists and user has access
):
"""Get file metadata and presigned download URL
All room members can access file metadata and download files.
Presigned URL expires in 1 hour.
"""
settings = get_settings()
# Get file metadata
file_record = FileService.get_file(db, file_id)
if not file_record:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="File not found"
)
# Verify file belongs to requested room
if file_record.room_id != room_id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="File not found in this room"
)
# Generate presigned download URL
download_url = minio_service.generate_presigned_url(
bucket=settings.MINIO_BUCKET,
object_path=file_record.minio_object_path,
expiry_seconds=3600
)
# Build response with download URL
return FileMetadata(
file_id=file_record.file_id,
room_id=file_record.room_id,
filename=file_record.filename,
file_type=file_record.file_type,
mime_type=file_record.mime_type,
file_size=file_record.file_size,
minio_bucket=file_record.minio_bucket,
minio_object_path=file_record.minio_object_path,
uploaded_at=file_record.uploaded_at,
uploader_id=file_record.uploader_id,
deleted_at=file_record.deleted_at,
download_url=download_url
)
@router.delete("/{room_id}/files/{file_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_file(
room_id: str,
file_id: str,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user),
_room = Depends(get_current_room) # Validates room exists and user has access
):
"""Soft delete a file
Only the file uploader or room OWNER can delete files.
"""
user_email = current_user["username"]
# Get file to check ownership
file_record = FileService.get_file(db, file_id)
if not file_record:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="File not found"
)
# Verify file belongs to requested room
if file_record.room_id != room_id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="File not found in this room"
)
# Check if user is room owner
role = membership_service.get_user_role_in_room(db, room_id, user_email)
is_room_owner = role == MemberRole.OWNER
# 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)
# Broadcast file deletion event to room members via WebSocket
if deleted_file:
async def broadcast_file_delete():
try:
broadcast = FileDeletedBroadcast(
file_id=file_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())
logger.info(f"Broadcasted file deletion event: {file_id} from room {room_id}")
except Exception as e:
logger.error(f"Failed to broadcast file deletion: {e}")
# Run broadcast in background
background_tasks.add_task(asyncio.create_task, broadcast_file_delete())
return None