"""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, MessageDeletedBroadcast, MessageBroadcast, MessageTypeEnum from app.modules.realtime.services.message_service import MessageService 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) # 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, file_type=result.file_type.value, 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} (message: {result.message_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(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 ) # 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, 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, thumbnail_url=thumbnail_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 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 and message deletion events to room members via WebSocket if deleted_file: async def broadcast_file_delete(): try: # 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, 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/message deletion: {e}") # Run broadcast in background background_tasks.add_task(broadcast_file_delete) return None