Files
Task_Reporter/app/modules/chat_room/router.py
egg 599802b818 feat: Add Chat UX improvements with notifications and @mention support
- Add ActionBar component with expandable toolbar for mobile
- Add @mention functionality with autocomplete dropdown
- Add browser notification system (push, sound, vibration)
- Add NotificationSettings modal for user preferences
- Add mention badges on room list cards
- Add ReportPreview with Markdown rendering and copy/download
- Add message copy functionality with hover actions
- Add backend mentions field to messages with Alembic migration
- Add lots field to rooms, remove templates
- Optimize WebSocket database session handling
- Various UX polish (animations, accessibility)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-08 08:20:37 +08:00

544 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.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"]
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]
# LOT Endpoints
@router.post("/{room_id}/lots", response_model=List[str])
async def add_lot(
room_id: str,
request: schemas.AddLotRequest,
_: None = Depends(require_room_permission("update_metadata")),
db: Session = Depends(get_db)
):
"""Add a LOT batch number to the room"""
from app.modules.chat_room.models import IncidentRoom
room = db.query(IncidentRoom).filter(IncidentRoom.room_id == room_id).first()
if not room:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Room not found")
# Get current lots or initialize empty list
current_lots = room.lots or []
# Prevent duplicates
if request.lot not in current_lots:
current_lots.append(request.lot)
room.lots = current_lots
room.last_updated_at = datetime.utcnow()
db.commit()
db.refresh(room)
return room.lots
@router.delete("/{room_id}/lots/{lot}", response_model=List[str])
async def remove_lot(
room_id: str,
lot: str,
_: None = Depends(require_room_permission("update_metadata")),
db: Session = Depends(get_db)
):
"""Remove a LOT batch number from the room"""
from app.modules.chat_room.models import IncidentRoom
room = db.query(IncidentRoom).filter(IncidentRoom.room_id == room_id).first()
if not room:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Room not found")
current_lots = room.lots or []
if lot in current_lots:
current_lots.remove(lot)
room.lots = current_lots
room.last_updated_at = datetime.utcnow()
db.commit()
db.refresh(room)
return room.lots