diff --git a/app/main.py b/app/main.py index 53aa8c7..3f8bff2 100644 --- a/app/main.py +++ b/app/main.py @@ -11,6 +11,7 @@ from fastapi.responses import FileResponse from app.core.config import get_settings from app.core.database import engine, Base 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 from app.modules.chat_room import router as chat_room_router from app.modules.chat_room.services.template_service import template_service @@ -43,12 +44,12 @@ app.add_middleware( allow_headers=["*"], ) -# Authentication middleware (applies to all routes except login/logout) -# Note: Commented out for now to allow testing without auth -# app.middleware("http")(auth_middleware) +# Authentication middleware (applies to all /api routes except login/logout) +app.middleware("http")(auth_middleware) # Include routers app.include_router(auth_router) +app.include_router(users_router) app.include_router(chat_room_router) app.include_router(realtime_router) app.include_router(file_storage_router) @@ -81,21 +82,14 @@ async def startup_event(): logger.warning(f"MinIO connection failed: {e} - file uploads will be unavailable") -@app.get("/") -async def root(): - """Health check endpoint""" - return { - "status": "ok", - "service": "Task Reporter API", - "version": "1.0.0", - "description": "生產線異常即時反應系統", - } - - -@app.get("/health") +@app.get("/api/health") async def health_check(): """Health check for monitoring""" - return {"status": "healthy"} + return { + "status": "healthy", + "service": "Task Reporter API", + "version": "1.0.0", + } # Serve frontend static files (only if build exists) diff --git a/app/modules/auth/middleware.py b/app/modules/auth/middleware.py index 402ddff..df38e96 100644 --- a/app/modules/auth/middleware.py +++ b/app/modules/auth/middleware.py @@ -6,7 +6,8 @@ 3. AD token 自動刷新(5 分鐘內過期時) 4. 重試計數器管理(最多 3 次) """ -from fastapi import Request, HTTPException, status +from fastapi import Request, status +from fastapi.responses import JSONResponse from datetime import datetime, timedelta from app.core.database import SessionLocal from app.core.config import get_settings @@ -25,15 +26,19 @@ class AuthMiddleware: async def __call__(self, request: Request, call_next): """Process request through authentication checks""" - # Skip auth for login/logout endpoints - if request.url.path in ["/api/auth/login", "/api/auth/logout", "/docs", "/openapi.json"]: + # Skip auth for non-API routes (frontend), login/logout, and docs + path = request.url.path + if not path.startswith("/api") or path in ["/api/auth/login", "/api/auth/logout", "/api/health"]: + return await call_next(request) + if path in ["/docs", "/openapi.json", "/redoc"]: return await call_next(request) # Extract token from Authorization header authorization = request.headers.get("Authorization") if not authorization or not authorization.startswith("Bearer "): - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentication required" + return JSONResponse( + status_code=status.HTTP_401_UNAUTHORIZED, + content={"detail": "Authentication required"} ) internal_token = authorization.replace("Bearer ", "") @@ -44,32 +49,35 @@ class AuthMiddleware: # Query session user_session = session_service.get_session_by_token(db, internal_token) if not user_session: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired token" + return JSONResponse( + status_code=status.HTTP_401_UNAUTHORIZED, + content={"detail": "Invalid or expired token"} ) # Check 3-day inactivity timeout inactivity_limit = datetime.utcnow() - timedelta(days=settings.SESSION_INACTIVITY_DAYS) if user_session.last_activity < inactivity_limit: session_service.delete_session(db, user_session.id) - raise HTTPException( + return JSONResponse( status_code=status.HTTP_401_UNAUTHORIZED, - detail="Session expired due to inactivity. Please login again.", + content={"detail": "Session expired due to inactivity. Please login again."} ) # Check if refresh attempts exceeded if user_session.refresh_attempt_count >= settings.MAX_REFRESH_ATTEMPTS: session_service.delete_session(db, user_session.id) - raise HTTPException( + return JSONResponse( status_code=status.HTTP_401_UNAUTHORIZED, - detail="Session expired due to authentication failures. Please login again.", + content={"detail": "Session expired due to authentication failures. Please login again."} ) # Check if AD token needs refresh (< 5 minutes until expiry) time_until_expiry = user_session.ad_token_expires_at - datetime.utcnow() if time_until_expiry < timedelta(minutes=settings.TOKEN_REFRESH_THRESHOLD_MINUTES): # Auto-refresh AD token - await self._refresh_ad_token(db, user_session) + refresh_error = await self._refresh_ad_token(db, user_session) + if refresh_error: + return refresh_error # Update last_activity session_service.update_activity(db, user_session.id) @@ -87,7 +95,11 @@ class AuthMiddleware: return await call_next(request) async def _refresh_ad_token(self, db, user_session): - """Auto-refresh AD token using stored encrypted password""" + """Auto-refresh AD token using stored encrypted password + + Returns: + JSONResponse on error, None on success + """ try: # Decrypt password password = encryption_service.decrypt_password(user_session.encrypted_password) @@ -101,6 +113,7 @@ class AuthMiddleware: ) logger.info(f"AD token refreshed successfully for user: {user_session.username}") + return None # Success except (ValueError, ConnectionError) as e: # Refresh failed, increment counter @@ -117,14 +130,14 @@ class AuthMiddleware: logger.error( f"Session terminated for {user_session.username} after {new_count} failed refresh attempts" ) - raise HTTPException( + return JSONResponse( status_code=status.HTTP_401_UNAUTHORIZED, - detail="Session terminated. Your password may have been changed. Please login again.", + content={"detail": "Session terminated. Your password may have been changed. Please login again."} ) else: - raise HTTPException( + return JSONResponse( status_code=status.HTTP_401_UNAUTHORIZED, - detail="Token refresh failed. Please try again or re-login if issue persists.", + content={"detail": "Token refresh failed. Please try again or re-login if issue persists."} ) diff --git a/app/modules/auth/services/user_service.py b/app/modules/auth/services/user_service.py index 385be5f..647503c 100644 --- a/app/modules/auth/services/user_service.py +++ b/app/modules/auth/services/user_service.py @@ -87,3 +87,31 @@ def get_display_name(db: Session, user_id: str) -> str: if user: return user.display_name return user_id # Fallback to email if user not in database + + +def search_users(db: Session, query: str, limit: int = 20) -> list[User]: + """Search users by display_name or user_id (email) + + Args: + db: Database session + query: Search query string + limit: Maximum number of results (default 20) + + Returns: + List of matching users + """ + from sqlalchemy import or_ + + search_pattern = f"%{query}%" + + return ( + db.query(User) + .filter( + or_( + User.display_name.ilike(search_pattern), + User.user_id.ilike(search_pattern) + ) + ) + .limit(limit) + .all() + ) diff --git a/app/modules/auth/users_router.py b/app/modules/auth/users_router.py new file mode 100644 index 0000000..e99c989 --- /dev/null +++ b/app/modules/auth/users_router.py @@ -0,0 +1,52 @@ +"""User management API endpoints + +Provides: +- GET /api/users/search - Search users by name or email +""" +from fastapi import APIRouter, Depends, HTTPException, status, Query +from sqlalchemy.orm import Session +from typing import List +from pydantic import BaseModel + +from app.core.database import get_db +from app.modules.auth import get_current_user +from app.modules.auth.services.user_service import search_users + +router = APIRouter(prefix="/api/users", tags=["Users"]) + + +class UserSearchResult(BaseModel): + """User search result""" + user_id: str + display_name: str + + class Config: + from_attributes = True + + +@router.get("/search", response_model=List[UserSearchResult]) +async def search_users_endpoint( + q: str = Query(..., min_length=1, description="Search query (name or email)"), + db: Session = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """Search users by display_name or email + + Returns up to 20 matching users. Requires authentication. + Search is case-insensitive and matches partial strings. + """ + if not q or len(q.strip()) == 0: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Search query required" + ) + + users = search_users(db, q.strip(), limit=20) + + return [ + UserSearchResult( + user_id=user.user_id, + display_name=user.display_name + ) + for user in users + ] diff --git a/app/modules/chat_room/router.py b/app/modules/chat_room/router.py index f102da3..66ae68d 100644 --- a/app/modules/chat_room/router.py +++ b/app/modules/chat_room/router.py @@ -5,6 +5,7 @@ FastAPI router with all room-related endpoints from fastapi import APIRouter, Depends, HTTPException, status, Query from sqlalchemy.orm import Session from typing import List, Optional +from datetime import datetime from app.core.database import get_db from app.modules.auth import get_current_user @@ -20,6 +21,7 @@ from app.modules.chat_room.dependencies import ( require_admin, get_user_effective_role ) +from app.modules.realtime.websocket_manager import manager as ws_manager router = APIRouter(prefix="/api/rooms", tags=["Chat Rooms"]) @@ -59,7 +61,8 @@ async def create_room( return schemas.RoomResponse( **room.__dict__, - current_user_role=role + current_user_role=role, + is_member=True # Creator is always a member ) @@ -69,13 +72,18 @@ async def list_rooms( incident_type: Optional[schemas.IncidentType] = None, severity: Optional[schemas.SeverityLevel] = None, search: Optional[str] = None, - all: bool = Query(False, description="Admin only: show all rooms"), + my_rooms: bool = Query(False, description="Filter to show only rooms where user is a member"), limit: int = Query(20, ge=1, le=100), offset: int = Query(0, ge=0), db: Session = Depends(get_db), current_user: dict = Depends(get_current_user) ): - """List rooms accessible to current user""" + """List all rooms for authenticated user + + Returns all rooms by default. Use my_rooms=true to filter to only rooms + where the current user is a member. Each room includes is_member and + current_user_role fields. + """ user_email = current_user["username"] is_admin = membership_service.is_system_admin(user_email) @@ -85,21 +93,23 @@ async def list_rooms( incident_type=incident_type, severity=severity, search=search, - all=all, + my_rooms=my_rooms, limit=limit, offset=offset ) rooms, total = room_service.list_user_rooms(db, user_email, filters, is_admin) - # Add user role to each room + # Add user role and membership status to each room room_responses = [] for room in rooms: role = membership_service.get_user_role_in_room(db, room.room_id, user_email) + is_member = role is not None room_response = schemas.RoomResponse( **room.__dict__, current_user_role=role, - is_admin_view=is_admin and all + is_member=is_member, + is_admin_view=is_admin ) room_responses.append(room_response) @@ -125,11 +135,13 @@ async def get_room_details( member_responses = [schemas.MemberResponse.from_orm(m) for m in members] is_admin = membership_service.is_system_admin(current_user["username"]) + is_member = role is not None return schemas.RoomResponse( **room.__dict__, members=member_responses, current_user_role=role, + is_member=is_member, is_admin_view=is_admin ) @@ -152,7 +164,7 @@ async def update_room( ) role = membership_service.get_user_role_in_room(db, room_id, current_user["username"]) - return schemas.RoomResponse(**room.__dict__, current_user_role=role) + return schemas.RoomResponse(**room.__dict__, current_user_role=role, is_member=True) except ValueError as e: raise HTTPException( @@ -178,6 +190,88 @@ async def delete_room( return schemas.SuccessResponse(message="Room archived successfully") +@router.delete("/{room_id}/permanent", response_model=schemas.SuccessResponse) +async def permanent_delete_room( + room_id: str, + _: None = Depends(require_admin), + db: Session = Depends(get_db) +): + """Permanently delete a room and all associated data (admin only) + + This is an irreversible operation that deletes: + - All room members + - All messages and reactions + - All uploaded files (including MinIO storage) + - All generated reports (including MinIO storage) + - The room itself + + Only system administrators can perform this operation. + """ + # Broadcast room_deleted event to all connected users BEFORE deleting + await ws_manager.broadcast_to_room(room_id, { + "type": "system", + "event": "room_deleted", + "room_id": room_id, + "timestamp": datetime.utcnow().isoformat() + }) + + success, error = room_service.permanent_delete_room(db, room_id) + + if not success: + if error == "Room not found": + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Room not found" + ) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to delete room: {error}" + ) + + return schemas.SuccessResponse(message="Room permanently deleted") + + +# Self-Join Endpoint +@router.post("/{room_id}/join", response_model=schemas.MemberResponse) +async def join_room( + room_id: str, + db: Session = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """Self-join a room as a viewer + + Any authenticated user can join any non-archived room. + User will be added with VIEWER role. + """ + user_email = current_user["username"] + + member, error_code, existing = membership_service.self_join_room( + db, room_id, user_email + ) + + if error_code == "room_not_found": + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Room not found" + ) + elif error_code == "room_archived": + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Cannot join archived room" + ) + elif error_code == "already_member": + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail={ + "message": "Already a member of this room", + "current_role": existing.role.value, + "added_at": existing.added_at.isoformat() + } + ) + + return schemas.MemberResponse.from_orm(member) + + # Membership Endpoints @router.get("/{room_id}/members", response_model=List[schemas.MemberResponse]) async def list_room_members( @@ -224,10 +318,37 @@ async def update_member_role( room_id: str, user_id: str, request: schemas.UpdateMemberRoleRequest, - _: None = Depends(validate_room_owner), - db: Session = Depends(get_db) + db: Session = Depends(get_db), + current_user: dict = Depends(get_current_user) ): - """Update a member's role""" + """Update a member's role + + Permission rules: + - OWNER can change any role + - EDITOR can upgrade VIEWER → EDITOR only + - EDITOR cannot downgrade, remove, or set OWNER role + """ + changer_id = current_user["username"] + + # Get target member's current role + current_role = membership_service.get_user_role_in_room(db, room_id, user_id) + if not current_role: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Member not found" + ) + + # Check permission + allowed, error_msg = membership_service.can_change_member_role( + db, room_id, changer_id, user_id, current_role, request.role + ) + + if not allowed: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=error_msg + ) + member = membership_service.update_member_role( db, room_id, @@ -251,23 +372,28 @@ async def update_member_role( async def remove_member( room_id: str, user_id: str, - _: None = Depends(require_room_permission("manage_members")), db: Session = Depends(get_db), current_user: dict = Depends(get_current_user) ): - """Remove a member from the room""" - # Prevent removing the last owner - if user_id == current_user["username"]: - role = membership_service.get_user_role_in_room(db, room_id, user_id) - if role == MemberRole.OWNER: - # Check if there are other owners - members = membership_service.get_room_members(db, room_id) - owner_count = sum(1 for m in members if m.role == MemberRole.OWNER) - if owner_count == 1: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Cannot remove the last owner" - ) + """Remove a member from the room + + Permission rules: + - OWNER can remove any member + - EDITOR cannot remove members (only owner can) + - Users can remove themselves (leave room) unless they're the only owner + """ + remover_id = current_user["username"] + + # Check permission + allowed, error_msg = membership_service.can_remove_member( + db, room_id, remover_id, user_id + ) + + if not allowed: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=error_msg + ) success = membership_service.remove_member(db, room_id, user_id) if not success: diff --git a/app/modules/chat_room/schemas.py b/app/modules/chat_room/schemas.py index 6b8364d..f6a774f 100644 --- a/app/modules/chat_room/schemas.py +++ b/app/modules/chat_room/schemas.py @@ -83,7 +83,7 @@ class RoomFilterParams(BaseModel): created_after: Optional[datetime] = None created_before: Optional[datetime] = None search: Optional[str] = Field(None, description="Search in title and description") - all: Optional[bool] = Field(False, description="Admin: show all rooms") + my_rooms: Optional[bool] = Field(False, description="Filter to show only rooms where user is a member") limit: int = Field(20, ge=1, le=100) offset: int = Field(0, ge=0) @@ -122,6 +122,7 @@ class RoomResponse(BaseModel): member_count: int members: Optional[List[MemberResponse]] = None current_user_role: Optional[MemberRole] = None + is_member: bool = False is_admin_view: bool = False class Config: diff --git a/app/modules/chat_room/services/membership_service.py b/app/modules/chat_room/services/membership_service.py index 6a9d413..fa886ea 100644 --- a/app/modules/chat_room/services/membership_service.py +++ b/app/modules/chat_room/services/membership_service.py @@ -7,7 +7,7 @@ from sqlalchemy import and_ from typing import List, Optional from datetime import datetime -from app.modules.chat_room.models import RoomMember, IncidentRoom, MemberRole +from app.modules.chat_room.models import RoomMember, IncidentRoom, MemberRole, RoomStatus class MembershipService: @@ -16,6 +16,73 @@ class MembershipService: # System admin email (hardcoded as per requirement) SYSTEM_ADMIN_EMAIL = "ymirliu@panjit.com.tw" + def self_join_room( + self, + db: Session, + room_id: str, + user_id: str + ) -> tuple[Optional[RoomMember], str, Optional[RoomMember]]: + """Self-join a room as a viewer + + Allows any authenticated user to join a room without invitation. + User joins as VIEWER role. + + Args: + db: Database session + room_id: Room ID + user_id: User joining + + Returns: + Tuple of (member, error_code, existing_member) + - On success: (member, "", None) + - If already member: (None, "already_member", existing_member) + - If room archived: (None, "room_archived", None) + - If room not found: (None, "room_not_found", None) + """ + # Check if room exists + room = db.query(IncidentRoom).filter( + IncidentRoom.room_id == room_id + ).first() + + if not room: + return None, "room_not_found", None + + # Check if room is archived + if room.status == RoomStatus.ARCHIVED: + return None, "room_archived", None + + # Check if already a member + existing = db.query(RoomMember).filter( + and_( + RoomMember.room_id == room_id, + RoomMember.user_id == user_id, + RoomMember.removed_at.is_(None) + ) + ).first() + + if existing: + return None, "already_member", existing + + # Create membership as viewer + member = RoomMember( + room_id=room_id, + user_id=user_id, + role=MemberRole.VIEWER, + added_by=user_id, # Self-added + added_at=datetime.utcnow() + ) + db.add(member) + + # Update member count + self._update_member_count(db, room_id) + + # Update room activity + room.last_activity_at = datetime.utcnow() + + db.commit() + db.refresh(member) + return member, "", None + def add_member( self, db: Session, @@ -101,6 +168,120 @@ class MembershipService: db.commit() return True + def can_change_member_role( + self, + db: Session, + room_id: str, + changer_id: str, + target_id: str, + current_role: MemberRole, + new_role: MemberRole + ) -> tuple[bool, str]: + """Check if a user can change another member's role + + Permission rules: + - OWNER can change any role + - EDITOR can upgrade VIEWER → EDITOR only + - EDITOR cannot downgrade, remove, or set OWNER role + - VIEWER cannot change roles + + Args: + db: Database session + room_id: Room ID + changer_id: User attempting the change + target_id: Target user + current_role: Target's current role + new_role: Requested new role + + Returns: + Tuple of (allowed, error_message) + """ + # System admin can do anything + if self.is_system_admin(changer_id): + return True, "" + + changer_role = self.get_user_role_in_room(db, room_id, changer_id) + + if not changer_role: + return False, "Not a member of this room" + + # Owner can change any role + if changer_role == MemberRole.OWNER: + return True, "" + + # Editor permissions + if changer_role == MemberRole.EDITOR: + # Cannot set owner role + if new_role == MemberRole.OWNER: + return False, "Only owner can transfer ownership" + + # Can only upgrade viewer to editor + if current_role == MemberRole.VIEWER and new_role == MemberRole.EDITOR: + return True, "" + + # Cannot downgrade + if current_role == MemberRole.EDITOR and new_role == MemberRole.VIEWER: + return False, "Editors can only upgrade members" + + return False, "Editors can only upgrade viewers to editor" + + # Viewer cannot change roles + return False, "Insufficient permissions" + + def can_remove_member( + self, + db: Session, + room_id: str, + remover_id: str, + target_id: str + ) -> tuple[bool, str]: + """Check if a user can remove another member + + Permission rules: + - OWNER can remove any member + - EDITOR cannot remove members + - VIEWER cannot remove members + - Users can remove themselves (leave room) + + Args: + db: Database session + room_id: Room ID + remover_id: User attempting the removal + target_id: Target user + + Returns: + Tuple of (allowed, error_message) + """ + # System admin can do anything + if self.is_system_admin(remover_id): + return True, "" + + remover_role = self.get_user_role_in_room(db, room_id, remover_id) + + if not remover_role: + return False, "Not a member of this room" + + # User can leave the room (remove themselves) + if remover_id == target_id: + # But owner cannot leave if they're the only owner + if remover_role == MemberRole.OWNER: + members = self.get_room_members(db, room_id) + owner_count = sum(1 for m in members if m.role == MemberRole.OWNER) + if owner_count == 1: + return False, "Cannot leave: you are the only owner" + return True, "" + + # Owner can remove any member + if remover_role == MemberRole.OWNER: + return True, "" + + # Editor cannot remove members + if remover_role == MemberRole.EDITOR: + return False, "Only owner can remove members" + + # Viewer cannot remove members + return False, "Insufficient permissions" + def update_member_role( self, db: Session, diff --git a/app/modules/chat_room/services/room_service.py b/app/modules/chat_room/services/room_service.py index 613bd82..cf3eea0 100644 --- a/app/modules/chat_room/services/room_service.py +++ b/app/modules/chat_room/services/room_service.py @@ -4,13 +4,16 @@ Handles business logic for room CRUD operations """ from sqlalchemy.orm import Session from sqlalchemy import or_, and_, func -from typing import List, Optional, Dict +from typing import List, Optional, Dict, Tuple from datetime import datetime import uuid +import logging from app.modules.chat_room.models import IncidentRoom, RoomMember, RoomStatus, MemberRole from app.modules.chat_room.schemas import CreateRoomRequest, UpdateRoomRequest, RoomFilterParams +logger = logging.getLogger(__name__) + class RoomService: """Service for room management operations""" @@ -111,6 +114,10 @@ class RoomService: ) -> List[IncidentRoom]: """List rooms accessible to user with filters + All authenticated users can see all rooms by default. + Use my_rooms=true to filter to only rooms where user is a member. + Non-admin users cannot see ARCHIVED rooms. + Args: db: Database session user_id: User ID @@ -118,13 +125,16 @@ class RoomService: is_admin: Whether user is system admin Returns: - List of accessible rooms + List of rooms and total count """ + # Non-admin requesting archived rooms explicitly - return empty + if not is_admin and filters.status == RoomStatus.ARCHIVED: + return [], 0 + query = db.query(IncidentRoom) - # Access control: admin sees all, others see only their rooms - if not is_admin or not filters.all: - # Join with room_members to filter by membership + # Filter to user's rooms only if my_rooms=true + if filters.my_rooms: query = query.join(RoomMember).filter( and_( RoomMember.user_id == user_id, @@ -132,6 +142,10 @@ class RoomService: ) ) + # Hide archived rooms from non-admin users + if not is_admin: + query = query.filter(IncidentRoom.status != RoomStatus.ARCHIVED) + # Apply filters if filters.status: query = query.filter(IncidentRoom.status == filters.status) @@ -381,6 +395,86 @@ class RoomService: room.last_activity_at = datetime.utcnow() db.commit() + def permanent_delete_room( + self, + db: Session, + room_id: str + ) -> Tuple[bool, Optional[str]]: + """Permanently delete a room and all associated data (admin only) + + This is an irreversible operation that: + 1. Deletes all files from MinIO storage + 2. Deletes all report documents from MinIO storage + 3. Cascades delete to all related database records + + Args: + db: Database session + room_id: Room ID to permanently delete + + Returns: + Tuple of (success, error_message) + """ + # Late imports to avoid circular dependency + from app.modules.file_storage.models import RoomFile + from app.modules.report_generation.models import GeneratedReport + from app.modules.file_storage.services import minio_service + from app.core.config import get_settings + + settings = get_settings() + + # Check room exists + room = db.query(IncidentRoom).filter( + IncidentRoom.room_id == room_id + ).first() + + if not room: + return False, "Room not found" + + try: + # Step 1: Delete room files from MinIO + room_files = db.query(RoomFile).filter( + RoomFile.room_id == room_id + ).all() + + for rf in room_files: + if rf.minio_object_path: + success = minio_service.delete_file( + rf.minio_bucket or settings.MINIO_BUCKET, + rf.minio_object_path + ) + if not success: + logger.warning( + f"Failed to delete MinIO file: {rf.minio_object_path}" + ) + + # Step 2: Delete generated report documents from MinIO + reports = db.query(GeneratedReport).filter( + GeneratedReport.room_id == room_id + ).all() + + for report in reports: + if report.docx_storage_path: + success = minio_service.delete_file( + settings.MINIO_BUCKET, + report.docx_storage_path + ) + if not success: + logger.warning( + f"Failed to delete report file: {report.docx_storage_path}" + ) + + # Step 3: Delete room from database (CASCADE handles related tables) + db.delete(room) + db.commit() + + logger.info(f"Permanently deleted room {room_id} and all associated data") + return True, None + + except Exception as e: + db.rollback() + logger.error(f"Failed to permanently delete room {room_id}: {e}") + return False, str(e) + # Create singleton instance room_service = RoomService() \ No newline at end of file diff --git a/app/modules/file_storage/models.py b/app/modules/file_storage/models.py index 9334dfb..d67aa70 100644 --- a/app/modules/file_storage/models.py +++ b/app/modules/file_storage/models.py @@ -13,8 +13,8 @@ class RoomFile(Base): # Primary key file_id = Column(String(36), primary_key=True) - # Foreign key to incident room - room_id = Column(String(36), ForeignKey("incident_rooms.room_id"), nullable=False) + # Foreign key to incident room (CASCADE delete when room is permanently deleted) + room_id = Column(String(36), ForeignKey("incident_rooms.room_id", ondelete="CASCADE"), nullable=False) # File metadata uploader_id = Column(String(255), nullable=False) diff --git a/app/modules/realtime/router.py b/app/modules/realtime/router.py index e387bf5..6a57c14 100644 --- a/app/modules/realtime/router.py +++ b/app/modules/realtime/router.py @@ -8,8 +8,9 @@ import json from app.core.database import get_db from app.modules.auth.dependencies import get_current_user +from app.modules.auth.services.session_service import session_service from app.modules.chat_room.models import RoomMember, MemberRole -from app.modules.realtime.websocket_manager import manager +from app.modules.realtime.websocket_manager import manager, json_serializer from app.modules.realtime.services.message_service import MessageService from app.modules.realtime.schemas import ( WebSocketMessageIn, @@ -34,6 +35,11 @@ router = APIRouter(prefix="/api", tags=["realtime"]) SYSTEM_ADMIN_EMAIL = "ymirliu@panjit.com.tw" +async def ws_send_json(websocket: WebSocket, data: dict): + """Send JSON with custom datetime serializer""" + await websocket.send_text(json.dumps(data, default=json_serializer)) + + def get_user_room_membership(db: Session, room_id: str, user_id: str) -> Optional[RoomMember]: """Check if user is a member of the room""" return db.query(RoomMember).filter( @@ -79,9 +85,17 @@ async def websocket_endpoint( db: Session = next(get_db()) try: - # For now, we'll extract user from cookie or token - # TODO: Implement proper WebSocket token authentication - user_id = token if token else "anonymous@example.com" # Placeholder + # Authenticate token via session lookup + if not token: + await websocket.close(code=4001, reason="Authentication required") + return + + user_session = session_service.get_session_by_token(db, token) + if not user_session: + await websocket.close(code=4001, reason="Invalid or expired token") + return + + user_id = user_session.username # Check room membership membership = get_user_room_membership(db, room_id, user_id) @@ -114,7 +128,7 @@ async def websocket_endpoint( try: ws_message = WebSocketMessageIn(**message_data) except Exception as e: - await websocket.send_json( + await ws_send_json(websocket, ErrorMessage(error=str(e), code="INVALID_MESSAGE").dict() ) continue @@ -123,7 +137,7 @@ async def websocket_endpoint( if ws_message.type == WebSocketMessageType.MESSAGE: # Check write permission if not can_write_message(membership, user_id): - await websocket.send_json( + await ws_send_json(websocket, ErrorMessage( error="Insufficient permissions", code="PERMISSION_DENIED" @@ -142,7 +156,7 @@ async def websocket_endpoint( ) # Send acknowledgment to sender - await websocket.send_json( + await ws_send_json(websocket, MessageAck( message_id=message.message_id, sequence_number=message.sequence_number, @@ -167,7 +181,7 @@ async def websocket_endpoint( elif ws_message.type == WebSocketMessageType.EDIT_MESSAGE: if not ws_message.message_id or not ws_message.content: - await websocket.send_json( + await ws_send_json(websocket, ErrorMessage(error="Missing message_id or content", code="INVALID_REQUEST").dict() ) continue @@ -181,7 +195,7 @@ async def websocket_endpoint( ) if not edited_message: - await websocket.send_json( + await ws_send_json(websocket, ErrorMessage(error="Cannot edit message", code="EDIT_FAILED").dict() ) continue @@ -205,7 +219,7 @@ async def websocket_endpoint( elif ws_message.type == WebSocketMessageType.DELETE_MESSAGE: if not ws_message.message_id: - await websocket.send_json( + await ws_send_json(websocket, ErrorMessage(error="Missing message_id", code="INVALID_REQUEST").dict() ) continue @@ -220,7 +234,7 @@ async def websocket_endpoint( ) if not deleted_message: - await websocket.send_json( + await ws_send_json(websocket, ErrorMessage(error="Cannot delete message", code="DELETE_FAILED").dict() ) continue @@ -233,7 +247,7 @@ async def websocket_endpoint( elif ws_message.type == WebSocketMessageType.ADD_REACTION: if not ws_message.message_id or not ws_message.emoji: - await websocket.send_json( + await ws_send_json(websocket, ErrorMessage(error="Missing message_id or emoji", code="INVALID_REQUEST").dict() ) continue @@ -260,7 +274,7 @@ async def websocket_endpoint( elif ws_message.type == WebSocketMessageType.REMOVE_REACTION: if not ws_message.message_id or not ws_message.emoji: - await websocket.send_json( + await ws_send_json(websocket, ErrorMessage(error="Missing message_id or emoji", code="INVALID_REQUEST").dict() ) continue diff --git a/app/modules/realtime/websocket_manager.py b/app/modules/realtime/websocket_manager.py index c65a02d..6f94825 100644 --- a/app/modules/realtime/websocket_manager.py +++ b/app/modules/realtime/websocket_manager.py @@ -1,12 +1,19 @@ """WebSocket connection pool management""" from fastapi import WebSocket -from typing import Dict, List, Set +from typing import Dict, List, Set, Any from datetime import datetime import asyncio import json from collections import defaultdict +def json_serializer(obj: Any) -> str: + """Custom JSON serializer for objects not serializable by default json code""" + if isinstance(obj, datetime): + return obj.isoformat() + raise TypeError(f"Object of type {type(obj).__name__} is not JSON serializable") + + class ConnectionInfo: """Information about a WebSocket connection""" def __init__(self, websocket: WebSocket, user_id: str, room_id: str): @@ -93,7 +100,7 @@ class WebSocketManager: if room_id not in self._room_connections: return - message_json = json.dumps(message) + message_json = json.dumps(message, default=json_serializer) # Collect disconnected connections disconnected = [] @@ -124,7 +131,7 @@ class WebSocketManager: return conn_info = self._user_connections[user_id] - message_json = json.dumps(message) + message_json = json.dumps(message, default=json_serializer) try: await conn_info.websocket.send_text(message_json) diff --git a/frontend/index.html b/frontend/index.html index 072a57e..e949aad 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -3,8 +3,11 @@
- -