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:
26
app/main.py
26
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)
|
||||
|
||||
@@ -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."}
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
|
||||
52
app/modules/auth/users_router.py
Normal file
52
app/modules/auth/users_router.py
Normal 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
|
||||
]
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user