feat: Add mobile responsive layout, open room access, and admin room management

Mobile Responsive Layout:
- Add useMediaQuery, useIsMobile, useIsTablet, useIsDesktop hooks for device detection
- Create MobileHeader component with hamburger menu and action drawer
- Create BottomToolbar for mobile navigation (Files, Members)
- Create SlidePanel component for full-screen mobile sidebars
- Update RoomDetail.tsx with mobile/desktop conditional rendering
- Update RoomList.tsx with single-column grid and touch-friendly buttons
- Add CSS custom properties for safe areas and touch targets (min 44px)
- Add mobile viewport meta tags for notched devices

Open Room Access:
- All authenticated users can view all rooms (not just their own)
- Users can join active rooms they're not members of
- Add is_member field to room responses
- Update room list API to return all rooms by default

Admin Room Management:
- Add permanent delete functionality for system admins
- Add delete confirmation dialog with room title verification
- Broadcast room deletion via WebSocket to connected users
- Add users search API for adding members

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
egg
2025-12-05 09:12:10 +08:00
parent 1e44a63a8e
commit 1d5d4d447d
48 changed files with 3505 additions and 401 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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