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>
519 lines
16 KiB
Python
519 lines
16 KiB
Python
"""API routes for chat room management
|
|
|
|
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
|
|
from app.modules.chat_room import schemas
|
|
from app.modules.chat_room.models import MemberRole, RoomStatus
|
|
from app.modules.chat_room.services.room_service import room_service
|
|
from app.modules.chat_room.services.membership_service import membership_service
|
|
from app.modules.chat_room.services.template_service import template_service
|
|
from app.modules.chat_room.dependencies import (
|
|
get_current_room,
|
|
require_room_permission,
|
|
validate_room_owner,
|
|
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"])
|
|
|
|
|
|
# Room CRUD Endpoints
|
|
@router.post("", response_model=schemas.RoomResponse, status_code=status.HTTP_201_CREATED)
|
|
async def create_room(
|
|
room_data: schemas.CreateRoomRequest,
|
|
db: Session = Depends(get_db),
|
|
current_user: dict = Depends(get_current_user)
|
|
):
|
|
"""Create a new incident room"""
|
|
user_email = current_user["username"]
|
|
|
|
# Check if using template
|
|
if room_data.template:
|
|
template = template_service.get_template_by_name(db, room_data.template)
|
|
if template:
|
|
room = template_service.create_room_from_template(
|
|
db,
|
|
template.template_id,
|
|
user_email,
|
|
room_data.title,
|
|
room_data.location,
|
|
room_data.description
|
|
)
|
|
else:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"Template '{room_data.template}' not found"
|
|
)
|
|
else:
|
|
room = room_service.create_room(db, user_email, room_data)
|
|
|
|
# Get user role for response
|
|
role = membership_service.get_user_role_in_room(db, room.room_id, user_email)
|
|
|
|
return schemas.RoomResponse(
|
|
**room.__dict__,
|
|
current_user_role=role,
|
|
is_member=True # Creator is always a member
|
|
)
|
|
|
|
|
|
@router.get("", response_model=schemas.RoomListResponse)
|
|
async def list_rooms(
|
|
status: Optional[RoomStatus] = None,
|
|
incident_type: Optional[schemas.IncidentType] = None,
|
|
severity: Optional[schemas.SeverityLevel] = None,
|
|
search: Optional[str] = None,
|
|
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 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)
|
|
|
|
# Create filter params
|
|
filters = schemas.RoomFilterParams(
|
|
status=status,
|
|
incident_type=incident_type,
|
|
severity=severity,
|
|
search=search,
|
|
my_rooms=my_rooms,
|
|
limit=limit,
|
|
offset=offset
|
|
)
|
|
|
|
rooms, total = room_service.list_user_rooms(db, user_email, filters, is_admin)
|
|
|
|
# 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_member=is_member,
|
|
is_admin_view=is_admin
|
|
)
|
|
room_responses.append(room_response)
|
|
|
|
return schemas.RoomListResponse(
|
|
rooms=room_responses,
|
|
total=total,
|
|
limit=limit,
|
|
offset=offset
|
|
)
|
|
|
|
|
|
@router.get("/{room_id}", response_model=schemas.RoomResponse)
|
|
async def get_room_details(
|
|
room_id: str,
|
|
room = Depends(get_current_room),
|
|
role: Optional[MemberRole] = Depends(get_user_effective_role),
|
|
db: Session = Depends(get_db),
|
|
current_user: dict = Depends(get_current_user)
|
|
):
|
|
"""Get room details including members"""
|
|
# Load members
|
|
members = membership_service.get_room_members(db, room.room_id)
|
|
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
|
|
)
|
|
|
|
|
|
@router.patch("/{room_id}", response_model=schemas.RoomResponse)
|
|
async def update_room(
|
|
room_id: str,
|
|
updates: schemas.UpdateRoomRequest,
|
|
_: None = Depends(require_room_permission("update_metadata")),
|
|
db: Session = Depends(get_db),
|
|
current_user: dict = Depends(get_current_user)
|
|
):
|
|
"""Update room metadata"""
|
|
try:
|
|
room = room_service.update_room(db, room_id, updates)
|
|
if not room:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="Room not found"
|
|
)
|
|
|
|
role = membership_service.get_user_role_in_room(db, room_id, current_user["username"])
|
|
return schemas.RoomResponse(**room.__dict__, current_user_role=role, is_member=True)
|
|
|
|
except ValueError as e:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=str(e)
|
|
)
|
|
|
|
|
|
@router.delete("/{room_id}", response_model=schemas.SuccessResponse)
|
|
async def delete_room(
|
|
room_id: str,
|
|
_: None = Depends(validate_room_owner),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Soft delete (archive) a room"""
|
|
success = room_service.delete_room(db, room_id)
|
|
if not success:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="Room not found"
|
|
)
|
|
|
|
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(
|
|
room_id: str,
|
|
_ = Depends(get_current_room),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""List all members of a room"""
|
|
members = membership_service.get_room_members(db, room_id)
|
|
return [schemas.MemberResponse.from_orm(m) for m in members]
|
|
|
|
|
|
@router.post("/{room_id}/members", response_model=schemas.MemberResponse)
|
|
async def add_member(
|
|
room_id: str,
|
|
request: schemas.AddMemberRequest,
|
|
_: None = Depends(require_room_permission("manage_members")),
|
|
db: Session = Depends(get_db),
|
|
current_user: dict = Depends(get_current_user)
|
|
):
|
|
"""Add a member to the room"""
|
|
member = membership_service.add_member(
|
|
db,
|
|
room_id,
|
|
request.user_id,
|
|
request.role,
|
|
current_user["username"]
|
|
)
|
|
|
|
if not member:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="User is already a member"
|
|
)
|
|
|
|
# Update room activity
|
|
room_service.update_room_activity(db, room_id)
|
|
|
|
return schemas.MemberResponse.from_orm(member)
|
|
|
|
|
|
@router.patch("/{room_id}/members/{user_id}", response_model=schemas.MemberResponse)
|
|
async def update_member_role(
|
|
room_id: str,
|
|
user_id: str,
|
|
request: schemas.UpdateMemberRoleRequest,
|
|
db: Session = Depends(get_db),
|
|
current_user: dict = Depends(get_current_user)
|
|
):
|
|
"""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,
|
|
user_id,
|
|
request.role
|
|
)
|
|
|
|
if not member:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="Member not found"
|
|
)
|
|
|
|
# Update room activity
|
|
room_service.update_room_activity(db, room_id)
|
|
|
|
return schemas.MemberResponse.from_orm(member)
|
|
|
|
|
|
@router.delete("/{room_id}/members/{user_id}", response_model=schemas.SuccessResponse)
|
|
async def remove_member(
|
|
room_id: str,
|
|
user_id: str,
|
|
db: Session = Depends(get_db),
|
|
current_user: dict = Depends(get_current_user)
|
|
):
|
|
"""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:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="Member not found"
|
|
)
|
|
|
|
# Update room activity
|
|
room_service.update_room_activity(db, room_id)
|
|
|
|
return schemas.SuccessResponse(message="Member removed successfully")
|
|
|
|
|
|
@router.post("/{room_id}/transfer-ownership", response_model=schemas.SuccessResponse)
|
|
async def transfer_ownership(
|
|
room_id: str,
|
|
request: schemas.TransferOwnershipRequest,
|
|
_: None = Depends(validate_room_owner),
|
|
db: Session = Depends(get_db),
|
|
current_user: dict = Depends(get_current_user)
|
|
):
|
|
"""Transfer room ownership to another member"""
|
|
success = membership_service.transfer_ownership(
|
|
db,
|
|
room_id,
|
|
current_user["username"],
|
|
request.new_owner_id
|
|
)
|
|
|
|
if not success:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="New owner must be an existing room member"
|
|
)
|
|
|
|
return schemas.SuccessResponse(message="Ownership transferred successfully")
|
|
|
|
|
|
# Permission Endpoints
|
|
@router.get("/{room_id}/permissions", response_model=schemas.PermissionResponse)
|
|
async def get_user_permissions(
|
|
room_id: str,
|
|
role: Optional[MemberRole] = Depends(get_user_effective_role),
|
|
db: Session = Depends(get_db),
|
|
current_user: dict = Depends(get_current_user)
|
|
):
|
|
"""Get current user's permissions in the room"""
|
|
user_email = current_user["username"]
|
|
is_admin = membership_service.is_system_admin(user_email)
|
|
|
|
if is_admin:
|
|
# Admin has all permissions
|
|
return schemas.PermissionResponse(
|
|
role=role or MemberRole.OWNER,
|
|
is_admin=True,
|
|
can_read=True,
|
|
can_write=True,
|
|
can_manage_members=True,
|
|
can_transfer_ownership=True,
|
|
can_update_status=True,
|
|
can_delete=True
|
|
)
|
|
|
|
if not role:
|
|
# Not a member
|
|
return schemas.PermissionResponse(
|
|
role=None,
|
|
is_admin=False,
|
|
can_read=False,
|
|
can_write=False,
|
|
can_manage_members=False,
|
|
can_transfer_ownership=False,
|
|
can_update_status=False,
|
|
can_delete=False
|
|
)
|
|
|
|
# Return permissions based on role
|
|
permissions = {
|
|
MemberRole.OWNER: schemas.PermissionResponse(
|
|
role=role,
|
|
is_admin=False,
|
|
can_read=True,
|
|
can_write=True,
|
|
can_manage_members=True,
|
|
can_transfer_ownership=True,
|
|
can_update_status=True,
|
|
can_delete=True
|
|
),
|
|
MemberRole.EDITOR: schemas.PermissionResponse(
|
|
role=role,
|
|
is_admin=False,
|
|
can_read=True,
|
|
can_write=True,
|
|
can_manage_members=False,
|
|
can_transfer_ownership=False,
|
|
can_update_status=False,
|
|
can_delete=False
|
|
),
|
|
MemberRole.VIEWER: schemas.PermissionResponse(
|
|
role=role,
|
|
is_admin=False,
|
|
can_read=True,
|
|
can_write=False,
|
|
can_manage_members=False,
|
|
can_transfer_ownership=False,
|
|
can_update_status=False,
|
|
can_delete=False
|
|
)
|
|
}
|
|
|
|
return permissions[role]
|
|
|
|
|
|
# Template Endpoints
|
|
@router.get("/templates", response_model=List[schemas.TemplateResponse])
|
|
async def list_templates(
|
|
db: Session = Depends(get_db),
|
|
_: dict = Depends(get_current_user)
|
|
):
|
|
"""List available room templates"""
|
|
templates = template_service.get_templates(db)
|
|
return [schemas.TemplateResponse.from_orm(t) for t in templates] |