feat: Initial commit - Task Reporter incident response system

Complete implementation of the production line incident response system (生產線異常即時反應系統) including:

Backend (FastAPI):
- User authentication with AD integration and session management
- Chat room management (create, list, update, members, roles)
- Real-time messaging via WebSocket (typing indicators, reactions)
- File storage with MinIO (upload, download, image preview)

Frontend (React + Vite):
- Authentication flow with token management
- Room list with filtering, search, and pagination
- Real-time chat interface with WebSocket
- File upload with drag-and-drop and image preview
- Member management and room settings
- Breadcrumb navigation
- 53 unit tests (Vitest)

Specifications:
- authentication: AD auth, sessions, JWT tokens
- chat-room: rooms, members, templates
- realtime-messaging: WebSocket, messages, reactions
- file-storage: MinIO integration, file management
- frontend-core: React SPA structure

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
egg
2025-12-01 17:42:52 +08:00
commit c8966477b9
135 changed files with 23269 additions and 0 deletions

1
app/modules/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Application modules"""

View File

@@ -0,0 +1,21 @@
"""Authentication module - Reusable authentication system with AD API integration
This module provides:
- Dual-token session management (internal + AD tokens)
- Automatic AD token refresh with retry limit (max 3 attempts)
- 3-day inactivity timeout
- Encrypted password storage for auto-refresh
- FastAPI dependency injection for protected routes
Usage in other modules:
from app.modules.auth import get_current_user
@router.get("/protected-endpoint")
async def my_endpoint(current_user: dict = Depends(get_current_user)):
# current_user contains: {"username": "...", "display_name": "..."}
return {"user": current_user["display_name"]}
"""
from app.modules.auth.router import router
from app.modules.auth.dependencies import get_current_user
__all__ = ["router", "get_current_user"]

View File

@@ -0,0 +1,31 @@
"""FastAPI dependencies for authentication
供其他模組引用的 dependency injection 函數
"""
from fastapi import Request, HTTPException, status
async def get_current_user(request: Request) -> dict:
"""Get current authenticated user from request state
Usage in other modules:
from app.modules.auth import get_current_user
@router.get("/my-endpoint")
async def my_endpoint(current_user: dict = Depends(get_current_user)):
username = current_user["username"]
display_name = current_user["display_name"]
...
Returns:
dict: {"id": int, "username": str, "display_name": str}
Raises:
HTTPException: If user not authenticated (middleware should prevent this)
"""
if not hasattr(request.state, "user"):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentication required"
)
return request.state.user

View File

@@ -0,0 +1,131 @@
"""Authentication middleware for protected routes
自動處理:
1. Token 驗證
2. 3 天不活動逾時檢查
3. AD token 自動刷新5 分鐘內過期時)
4. 重試計數器管理(最多 3 次)
"""
from fastapi import Request, HTTPException, status
from datetime import datetime, timedelta
from app.core.database import SessionLocal
from app.core.config import get_settings
from app.modules.auth.services.session_service import session_service
from app.modules.auth.services.encryption import encryption_service
from app.modules.auth.services.ad_client import ad_auth_service
import logging
settings = get_settings()
logger = logging.getLogger(__name__)
class AuthMiddleware:
"""Authentication middleware"""
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"]:
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"
)
internal_token = authorization.replace("Bearer ", "")
# Get database session
db = SessionLocal()
try:
# 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"
)
# 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(
status_code=status.HTTP_401_UNAUTHORIZED,
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(
status_code=status.HTTP_401_UNAUTHORIZED,
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)
# Update last_activity
session_service.update_activity(db, user_session.id)
# Attach user info to request state
request.state.user = {
"id": user_session.id,
"username": user_session.username,
"display_name": user_session.display_name,
}
finally:
db.close()
return await call_next(request)
async def _refresh_ad_token(self, db, user_session):
"""Auto-refresh AD token using stored encrypted password"""
try:
# Decrypt password
password = encryption_service.decrypt_password(user_session.encrypted_password)
# Re-authenticate with AD API
ad_result = await ad_auth_service.authenticate(user_session.username, password)
# Update session with new token
session_service.update_ad_token(
db, user_session.id, ad_result["token"], ad_result["expires_at"]
)
logger.info(f"AD token refreshed successfully for user: {user_session.username}")
except (ValueError, ConnectionError) as e:
# Refresh failed, increment counter
new_count = session_service.increment_refresh_attempts(db, user_session.id)
logger.warning(
f"AD token refresh failed for user {user_session.username}. "
f"Attempt {new_count}/{settings.MAX_REFRESH_ATTEMPTS}"
)
# If reached max attempts, delete session
if new_count >= settings.MAX_REFRESH_ATTEMPTS:
session_service.delete_session(db, user_session.id)
logger.error(
f"Session terminated for {user_session.username} after {new_count} failed refresh attempts"
)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Session terminated. Your password may have been changed. Please login again.",
)
else:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token refresh failed. Please try again or re-login if issue persists.",
)
auth_middleware = AuthMiddleware()

View File

@@ -0,0 +1,31 @@
"""SQLAlchemy models for authentication
資料表結構:
- user_sessions: 儲存使用者 session 資料,包含加密密碼用於自動刷新
"""
from sqlalchemy import Column, Integer, String, DateTime, Index
from datetime import datetime
from app.core.database import Base
class UserSession(Base):
"""User session model with encrypted password for auto-refresh"""
__tablename__ = "user_sessions"
id = Column(Integer, primary_key=True, index=True)
username = Column(String(255), nullable=False, comment="User email from AD")
display_name = Column(String(255), nullable=False, comment="Display name for chat")
internal_token = Column(
String(255), unique=True, nullable=False, index=True, comment="Internal session token (UUID)"
)
ad_token = Column(String(500), nullable=False, comment="AD API token")
encrypted_password = Column(String(500), nullable=False, comment="AES-256 encrypted password")
ad_token_expires_at = Column(DateTime, nullable=False, comment="AD token expiry time")
refresh_attempt_count = Column(
Integer, default=0, nullable=False, comment="Failed refresh attempts counter"
)
last_activity = Column(
DateTime, default=datetime.utcnow, nullable=False, comment="Last API request time"
)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)

View File

@@ -0,0 +1,95 @@
"""Authentication API endpoints
提供:
- POST /api/auth/login - 使用者登入
- POST /api/auth/logout - 使用者登出
"""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.modules.auth.schemas import LoginRequest, LoginResponse, LogoutResponse, ErrorResponse
from app.modules.auth.services.ad_client import ad_auth_service
from app.modules.auth.services.encryption import encryption_service
from app.modules.auth.services.session_service import session_service
from fastapi import Header
from typing import Optional
router = APIRouter(prefix="/api/auth", tags=["Authentication"])
@router.post(
"/login",
response_model=LoginResponse,
responses={
401: {"model": ErrorResponse, "description": "Invalid credentials"},
503: {"model": ErrorResponse, "description": "Authentication service unavailable"},
},
)
async def login(request: LoginRequest, db: Session = Depends(get_db)):
"""使用者登入
流程:
1. 呼叫 AD API 驗證憑證
2. 加密密碼(用於自動刷新)
3. 生成 internal token (UUID)
4. 儲存 session 到資料庫
5. 回傳 internal token 和 display_name
"""
try:
# Step 1: Authenticate with AD API
ad_result = await ad_auth_service.authenticate(request.username, request.password)
except ValueError as e:
# Invalid credentials
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials"
)
except ConnectionError as e:
# AD API unavailable
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Authentication service unavailable",
)
# Step 2: Encrypt password for future auto-refresh
encrypted_password = encryption_service.encrypt_password(request.password)
# Step 3 & 4: Generate internal token and create session
user_session = session_service.create_session(
db=db,
username=request.username,
display_name=ad_result["username"],
ad_token=ad_result["token"],
encrypted_password=encrypted_password,
ad_token_expires_at=ad_result["expires_at"],
)
# Step 5: Return internal token to client
return LoginResponse(token=user_session.internal_token, display_name=user_session.display_name)
@router.post(
"/logout",
response_model=LogoutResponse,
responses={401: {"model": ErrorResponse, "description": "No authentication token provided"}},
)
async def logout(authorization: Optional[str] = Header(None), db: Session = Depends(get_db)):
"""使用者登出
刪除 session 記錄,使 token 失效
"""
if not authorization or not authorization.startswith("Bearer "):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="No authentication token provided"
)
# Extract token
internal_token = authorization.replace("Bearer ", "")
# Find and delete session
user_session = session_service.get_session_by_token(db, internal_token)
if user_session:
session_service.delete_session(db, user_session.id)
return LogoutResponse(message="Logout successful")

View File

@@ -0,0 +1,28 @@
"""Pydantic schemas for authentication API requests/responses"""
from pydantic import BaseModel
class LoginRequest(BaseModel):
"""Login request body"""
username: str # Email address
password: str
class LoginResponse(BaseModel):
"""Login response"""
token: str # Internal session token
display_name: str
class LogoutResponse(BaseModel):
"""Logout response"""
message: str
class ErrorResponse(BaseModel):
"""Error response"""
error: str

View File

@@ -0,0 +1 @@
"""Authentication services"""

View File

@@ -0,0 +1,98 @@
"""AD API client service for authentication
與 Panjit AD API 整合,負責:
- 驗證使用者憑證
- 取得 AD token 和使用者名稱
- 處理 API 連線錯誤
"""
from datetime import datetime, timedelta
import httpx
from typing import Dict
from app.core.config import get_settings
settings = get_settings()
class ADAuthService:
"""Active Directory authentication service"""
def __init__(self):
self.ad_api_url = settings.AD_API_URL
self._client = httpx.AsyncClient(timeout=10.0)
async def authenticate(self, username: str, password: str) -> Dict[str, any]:
"""Authenticate user with AD API
Args:
username: User email (e.g., ymirliu@panjit.com.tw)
password: User password
Returns:
Dict containing:
- token: AD authentication token
- username: Display name from AD
- expires_at: Estimated token expiry datetime
Raises:
httpx.HTTPStatusError: If authentication fails (401, 403)
httpx.RequestError: If AD API is unreachable
"""
payload = {"username": username, "password": password}
try:
response = await self._client.post(
self.ad_api_url, json=payload, headers={"Content-Type": "application/json"}
)
# Raise exception for 4xx/5xx status codes
response.raise_for_status()
data = response.json()
# Extract token and username from response
# Response structure: {"success": true, "data": {"access_token": "...", "userInfo": {"name": "...", "email": "..."}}}
if not data.get("success"):
raise ValueError("Authentication failed")
token_data = data.get("data", {})
ad_token = token_data.get("access_token")
user_info = token_data.get("userInfo", {})
display_name = user_info.get("name") or username
if not ad_token:
raise ValueError("No token received from AD API")
# Parse expiry time from response (expiresAt field)
expires_at_str = token_data.get("expiresAt")
if expires_at_str:
# Parse ISO format: "2025-11-16T14:38:37.912Z"
try:
expires_at = datetime.fromisoformat(expires_at_str.replace("Z", "+00:00"))
except:
expires_at = datetime.utcnow() + timedelta(hours=1)
else:
# Fallback: assume 1 hour if not provided
expires_at = datetime.utcnow() + timedelta(hours=1)
return {"token": ad_token, "username": display_name, "expires_at": expires_at}
except httpx.HTTPStatusError as e:
# Authentication failed (401) or other HTTP errors
if e.response.status_code == 401:
raise ValueError("Invalid credentials") from e
elif e.response.status_code >= 500:
raise ConnectionError("Authentication service error") from e
else:
raise
except httpx.RequestError as e:
# Network error, timeout, etc.
raise ConnectionError("Authentication service unavailable") from e
async def close(self):
"""Close HTTP client"""
await self._client.aclose()
# Singleton instance
ad_auth_service = ADAuthService()

View File

@@ -0,0 +1,47 @@
"""Password encryption service using Fernet (AES-256)
安全性說明:
- 使用 Fernet 對稱加密(基於 AES-256
- 加密金鑰從環境變數 FERNET_KEY 讀取
- 密碼加密後儲存於資料庫,用於自動刷新 AD token
"""
from cryptography.fernet import Fernet
from app.core.config import get_settings
settings = get_settings()
class EncryptionService:
"""Password encryption/decryption service"""
def __init__(self):
"""Initialize with Fernet key from settings"""
self._fernet = Fernet(settings.FERNET_KEY.encode())
def encrypt_password(self, plaintext: str) -> str:
"""Encrypt password for storage
Args:
plaintext: Plain text password
Returns:
Encrypted password as base64 string
"""
encrypted_bytes = self._fernet.encrypt(plaintext.encode())
return encrypted_bytes.decode()
def decrypt_password(self, ciphertext: str) -> str:
"""Decrypt stored password
Args:
ciphertext: Encrypted password (base64 string)
Returns:
Decrypted plain text password
"""
decrypted_bytes = self._fernet.decrypt(ciphertext.encode())
return decrypted_bytes.decode()
# Singleton instance
encryption_service = EncryptionService()

View File

@@ -0,0 +1,144 @@
"""Session management service
處理 user_sessions 資料庫操作:
- 建立/查詢/刪除 session
- 更新活動時間戳
- 管理 refresh 重試計數器
"""
import uuid
from datetime import datetime
from sqlalchemy.orm import Session
from app.modules.auth.models import UserSession
class SessionService:
"""Session management service"""
def create_session(
self,
db: Session,
username: str,
display_name: str,
ad_token: str,
encrypted_password: str,
ad_token_expires_at: datetime,
) -> UserSession:
"""Create new user session
Args:
db: Database session
username: User email from AD
display_name: Display name from AD
ad_token: AD API token
encrypted_password: Encrypted password for auto-refresh
ad_token_expires_at: AD token expiry datetime
Returns:
Created UserSession object
"""
# Generate unique internal token
internal_token = str(uuid.uuid4())
session = UserSession(
username=username,
display_name=display_name,
internal_token=internal_token,
ad_token=ad_token,
encrypted_password=encrypted_password,
ad_token_expires_at=ad_token_expires_at,
refresh_attempt_count=0,
last_activity=datetime.utcnow(),
)
db.add(session)
db.commit()
db.refresh(session)
return session
def get_session_by_token(self, db: Session, internal_token: str) -> UserSession | None:
"""Get session by internal token
Args:
db: Database session
internal_token: Internal session token (UUID)
Returns:
UserSession if found, None otherwise
"""
return db.query(UserSession).filter(UserSession.internal_token == internal_token).first()
def update_activity(self, db: Session, session_id: int) -> None:
"""Update last_activity timestamp
Args:
db: Database session
session_id: Session ID
"""
db.query(UserSession).filter(UserSession.id == session_id).update(
{"last_activity": datetime.utcnow()}
)
db.commit()
def update_ad_token(
self, db: Session, session_id: int, new_ad_token: str, new_expires_at: datetime
) -> None:
"""Update AD token after successful refresh
Args:
db: Database session
session_id: Session ID
new_ad_token: New AD token
new_expires_at: New expiry datetime
"""
db.query(UserSession).filter(UserSession.id == session_id).update(
{
"ad_token": new_ad_token,
"ad_token_expires_at": new_expires_at,
"refresh_attempt_count": 0, # Reset counter on success
}
)
db.commit()
def increment_refresh_attempts(self, db: Session, session_id: int) -> int:
"""Increment refresh attempt counter
Args:
db: Database session
session_id: Session ID
Returns:
New refresh_attempt_count value
"""
session = db.query(UserSession).filter(UserSession.id == session_id).first()
if session:
session.refresh_attempt_count += 1
db.commit()
return session.refresh_attempt_count
return 0
def reset_refresh_attempts(self, db: Session, session_id: int) -> None:
"""Reset refresh attempt counter
Args:
db: Database session
session_id: Session ID
"""
db.query(UserSession).filter(UserSession.id == session_id).update(
{"refresh_attempt_count": 0}
)
db.commit()
def delete_session(self, db: Session, session_id: int) -> None:
"""Delete session
Args:
db: Database session
session_id: Session ID
"""
db.query(UserSession).filter(UserSession.id == session_id).delete()
db.commit()
# Singleton instance
session_service = SessionService()

View File

@@ -0,0 +1,19 @@
"""Chat room management module
Provides functionality for creating and managing incident response chat rooms
"""
from app.modules.chat_room.router import router
from app.modules.chat_room.models import IncidentRoom, RoomMember, RoomTemplate
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
__all__ = [
"router",
"IncidentRoom",
"RoomMember",
"RoomTemplate",
"room_service",
"membership_service",
"template_service"
]

View File

@@ -0,0 +1,164 @@
"""Dependencies for chat room management
FastAPI dependency injection functions for authentication and authorization
"""
from fastapi import Depends, HTTPException, status
from sqlalchemy.orm import Session
from typing import Optional
from app.core.database import get_db
from app.modules.auth import get_current_user
from app.modules.chat_room.models import IncidentRoom, MemberRole
from app.modules.chat_room.services.membership_service import membership_service
from app.modules.chat_room.services.room_service import room_service
def get_current_room(
room_id: str,
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user)
) -> IncidentRoom:
"""Get current room with access validation
Args:
room_id: Room ID from path parameter
db: Database session
current_user: Current authenticated user
Returns:
Room instance
Raises:
HTTPException: 404 if room not found, 403 if no access
"""
user_email = current_user["username"]
is_admin = membership_service.is_system_admin(user_email)
room = room_service.get_room(db, room_id, user_email, is_admin)
if not room:
# Check if room exists at all
room_exists = db.query(IncidentRoom).filter(
IncidentRoom.room_id == room_id
).first()
if not room_exists:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Room not found"
)
else:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not a member of this room"
)
return room
def require_room_permission(permission: str):
"""Create a dependency that requires specific permission in room
Args:
permission: Required permission
Returns:
Dependency function
"""
def permission_checker(
room_id: str,
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""Check if user has required permission in room
Args:
room_id: Room ID from path parameter
db: Database session
current_user: Current authenticated user
Raises:
HTTPException: 403 if insufficient permissions
"""
user_email = current_user["username"]
if not membership_service.check_user_permission(db, room_id, user_email, permission):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Insufficient permissions: {permission} required"
)
return permission_checker
def validate_room_owner(
room_id: str,
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""Validate that current user is room owner (or admin)
Args:
room_id: Room ID from path parameter
db: Database session
current_user: Current authenticated user
Raises:
HTTPException: 403 if not owner or admin
"""
user_email = current_user["username"]
# Check if admin
if membership_service.is_system_admin(user_email):
return
# Check if owner
role = membership_service.get_user_role_in_room(db, room_id, user_email)
if role != MemberRole.OWNER:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only room owner can perform this operation"
)
def require_admin(current_user: dict = Depends(get_current_user)):
"""Require system administrator privileges
Args:
current_user: Current authenticated user
Raises:
HTTPException: 403 if not system admin
"""
user_email = current_user["username"]
if not membership_service.is_system_admin(user_email):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Administrator privileges required"
)
def get_user_effective_role(
room_id: str,
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user)
) -> Optional[MemberRole]:
"""Get user's effective role in room (considers admin override)
Args:
room_id: Room ID from path parameter
db: Database session
current_user: Current authenticated user
Returns:
User's role or None if not a member (admin always gets OWNER role)
"""
user_email = current_user["username"]
# Admin always has owner privileges
if membership_service.is_system_admin(user_email):
return MemberRole.OWNER
return membership_service.get_user_role_in_room(db, room_id, user_email)

View File

@@ -0,0 +1,126 @@
"""SQLAlchemy models for chat room management
Tables:
- incident_rooms: Stores room metadata and configuration
- room_members: User-room associations with roles
- room_templates: Predefined templates for common incident types
"""
from sqlalchemy import Column, Integer, String, Text, DateTime, Enum, ForeignKey, UniqueConstraint, Index
from sqlalchemy.orm import relationship
from datetime import datetime
import enum
import uuid
from app.core.database import Base
class IncidentType(str, enum.Enum):
"""Types of production incidents"""
EQUIPMENT_FAILURE = "equipment_failure"
MATERIAL_SHORTAGE = "material_shortage"
QUALITY_ISSUE = "quality_issue"
OTHER = "other"
class SeverityLevel(str, enum.Enum):
"""Incident severity levels"""
LOW = "low"
MEDIUM = "medium"
HIGH = "high"
CRITICAL = "critical"
class RoomStatus(str, enum.Enum):
"""Room lifecycle status"""
ACTIVE = "active"
RESOLVED = "resolved"
ARCHIVED = "archived"
class MemberRole(str, enum.Enum):
"""Room member roles"""
OWNER = "owner"
EDITOR = "editor"
VIEWER = "viewer"
class IncidentRoom(Base):
"""Incident room model for production incidents"""
__tablename__ = "incident_rooms"
room_id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
title = Column(String(255), nullable=False)
incident_type = Column(Enum(IncidentType), nullable=False)
severity = Column(Enum(SeverityLevel), nullable=False)
status = Column(Enum(RoomStatus), default=RoomStatus.ACTIVE, nullable=False)
location = Column(String(255))
description = Column(Text)
resolution_notes = Column(Text)
# User tracking
created_by = Column(String(255), nullable=False) # User email/ID who created the room
# Timestamps
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
resolved_at = Column(DateTime)
archived_at = Column(DateTime)
last_activity_at = Column(DateTime, default=datetime.utcnow, nullable=False)
last_updated_at = Column(DateTime, default=datetime.utcnow, nullable=False)
# Ownership transfer tracking
ownership_transferred_at = Column(DateTime)
ownership_transferred_by = Column(String(255))
# Denormalized count for performance
member_count = Column(Integer, default=0, nullable=False)
# Relationships
members = relationship("RoomMember", back_populates="room", cascade="all, delete-orphan")
files = relationship("RoomFile", back_populates="room", cascade="all, delete-orphan")
# Indexes for common queries
__table_args__ = (
Index("ix_incident_rooms_status_created", "status", "created_at"),
Index("ix_incident_rooms_created_by", "created_by"),
)
class RoomMember(Base):
"""Room membership model"""
__tablename__ = "room_members"
id = Column(Integer, primary_key=True, autoincrement=True)
room_id = Column(String(36), ForeignKey("incident_rooms.room_id", ondelete="CASCADE"), nullable=False)
user_id = Column(String(255), nullable=False) # User email/ID
role = Column(Enum(MemberRole), nullable=False)
# Tracking
added_by = Column(String(255), nullable=False) # Who added this member
added_at = Column(DateTime, default=datetime.utcnow, nullable=False)
removed_at = Column(DateTime) # Soft delete timestamp
# Relationships
room = relationship("IncidentRoom", back_populates="members")
# Constraints and indexes
__table_args__ = (
# Ensure unique active membership (where removed_at IS NULL)
UniqueConstraint("room_id", "user_id", "removed_at", name="uq_room_member_active"),
Index("ix_room_members_room_user", "room_id", "user_id"),
Index("ix_room_members_user", "user_id"),
)
class RoomTemplate(Base):
"""Predefined templates for common incident types"""
__tablename__ = "room_templates"
template_id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String(100), unique=True, nullable=False)
description = Column(Text)
incident_type = Column(Enum(IncidentType), nullable=False)
default_severity = Column(Enum(SeverityLevel), nullable=False)
default_members = Column(Text) # JSON array of user roles
metadata_fields = Column(Text) # JSON schema for additional fields

View File

@@ -0,0 +1,393 @@
"""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 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
)
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
)
@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,
all: bool = Query(False, description="Admin only: show all rooms"),
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"""
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,
all=all,
limit=limit,
offset=offset
)
rooms, total = room_service.list_user_rooms(db, user_email, filters, is_admin)
# Add user role to each room
room_responses = []
for room in rooms:
role = membership_service.get_user_role_in_room(db, room.room_id, user_email)
room_response = schemas.RoomResponse(
**room.__dict__,
current_user_role=role,
is_admin_view=is_admin and all
)
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"])
return schemas.RoomResponse(
**room.__dict__,
members=member_responses,
current_user_role=role,
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)
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")
# 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,
_: None = Depends(validate_room_owner),
db: Session = Depends(get_db)
):
"""Update a member's role"""
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,
_: 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"
)
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]

View File

@@ -0,0 +1,167 @@
"""Pydantic schemas for chat room management
Request and response models for API endpoints
"""
from pydantic import BaseModel, Field
from typing import Optional, List
from datetime import datetime
from enum import Enum
class IncidentType(str, Enum):
"""Types of production incidents"""
EQUIPMENT_FAILURE = "equipment_failure"
MATERIAL_SHORTAGE = "material_shortage"
QUALITY_ISSUE = "quality_issue"
OTHER = "other"
class SeverityLevel(str, Enum):
"""Incident severity levels"""
LOW = "low"
MEDIUM = "medium"
HIGH = "high"
CRITICAL = "critical"
class RoomStatus(str, Enum):
"""Room lifecycle status"""
ACTIVE = "active"
RESOLVED = "resolved"
ARCHIVED = "archived"
class MemberRole(str, Enum):
"""Room member roles"""
OWNER = "owner"
EDITOR = "editor"
VIEWER = "viewer"
# Request Schemas
class CreateRoomRequest(BaseModel):
"""Request to create a new incident room"""
title: str = Field(..., min_length=1, max_length=255, description="Room title")
incident_type: IncidentType = Field(..., description="Type of incident")
severity: SeverityLevel = Field(..., description="Severity level")
location: Optional[str] = Field(None, max_length=255, description="Incident location")
description: Optional[str] = Field(None, description="Detailed description")
template: Optional[str] = Field(None, description="Template name to use")
class UpdateRoomRequest(BaseModel):
"""Request to update room metadata"""
title: Optional[str] = Field(None, min_length=1, max_length=255)
severity: Optional[SeverityLevel] = None
status: Optional[RoomStatus] = None
location: Optional[str] = Field(None, max_length=255)
description: Optional[str] = None
resolution_notes: Optional[str] = None
class AddMemberRequest(BaseModel):
"""Request to add a member to a room"""
user_id: str = Field(..., description="User email or ID to add")
role: MemberRole = Field(..., description="Role to assign")
class UpdateMemberRoleRequest(BaseModel):
"""Request to update a member's role"""
role: MemberRole = Field(..., description="New role")
class TransferOwnershipRequest(BaseModel):
"""Request to transfer room ownership"""
new_owner_id: str = Field(..., description="User ID of new owner")
class RoomFilterParams(BaseModel):
"""Query parameters for filtering rooms"""
status: Optional[RoomStatus] = None
incident_type: Optional[IncidentType] = None
severity: Optional[SeverityLevel] = None
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")
limit: int = Field(20, ge=1, le=100)
offset: int = Field(0, ge=0)
# Response Schemas
class MemberResponse(BaseModel):
"""Room member information"""
user_id: str
role: MemberRole
added_by: str
added_at: datetime
removed_at: Optional[datetime] = None
class Config:
from_attributes = True
class RoomResponse(BaseModel):
"""Complete room information"""
room_id: str
title: str
incident_type: IncidentType
severity: SeverityLevel
status: RoomStatus
location: Optional[str] = None
description: Optional[str] = None
resolution_notes: Optional[str] = None
created_by: str
created_at: datetime
resolved_at: Optional[datetime] = None
archived_at: Optional[datetime] = None
last_activity_at: datetime
last_updated_at: datetime
ownership_transferred_at: Optional[datetime] = None
ownership_transferred_by: Optional[str] = None
member_count: int
members: Optional[List[MemberResponse]] = None
current_user_role: Optional[MemberRole] = None
is_admin_view: bool = False
class Config:
from_attributes = True
class RoomListResponse(BaseModel):
"""Paginated list of rooms"""
rooms: List[RoomResponse]
total: int
limit: int
offset: int
class TemplateResponse(BaseModel):
"""Room template information"""
template_id: int
name: str
description: Optional[str] = None
incident_type: IncidentType
default_severity: SeverityLevel
default_members: Optional[List[dict]] = None
metadata_fields: Optional[dict] = None
class Config:
from_attributes = True
class PermissionResponse(BaseModel):
"""User permissions in a room"""
role: Optional[MemberRole] = None
is_admin: bool = False
can_read: bool = False
can_write: bool = False
can_manage_members: bool = False
can_transfer_ownership: bool = False
can_update_status: bool = False
can_delete: bool = False
class SuccessResponse(BaseModel):
"""Generic success response"""
message: str

View File

@@ -0,0 +1,13 @@
"""Chat room services
Business logic for room management operations
"""
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
__all__ = [
"room_service",
"membership_service",
"template_service"
]

View File

@@ -0,0 +1,345 @@
"""Membership service for managing room members
Handles business logic for room membership operations
"""
from sqlalchemy.orm import Session
from sqlalchemy import and_
from typing import List, Optional
from datetime import datetime
from app.modules.chat_room.models import RoomMember, IncidentRoom, MemberRole
class MembershipService:
"""Service for room membership operations"""
# System admin email (hardcoded as per requirement)
SYSTEM_ADMIN_EMAIL = "ymirliu@panjit.com.tw"
def add_member(
self,
db: Session,
room_id: str,
user_id: str,
role: MemberRole,
added_by: str
) -> Optional[RoomMember]:
"""Add a member to a room
Args:
db: Database session
room_id: Room ID
user_id: User to add
role: Role to assign
added_by: User adding the member
Returns:
Created member or None if already exists
"""
# Check if member already exists (active)
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
# Create new member
member = RoomMember(
room_id=room_id,
user_id=user_id,
role=role,
added_by=added_by,
added_at=datetime.utcnow()
)
db.add(member)
# Update member count
self._update_member_count(db, room_id)
db.commit()
db.refresh(member)
return member
def remove_member(
self,
db: Session,
room_id: str,
user_id: str
) -> bool:
"""Remove a member from a room (soft delete)
Args:
db: Database session
room_id: Room ID
user_id: User to remove
Returns:
True if removed, False if not found
"""
member = db.query(RoomMember).filter(
and_(
RoomMember.room_id == room_id,
RoomMember.user_id == user_id,
RoomMember.removed_at.is_(None)
)
).first()
if not member:
return False
# Soft delete
member.removed_at = datetime.utcnow()
# Update member count
self._update_member_count(db, room_id)
db.commit()
return True
def update_member_role(
self,
db: Session,
room_id: str,
user_id: str,
new_role: MemberRole
) -> Optional[RoomMember]:
"""Update a member's role
Args:
db: Database session
room_id: Room ID
user_id: User ID
new_role: New role
Returns:
Updated member or None if not found
"""
member = db.query(RoomMember).filter(
and_(
RoomMember.room_id == room_id,
RoomMember.user_id == user_id,
RoomMember.removed_at.is_(None)
)
).first()
if not member:
return None
member.role = new_role
db.commit()
db.refresh(member)
return member
def transfer_ownership(
self,
db: Session,
room_id: str,
current_owner_id: str,
new_owner_id: str
) -> bool:
"""Transfer room ownership to another member
Args:
db: Database session
room_id: Room ID
current_owner_id: Current owner's user ID
new_owner_id: New owner's user ID
Returns:
True if successful, False otherwise
"""
# Verify new owner is a member
new_owner = db.query(RoomMember).filter(
and_(
RoomMember.room_id == room_id,
RoomMember.user_id == new_owner_id,
RoomMember.removed_at.is_(None)
)
).first()
if not new_owner:
return False
# Get current owner
current_owner = db.query(RoomMember).filter(
and_(
RoomMember.room_id == room_id,
RoomMember.user_id == current_owner_id,
RoomMember.role == MemberRole.OWNER,
RoomMember.removed_at.is_(None)
)
).first()
if not current_owner:
return False
# Transfer ownership
new_owner.role = MemberRole.OWNER
current_owner.role = MemberRole.EDITOR
# Update room ownership transfer tracking
room = db.query(IncidentRoom).filter(
IncidentRoom.room_id == room_id
).first()
if room:
room.ownership_transferred_at = datetime.utcnow()
room.ownership_transferred_by = current_owner_id
room.last_updated_at = datetime.utcnow()
room.last_activity_at = datetime.utcnow()
db.commit()
return True
def get_room_members(
self,
db: Session,
room_id: str
) -> List[RoomMember]:
"""Get all active members of a room
Args:
db: Database session
room_id: Room ID
Returns:
List of active members
"""
return db.query(RoomMember).filter(
and_(
RoomMember.room_id == room_id,
RoomMember.removed_at.is_(None)
)
).all()
def get_user_rooms(
self,
db: Session,
user_id: str
) -> List[IncidentRoom]:
"""Get all rooms where user is a member
Args:
db: Database session
user_id: User ID
Returns:
List of rooms
"""
return db.query(IncidentRoom).join(RoomMember).filter(
and_(
RoomMember.user_id == user_id,
RoomMember.removed_at.is_(None)
)
).all()
def get_user_role_in_room(
self,
db: Session,
room_id: str,
user_id: str
) -> Optional[MemberRole]:
"""Get user's role in a specific room
Args:
db: Database session
room_id: Room ID
user_id: User ID
Returns:
User's role or None if not a member
"""
member = db.query(RoomMember).filter(
and_(
RoomMember.room_id == room_id,
RoomMember.user_id == user_id,
RoomMember.removed_at.is_(None)
)
).first()
return member.role if member else None
def check_user_permission(
self,
db: Session,
room_id: str,
user_id: str,
permission: str
) -> bool:
"""Check if user has specific permission in room
Args:
db: Database session
room_id: Room ID
user_id: User ID
permission: Permission to check
Returns:
True if user has permission, False otherwise
"""
# Check if user is system admin
if self.is_system_admin(user_id):
return True
# Get user role
role = self.get_user_role_in_room(db, room_id, user_id)
if not role:
return False
# Permission matrix
permissions = {
MemberRole.OWNER: [
"read", "write", "manage_members", "transfer_ownership",
"update_status", "delete", "update_metadata"
],
MemberRole.EDITOR: [
"read", "write", "add_viewer"
],
MemberRole.VIEWER: [
"read"
]
}
return permission in permissions.get(role, [])
def is_system_admin(self, user_email: str) -> bool:
"""Check if user is system administrator
Args:
user_email: User's email
Returns:
True if system admin, False otherwise
"""
return user_email == self.SYSTEM_ADMIN_EMAIL
def _update_member_count(self, db: Session, room_id: str) -> None:
"""Update room's member count
Args:
db: Database session
room_id: Room ID
"""
count = db.query(RoomMember).filter(
and_(
RoomMember.room_id == room_id,
RoomMember.removed_at.is_(None)
)
).count()
room = db.query(IncidentRoom).filter(
IncidentRoom.room_id == room_id
).first()
if room:
room.member_count = count
# Create singleton instance
membership_service = MembershipService()

View File

@@ -0,0 +1,386 @@
"""Room service for managing incident rooms
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 datetime import datetime
import uuid
from app.modules.chat_room.models import IncidentRoom, RoomMember, RoomStatus, MemberRole
from app.modules.chat_room.schemas import CreateRoomRequest, UpdateRoomRequest, RoomFilterParams
class RoomService:
"""Service for room management operations"""
def create_room(
self,
db: Session,
user_id: str,
room_data: CreateRoomRequest
) -> IncidentRoom:
"""Create a new incident room
Args:
db: Database session
user_id: ID of user creating the room
room_data: Room creation data
Returns:
Created room instance
"""
# Create room
room = IncidentRoom(
room_id=str(uuid.uuid4()),
title=room_data.title,
incident_type=room_data.incident_type,
severity=room_data.severity,
location=room_data.location,
description=room_data.description,
status=RoomStatus.ACTIVE,
created_by=user_id,
created_at=datetime.utcnow(),
last_activity_at=datetime.utcnow(),
last_updated_at=datetime.utcnow(),
member_count=1
)
db.add(room)
# Add creator as owner
owner = RoomMember(
room_id=room.room_id,
user_id=user_id,
role=MemberRole.OWNER,
added_by=user_id,
added_at=datetime.utcnow()
)
db.add(owner)
db.commit()
db.refresh(room)
return room
def get_room(
self,
db: Session,
room_id: str,
user_id: str,
is_admin: bool = False
) -> Optional[IncidentRoom]:
"""Get room details
Args:
db: Database session
room_id: Room ID
user_id: User requesting access
is_admin: Whether user is system admin
Returns:
Room instance if user has access, None otherwise
"""
room = db.query(IncidentRoom).filter(
IncidentRoom.room_id == room_id
).first()
if not room:
return None
# Check access: admin or member
if not is_admin:
member = db.query(RoomMember).filter(
and_(
RoomMember.room_id == room_id,
RoomMember.user_id == user_id,
RoomMember.removed_at.is_(None)
)
).first()
if not member:
return None
return room
def list_user_rooms(
self,
db: Session,
user_id: str,
filters: RoomFilterParams,
is_admin: bool = False
) -> List[IncidentRoom]:
"""List rooms accessible to user with filters
Args:
db: Database session
user_id: User ID
filters: Filter parameters
is_admin: Whether user is system admin
Returns:
List of accessible rooms
"""
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
query = query.join(RoomMember).filter(
and_(
RoomMember.user_id == user_id,
RoomMember.removed_at.is_(None)
)
)
# Apply filters
if filters.status:
query = query.filter(IncidentRoom.status == filters.status)
if filters.incident_type:
query = query.filter(IncidentRoom.incident_type == filters.incident_type)
if filters.severity:
query = query.filter(IncidentRoom.severity == filters.severity)
if filters.created_after:
query = query.filter(IncidentRoom.created_at >= filters.created_after)
if filters.created_before:
query = query.filter(IncidentRoom.created_at <= filters.created_before)
if filters.search:
search_term = f"%{filters.search}%"
query = query.filter(
or_(
IncidentRoom.title.ilike(search_term),
IncidentRoom.description.ilike(search_term)
)
)
# Order by last activity (most recent first)
query = query.order_by(IncidentRoom.last_activity_at.desc())
# Apply pagination
total = query.count()
rooms = query.offset(filters.offset).limit(filters.limit).all()
return rooms, total
def update_room(
self,
db: Session,
room_id: str,
updates: UpdateRoomRequest
) -> Optional[IncidentRoom]:
"""Update room metadata
Args:
db: Database session
room_id: Room ID
updates: Update data
Returns:
Updated room or None if not found
"""
room = db.query(IncidentRoom).filter(
IncidentRoom.room_id == room_id
).first()
if not room:
return None
# Apply updates
if updates.title is not None:
room.title = updates.title
if updates.severity is not None:
room.severity = updates.severity
if updates.location is not None:
room.location = updates.location
if updates.description is not None:
room.description = updates.description
if updates.resolution_notes is not None:
room.resolution_notes = updates.resolution_notes
# Handle status transitions
if updates.status is not None:
if not self._validate_status_transition(room.status, updates.status):
raise ValueError(f"Invalid status transition from {room.status} to {updates.status}")
room.status = updates.status
# Update timestamps based on status
if updates.status == RoomStatus.RESOLVED:
room.resolved_at = datetime.utcnow()
elif updates.status == RoomStatus.ARCHIVED:
room.archived_at = datetime.utcnow()
# Update activity timestamps
room.last_updated_at = datetime.utcnow()
room.last_activity_at = datetime.utcnow()
db.commit()
db.refresh(room)
return room
def change_room_status(
self,
db: Session,
room_id: str,
new_status: RoomStatus
) -> Optional[IncidentRoom]:
"""Change room status with validation
Args:
db: Database session
room_id: Room ID
new_status: New status
Returns:
Updated room or None
"""
room = db.query(IncidentRoom).filter(
IncidentRoom.room_id == room_id
).first()
if not room:
return None
if not self._validate_status_transition(room.status, new_status):
raise ValueError(f"Invalid status transition from {room.status} to {new_status}")
room.status = new_status
# Update timestamps
if new_status == RoomStatus.RESOLVED:
room.resolved_at = datetime.utcnow()
elif new_status == RoomStatus.ARCHIVED:
room.archived_at = datetime.utcnow()
room.last_updated_at = datetime.utcnow()
room.last_activity_at = datetime.utcnow()
db.commit()
db.refresh(room)
return room
def search_rooms(
self,
db: Session,
user_id: str,
search_term: str,
is_admin: bool = False
) -> List[IncidentRoom]:
"""Search rooms by title or description
Args:
db: Database session
user_id: User ID
search_term: Search string
is_admin: Whether user is system admin
Returns:
List of matching rooms
"""
query = db.query(IncidentRoom)
# Access control
if not is_admin:
query = query.join(RoomMember).filter(
and_(
RoomMember.user_id == user_id,
RoomMember.removed_at.is_(None)
)
)
# Search filter
search_pattern = f"%{search_term}%"
query = query.filter(
or_(
IncidentRoom.title.ilike(search_pattern),
IncidentRoom.description.ilike(search_pattern)
)
)
return query.order_by(IncidentRoom.last_activity_at.desc()).all()
def delete_room(
self,
db: Session,
room_id: str
) -> bool:
"""Soft delete a room (archive it)
Args:
db: Database session
room_id: Room ID
Returns:
True if deleted, False if not found
"""
room = db.query(IncidentRoom).filter(
IncidentRoom.room_id == room_id
).first()
if not room:
return False
room.status = RoomStatus.ARCHIVED
room.archived_at = datetime.utcnow()
room.last_updated_at = datetime.utcnow()
db.commit()
return True
def _validate_status_transition(
self,
current_status: RoomStatus,
new_status: RoomStatus
) -> bool:
"""Validate status transition
Valid transitions:
- active -> resolved
- resolved -> archived
- active -> archived (allowed but not recommended)
Args:
current_status: Current status
new_status: New status
Returns:
True if valid, False otherwise
"""
valid_transitions = {
RoomStatus.ACTIVE: [RoomStatus.RESOLVED, RoomStatus.ARCHIVED],
RoomStatus.RESOLVED: [RoomStatus.ARCHIVED],
RoomStatus.ARCHIVED: [] # No transitions from archived
}
return new_status in valid_transitions.get(current_status, [])
def update_room_activity(
self,
db: Session,
room_id: str
) -> None:
"""Update room's last activity timestamp
Args:
db: Database session
room_id: Room ID
"""
room = db.query(IncidentRoom).filter(
IncidentRoom.room_id == room_id
).first()
if room:
room.last_activity_at = datetime.utcnow()
db.commit()
# Create singleton instance
room_service = RoomService()

View File

@@ -0,0 +1,179 @@
"""Template service for room templates
Handles business logic for room template operations
"""
from sqlalchemy.orm import Session
from typing import List, Optional
import json
from datetime import datetime
from app.modules.chat_room.models import RoomTemplate, IncidentRoom, RoomMember, IncidentType, SeverityLevel, MemberRole
from app.modules.chat_room.services.room_service import room_service
from app.modules.chat_room.services.membership_service import membership_service
class TemplateService:
"""Service for room template operations"""
def get_templates(self, db: Session) -> List[RoomTemplate]:
"""Get all available templates
Args:
db: Database session
Returns:
List of templates
"""
return db.query(RoomTemplate).all()
def get_template_by_name(
self,
db: Session,
template_name: str
) -> Optional[RoomTemplate]:
"""Get template by name
Args:
db: Database session
template_name: Template name
Returns:
Template or None if not found
"""
return db.query(RoomTemplate).filter(
RoomTemplate.name == template_name
).first()
def create_room_from_template(
self,
db: Session,
template_id: int,
user_id: str,
title: str,
location: Optional[str] = None,
description: Optional[str] = None
) -> Optional[IncidentRoom]:
"""Create a room from a template
Args:
db: Database session
template_id: Template ID
user_id: User creating the room
title: Room title
location: Optional location override
description: Optional description override
Returns:
Created room or None if template not found
"""
# Get template
template = db.query(RoomTemplate).filter(
RoomTemplate.template_id == template_id
).first()
if not template:
return None
# Create room with template defaults
room = IncidentRoom(
title=title,
incident_type=template.incident_type,
severity=template.default_severity,
location=location,
description=description or template.description,
created_by=user_id,
status="active",
created_at=datetime.utcnow(),
last_activity_at=datetime.utcnow(),
last_updated_at=datetime.utcnow(),
member_count=1
)
db.add(room)
db.flush() # Get room_id
# Add creator as owner
owner = RoomMember(
room_id=room.room_id,
user_id=user_id,
role=MemberRole.OWNER,
added_by=user_id,
added_at=datetime.utcnow()
)
db.add(owner)
# Add default members from template
if template.default_members:
try:
default_members = json.loads(template.default_members)
for member_config in default_members:
if member_config.get("user_id") != user_id: # Don't duplicate owner
member = RoomMember(
room_id=room.room_id,
user_id=member_config["user_id"],
role=member_config.get("role", MemberRole.VIEWER),
added_by=user_id,
added_at=datetime.utcnow()
)
db.add(member)
room.member_count += 1
except (json.JSONDecodeError, KeyError):
# Invalid template configuration, skip default members
pass
db.commit()
db.refresh(room)
return room
def initialize_default_templates(self, db: Session) -> None:
"""Initialize default templates if none exist
Args:
db: Database session
"""
# Check if templates already exist
existing = db.query(RoomTemplate).count()
if existing > 0:
return
# Create default templates
templates = [
RoomTemplate(
name="equipment_failure",
description="Equipment failure incident requiring immediate attention",
incident_type=IncidentType.EQUIPMENT_FAILURE,
default_severity=SeverityLevel.HIGH,
default_members=json.dumps([
{"user_id": "maintenance_team@panjit.com.tw", "role": "editor"},
{"user_id": "engineering@panjit.com.tw", "role": "viewer"}
])
),
RoomTemplate(
name="material_shortage",
description="Material shortage affecting production",
incident_type=IncidentType.MATERIAL_SHORTAGE,
default_severity=SeverityLevel.MEDIUM,
default_members=json.dumps([
{"user_id": "procurement@panjit.com.tw", "role": "editor"},
{"user_id": "logistics@panjit.com.tw", "role": "editor"}
])
),
RoomTemplate(
name="quality_issue",
description="Quality control issue requiring investigation",
incident_type=IncidentType.QUALITY_ISSUE,
default_severity=SeverityLevel.HIGH,
default_members=json.dumps([
{"user_id": "quality_team@panjit.com.tw", "role": "editor"},
{"user_id": "production_manager@panjit.com.tw", "role": "viewer"}
])
)
]
for template in templates:
db.add(template)
db.commit()
# Create singleton instance
template_service = TemplateService()

View File

@@ -0,0 +1,5 @@
"""File storage module for MinIO integration"""
from app.modules.file_storage.models import RoomFile
from app.modules.file_storage.router import router
__all__ = ["RoomFile", "router"]

View File

@@ -0,0 +1,44 @@
"""Database models for file storage"""
from sqlalchemy import Column, String, BigInteger, DateTime, Index, ForeignKey
from sqlalchemy.orm import relationship
from datetime import datetime
from app.core.database import Base
class RoomFile(Base):
"""File uploaded to an incident room"""
__tablename__ = "room_files"
# 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)
# File metadata
uploader_id = Column(String(255), nullable=False)
filename = Column(String(255), nullable=False)
file_type = Column(String(20), nullable=False) # 'image', 'document', 'log'
mime_type = Column(String(100), nullable=False)
file_size = Column(BigInteger, nullable=False) # bytes
# MinIO storage information
minio_bucket = Column(String(100), nullable=False)
minio_object_path = Column(String(500), nullable=False)
# Timestamps
uploaded_at = Column(DateTime, default=datetime.utcnow, nullable=False)
deleted_at = Column(DateTime, nullable=True) # soft delete
# Relationships
room = relationship("IncidentRoom", back_populates="files")
# Indexes
__table_args__ = (
Index("ix_room_files", "room_id", "uploaded_at"),
Index("ix_file_uploader", "uploader_id"),
)
def __repr__(self):
return f"<RoomFile(file_id={self.file_id}, filename={self.filename}, room_id={self.room_id})>"

View File

@@ -0,0 +1,228 @@
"""API routes for file storage operations
FastAPI router with file upload, download, listing, and delete endpoints
"""
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form, Query, status, BackgroundTasks
from sqlalchemy.orm import Session
from typing import Optional
from datetime import datetime
import asyncio
import logging
from app.core.database import get_db
from app.core.config import get_settings
from app.modules.auth import get_current_user
from app.modules.chat_room.dependencies import get_current_room
from app.modules.chat_room.models import MemberRole
from app.modules.chat_room.services.membership_service import membership_service
from app.modules.file_storage.schemas import FileUploadResponse, FileMetadata, FileListResponse, FileType
from app.modules.file_storage.services.file_service import FileService
from app.modules.file_storage.services import minio_service
from app.modules.realtime.websocket_manager import manager as websocket_manager
from app.modules.realtime.schemas import FileUploadedBroadcast, FileDeletedBroadcast, FileUploadAck
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/rooms", tags=["Files"])
@router.post("/{room_id}/files", response_model=FileUploadResponse, status_code=status.HTTP_201_CREATED)
async def upload_file(
room_id: str,
background_tasks: BackgroundTasks,
file: UploadFile = File(...),
description: Optional[str] = Form(None),
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user),
_room = Depends(get_current_room) # Validates room exists and user has access
):
"""Upload a file to an incident room
Requires OWNER or EDITOR role in the room.
Supported file types:
- Images: jpg, jpeg, png, gif (max 10MB)
- Documents: pdf (max 20MB)
- Logs: txt, log, csv (max 5MB)
"""
user_email = current_user["username"]
# Check write permission (OWNER or EDITOR)
member = FileService.check_room_membership(db, room_id, user_email)
if not FileService.check_write_permission(member):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only OWNER or EDITOR can upload files"
)
# Upload file
result = FileService.upload_file(db, room_id, user_email, file, description)
# Broadcast file upload event to room members via WebSocket
async def broadcast_file_upload():
try:
broadcast = FileUploadedBroadcast(
file_id=result.file_id,
room_id=room_id,
uploader_id=user_email,
filename=result.filename,
file_type=result.file_type.value,
file_size=result.file_size,
mime_type=result.mime_type,
download_url=result.download_url,
uploaded_at=result.uploaded_at
)
await websocket_manager.broadcast_to_room(room_id, broadcast.to_dict())
logger.info(f"Broadcasted file upload event: {result.file_id} to room {room_id}")
# Send acknowledgment to uploader
ack = FileUploadAck(
file_id=result.file_id,
status="success",
download_url=result.download_url
)
await websocket_manager.send_personal(user_email, ack.to_dict())
except Exception as e:
logger.error(f"Failed to broadcast file upload: {e}")
# Run broadcast in background
background_tasks.add_task(asyncio.create_task, broadcast_file_upload())
return result
@router.get("/{room_id}/files", response_model=FileListResponse)
async def list_files(
room_id: str,
file_type: Optional[FileType] = Query(None, description="Filter by file type"),
limit: int = Query(50, ge=1, le=100, description="Number of files to return"),
offset: int = Query(0, ge=0, description="Number of files to skip"),
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user),
_room = Depends(get_current_room) # Validates room exists and user has access
):
"""List files in an incident room with pagination
All room members can list files.
"""
# Convert enum to string value if provided
file_type_str = file_type.value if file_type else None
return FileService.get_files(db, room_id, limit, offset, file_type_str)
@router.get("/{room_id}/files/{file_id}", response_model=FileMetadata)
async def get_file(
room_id: str,
file_id: str,
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user),
_room = Depends(get_current_room) # Validates room exists and user has access
):
"""Get file metadata and presigned download URL
All room members can access file metadata and download files.
Presigned URL expires in 1 hour.
"""
settings = get_settings()
# Get file metadata
file_record = FileService.get_file(db, file_id)
if not file_record:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="File not found"
)
# Verify file belongs to requested room
if file_record.room_id != room_id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="File not found in this room"
)
# Generate presigned download URL
download_url = minio_service.generate_presigned_url(
bucket=settings.MINIO_BUCKET,
object_path=file_record.minio_object_path,
expiry_seconds=3600
)
# Build response with download URL
return FileMetadata(
file_id=file_record.file_id,
room_id=file_record.room_id,
filename=file_record.filename,
file_type=file_record.file_type,
mime_type=file_record.mime_type,
file_size=file_record.file_size,
minio_bucket=file_record.minio_bucket,
minio_object_path=file_record.minio_object_path,
uploaded_at=file_record.uploaded_at,
uploader_id=file_record.uploader_id,
deleted_at=file_record.deleted_at,
download_url=download_url
)
@router.delete("/{room_id}/files/{file_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_file(
room_id: str,
file_id: str,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user),
_room = Depends(get_current_room) # Validates room exists and user has access
):
"""Soft delete a file
Only the file uploader or room OWNER can delete files.
"""
user_email = current_user["username"]
# Get file to check ownership
file_record = FileService.get_file(db, file_id)
if not file_record:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="File not found"
)
# Verify file belongs to requested room
if file_record.room_id != room_id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="File not found in this room"
)
# Check if user is room owner
role = membership_service.get_user_role_in_room(db, room_id, user_email)
is_room_owner = role == MemberRole.OWNER
# Check if admin
is_admin = membership_service.is_system_admin(user_email)
# Delete file (service will verify permissions)
deleted_file = FileService.delete_file(db, file_id, user_email, is_room_owner or is_admin)
# Broadcast file deletion event to room members via WebSocket
if deleted_file:
async def broadcast_file_delete():
try:
broadcast = FileDeletedBroadcast(
file_id=file_id,
room_id=room_id,
deleted_by=user_email,
deleted_at=deleted_file.deleted_at
)
await websocket_manager.broadcast_to_room(room_id, broadcast.to_dict())
logger.info(f"Broadcasted file deletion event: {file_id} from room {room_id}")
except Exception as e:
logger.error(f"Failed to broadcast file deletion: {e}")
# Run broadcast in background
background_tasks.add_task(asyncio.create_task, broadcast_file_delete())
return None

View File

@@ -0,0 +1,74 @@
"""Pydantic schemas for file storage operations"""
from pydantic import BaseModel, Field, field_validator
from typing import Optional, List
from datetime import datetime
from enum import Enum
class FileType(str, Enum):
"""File type enumeration"""
IMAGE = "image"
DOCUMENT = "document"
LOG = "log"
class FileUploadResponse(BaseModel):
"""Response after successful file upload"""
file_id: str
filename: str
file_type: FileType
file_size: int
mime_type: str
download_url: str # Presigned URL
uploaded_at: datetime
uploader_id: str
class Config:
from_attributes = True
class FileMetadata(BaseModel):
"""File metadata response"""
file_id: str
room_id: str
filename: str
file_type: FileType
mime_type: str
file_size: int
minio_bucket: str
minio_object_path: str
uploaded_at: datetime
uploader_id: str
deleted_at: Optional[datetime] = None
download_url: Optional[str] = None # Presigned URL (only when requested)
class Config:
from_attributes = True
@field_validator("file_size")
@classmethod
def validate_file_size(cls, v):
"""Validate file size is positive"""
if v <= 0:
raise ValueError("File size must be positive")
return v
class FileListResponse(BaseModel):
"""Paginated file list response"""
files: List[FileMetadata]
total: int
limit: int
offset: int
has_more: bool
class Config:
from_attributes = True
class FileUploadParams(BaseModel):
"""Parameters for file upload (optional description)"""
description: Optional[str] = Field(None, max_length=500)
class Config:
from_attributes = True

View File

@@ -0,0 +1 @@
"""File storage services"""

View File

@@ -0,0 +1,251 @@
"""File storage service layer"""
from sqlalchemy.orm import Session
from fastapi import UploadFile, HTTPException
from app.modules.file_storage.models import RoomFile
from app.modules.file_storage.schemas import FileUploadResponse, FileMetadata, FileListResponse
from app.modules.file_storage.validators import validate_upload_file
from app.modules.file_storage.services import minio_service
from app.modules.chat_room.models import RoomMember, MemberRole
from app.modules.realtime.models import Message, MessageType
from app.modules.realtime.services.message_service import MessageService
from app.core.config import get_settings
from datetime import datetime
from typing import Optional, Dict, Any
import uuid
import logging
logger = logging.getLogger(__name__)
class FileService:
"""Service for file operations"""
@staticmethod
def upload_file(
db: Session,
room_id: str,
uploader_id: str,
file: UploadFile,
description: Optional[str] = None
) -> FileUploadResponse:
"""
Upload file to MinIO and store metadata in database
Args:
db: Database session
room_id: Room ID
uploader_id: User ID uploading the file
file: FastAPI UploadFile object
description: Optional file description
Returns:
FileUploadResponse with file metadata and download URL
Raises:
HTTPException if upload fails
"""
settings = get_settings()
# Validate file
file_type, mime_type, file_size = validate_upload_file(file)
# Generate file ID and object path
file_id = str(uuid.uuid4())
file_extension = file.filename.split(".")[-1] if "." in file.filename else ""
object_path = f"room-{room_id}/{file_type}s/{file_id}.{file_extension}"
# Upload to MinIO
success = minio_service.upload_file(
bucket=settings.MINIO_BUCKET,
object_path=object_path,
file_data=file.file,
file_size=file_size,
content_type=mime_type
)
if not success:
raise HTTPException(
status_code=503,
detail="File storage service temporarily unavailable"
)
# Create database record
try:
room_file = RoomFile(
file_id=file_id,
room_id=room_id,
uploader_id=uploader_id,
filename=file.filename,
file_type=file_type,
mime_type=mime_type,
file_size=file_size,
minio_bucket=settings.MINIO_BUCKET,
minio_object_path=object_path,
uploaded_at=datetime.utcnow()
)
db.add(room_file)
db.commit()
db.refresh(room_file)
# Generate presigned download URL
download_url = minio_service.generate_presigned_url(
bucket=settings.MINIO_BUCKET,
object_path=object_path,
expiry_seconds=3600
)
return FileUploadResponse(
file_id=file_id,
filename=file.filename,
file_type=file_type,
file_size=file_size,
mime_type=mime_type,
download_url=download_url,
uploaded_at=room_file.uploaded_at,
uploader_id=uploader_id
)
except Exception as e:
# Rollback database and cleanup MinIO
db.rollback()
minio_service.delete_file(settings.MINIO_BUCKET, object_path)
logger.error(f"Failed to create file record: {e}")
raise HTTPException(status_code=500, detail="Failed to save file metadata")
@staticmethod
def get_file(db: Session, file_id: str) -> Optional[RoomFile]:
"""Get file metadata by ID"""
return db.query(RoomFile).filter(
RoomFile.file_id == file_id,
RoomFile.deleted_at.is_(None)
).first()
@staticmethod
def get_files(
db: Session,
room_id: str,
limit: int = 50,
offset: int = 0,
file_type: Optional[str] = None
) -> FileListResponse:
"""Get paginated list of files in a room"""
query = db.query(RoomFile).filter(
RoomFile.room_id == room_id,
RoomFile.deleted_at.is_(None)
)
if file_type:
query = query.filter(RoomFile.file_type == file_type)
total = query.count()
files = query.order_by(RoomFile.uploaded_at.desc()).offset(offset).limit(limit).all()
file_metadata_list = [
FileMetadata.from_orm(f) for f in files
]
return FileListResponse(
files=file_metadata_list,
total=total,
limit=limit,
offset=offset,
has_more=(offset + len(files)) < total
)
@staticmethod
def delete_file(
db: Session,
file_id: str,
user_id: str,
is_room_owner: bool = False
) -> Optional[RoomFile]:
"""Soft delete file"""
file = db.query(RoomFile).filter(RoomFile.file_id == file_id).first()
if not file:
return None
# Check permissions
if not is_room_owner and file.uploader_id != user_id:
raise HTTPException(
status_code=403,
detail="Only file uploader or room owner can delete files"
)
# Soft delete
file.deleted_at = datetime.utcnow()
db.commit()
db.refresh(file)
return file
@staticmethod
def check_room_membership(db: Session, room_id: str, user_id: str) -> Optional[RoomMember]:
"""Check if user is member of room"""
return db.query(RoomMember).filter(
RoomMember.room_id == room_id,
RoomMember.user_id == user_id,
RoomMember.removed_at.is_(None)
).first()
@staticmethod
def check_write_permission(member: Optional[RoomMember]) -> bool:
"""Check if member has write permission"""
if not member:
return False
return member.role in [MemberRole.OWNER, MemberRole.EDITOR]
@staticmethod
def create_file_reference_message(
db: Session,
room_id: str,
sender_id: str,
file_id: str,
filename: str,
file_type: str,
file_url: str,
description: Optional[str] = None
) -> Message:
"""
Create a message referencing an uploaded file in the room chat.
Args:
db: Database session
room_id: Room ID
sender_id: User ID who uploaded the file
file_id: File ID in room_files table
filename: Original filename
file_type: Type of file (image, document, log)
file_url: Presigned download URL
description: Optional description for the file
Returns:
Created Message object with file reference
"""
# Determine message type based on file type
if file_type == "image":
msg_type = MessageType.IMAGE_REF
content = description or f"[Image] {filename}"
else:
msg_type = MessageType.FILE_REF
content = description or f"[File] {filename}"
# Create metadata with file info
metadata: Dict[str, Any] = {
"file_id": file_id,
"file_url": file_url,
"filename": filename,
"file_type": file_type
}
# Use MessageService to create the message
return MessageService.create_message(
db=db,
room_id=room_id,
sender_id=sender_id,
content=content,
message_type=msg_type,
metadata=metadata
)

View File

@@ -0,0 +1,160 @@
"""MinIO service layer for file operations"""
from minio.error import S3Error
from app.core.minio_client import get_minio_client
from app.core.config import get_settings
from datetime import timedelta
from typing import BinaryIO
import logging
import time
logger = logging.getLogger(__name__)
def upload_file(
bucket: str,
object_path: str,
file_data: BinaryIO,
file_size: int,
content_type: str,
max_retries: int = 3
) -> bool:
"""
Upload file to MinIO with retry logic
Args:
bucket: Bucket name
object_path: Object path in bucket
file_data: File data stream
file_size: File size in bytes
content_type: MIME type
max_retries: Maximum retry attempts
Returns:
True if upload successful, False otherwise
"""
client = get_minio_client()
for attempt in range(max_retries):
try:
# Reset file pointer to beginning
file_data.seek(0)
client.put_object(
bucket,
object_path,
file_data,
length=file_size,
content_type=content_type
)
logger.info(f"File uploaded successfully: {bucket}/{object_path}")
return True
except S3Error as e:
logger.error(f"MinIO upload error (attempt {attempt + 1}/{max_retries}): {e}")
if attempt < max_retries - 1:
# Exponential backoff: 1s, 2s, 4s
sleep_time = 2 ** attempt
logger.info(f"Retrying upload after {sleep_time}s...")
time.sleep(sleep_time)
else:
logger.error(f"Failed to upload file after {max_retries} attempts")
return False
except Exception as e:
logger.error(f"Unexpected error uploading file: {e}")
return False
return False
def generate_presigned_url(
bucket: str,
object_path: str,
expiry_seconds: int = 3600
) -> str:
"""
Generate presigned download URL with expiry
Args:
bucket: Bucket name
object_path: Object path in bucket
expiry_seconds: URL expiry time in seconds (default 1 hour)
Returns:
Presigned URL string
Raises:
Exception if URL generation fails
"""
client = get_minio_client()
try:
url = client.presigned_get_object(
bucket,
object_path,
expires=timedelta(seconds=expiry_seconds)
)
return url
except S3Error as e:
logger.error(f"Failed to generate presigned URL for {bucket}/{object_path}: {e}")
raise
except Exception as e:
logger.error(f"Unexpected error generating presigned URL: {e}")
raise
def delete_file(bucket: str, object_path: str) -> bool:
"""
Delete file from MinIO (for cleanup, not exposed to users)
Args:
bucket: Bucket name
object_path: Object path in bucket
Returns:
True if deleted successfully, False otherwise
"""
client = get_minio_client()
try:
client.remove_object(bucket, object_path)
logger.info(f"File deleted: {bucket}/{object_path}")
return True
except S3Error as e:
logger.error(f"Failed to delete file {bucket}/{object_path}: {e}")
return False
except Exception as e:
logger.error(f"Unexpected error deleting file: {e}")
return False
def check_file_exists(bucket: str, object_path: str) -> bool:
"""
Check if file exists in MinIO
Args:
bucket: Bucket name
object_path: Object path in bucket
Returns:
True if file exists, False otherwise
"""
client = get_minio_client()
try:
client.stat_object(bucket, object_path)
return True
except S3Error:
return False
except Exception as e:
logger.error(f"Error checking file existence: {e}")
return False

View File

@@ -0,0 +1,158 @@
"""File validation utilities"""
import magic
from fastapi import UploadFile, HTTPException
from typing import Set
import logging
logger = logging.getLogger(__name__)
# MIME type whitelists
IMAGE_TYPES: Set[str] = {
"image/jpeg",
"image/png",
"image/gif"
}
DOCUMENT_TYPES: Set[str] = {
"application/pdf"
}
LOG_TYPES: Set[str] = {
"text/plain",
"text/csv"
}
# File size limits (bytes)
IMAGE_MAX_SIZE = 10 * 1024 * 1024 # 10MB
DOCUMENT_MAX_SIZE = 20 * 1024 * 1024 # 20MB
LOG_MAX_SIZE = 5 * 1024 * 1024 # 5MB
def detect_mime_type(file_data: bytes) -> str:
"""
Detect MIME type from file content using python-magic
Args:
file_data: First chunk of file data
Returns:
MIME type string
"""
try:
mime = magic.Magic(mime=True)
return mime.from_buffer(file_data)
except Exception as e:
logger.error(f"Failed to detect MIME type: {e}")
return "application/octet-stream"
def validate_file_type(file: UploadFile, allowed_types: Set[str]) -> str:
"""
Validate file MIME type using actual file content
Args:
file: FastAPI UploadFile object
allowed_types: Set of allowed MIME types
Returns:
Detected MIME type
Raises:
HTTPException if file type is not allowed
"""
# Read first 2048 bytes to detect MIME type
file.file.seek(0)
header = file.file.read(2048)
file.file.seek(0)
# Detect actual MIME type from content
detected_mime = detect_mime_type(header)
if detected_mime not in allowed_types:
raise HTTPException(
status_code=400,
detail=f"File type not allowed: {detected_mime}. Allowed types: {', '.join(allowed_types)}"
)
return detected_mime
def validate_file_size(file: UploadFile, max_size: int):
"""
Validate file size
Args:
file: FastAPI UploadFile object
max_size: Maximum allowed size in bytes
Raises:
HTTPException if file exceeds max size
"""
# Seek to end to get file size
file.file.seek(0, 2) # 2 = SEEK_END
file_size = file.file.tell()
file.file.seek(0) # Reset to beginning
if file_size > max_size:
max_mb = max_size / (1024 * 1024)
actual_mb = file_size / (1024 * 1024)
raise HTTPException(
status_code=413,
detail=f"File size exceeds limit: {actual_mb:.2f}MB > {max_mb:.2f}MB"
)
return file_size
def get_file_type_and_limits(mime_type: str) -> tuple[str, int]:
"""
Determine file type category and size limit from MIME type
Args:
mime_type: MIME type string
Returns:
Tuple of (file_type, max_size)
Raises:
HTTPException if MIME type not recognized
"""
if mime_type in IMAGE_TYPES:
return ("image", IMAGE_MAX_SIZE)
elif mime_type in DOCUMENT_TYPES:
return ("document", DOCUMENT_MAX_SIZE)
elif mime_type in LOG_TYPES:
return ("log", LOG_MAX_SIZE)
else:
raise HTTPException(
status_code=400,
detail=f"Unsupported file type: {mime_type}"
)
def validate_upload_file(file: UploadFile) -> tuple[str, str, int]:
"""
Validate uploaded file (type and size)
Args:
file: FastAPI UploadFile object
Returns:
Tuple of (file_type, mime_type, file_size)
Raises:
HTTPException if validation fails
"""
# Combine all allowed types
all_allowed_types = IMAGE_TYPES | DOCUMENT_TYPES | LOG_TYPES
# Validate MIME type
mime_type = validate_file_type(file, all_allowed_types)
# Get file type category and max size
file_type, max_size = get_file_type_and_limits(mime_type)
# Validate file size
file_size = validate_file_size(file, max_size)
return (file_type, mime_type, file_size)

View File

@@ -0,0 +1,5 @@
"""Realtime messaging module for WebSocket-based communication"""
from app.modules.realtime.models import Message, MessageReaction, MessageEditHistory, MessageType
from app.modules.realtime.router import router
__all__ = ["Message", "MessageReaction", "MessageEditHistory", "MessageType", "router"]

View File

@@ -0,0 +1,106 @@
"""SQLAlchemy models for realtime messaging
Tables:
- messages: Stores all messages sent in incident rooms
- message_reactions: User reactions to messages (emoji)
- message_edit_history: Audit trail for message edits
"""
from sqlalchemy import Column, Integer, String, Text, DateTime, Enum, ForeignKey, UniqueConstraint, Index, BigInteger, JSON
from sqlalchemy.orm import relationship
from datetime import datetime
import enum
import uuid
from app.core.database import Base
class MessageType(str, enum.Enum):
"""Types of messages in incident rooms"""
TEXT = "text"
IMAGE_REF = "image_ref"
FILE_REF = "file_ref"
SYSTEM = "system"
INCIDENT_DATA = "incident_data"
class Message(Base):
"""Message model for incident room communications"""
__tablename__ = "messages"
message_id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
room_id = Column(String(36), ForeignKey("incident_rooms.room_id", ondelete="CASCADE"), nullable=False)
sender_id = Column(String(255), nullable=False) # User email/ID
content = Column(Text, nullable=False)
message_type = Column(Enum(MessageType), default=MessageType.TEXT, nullable=False)
# Message metadata for structured data, mentions, file references, etc.
message_metadata = Column(JSON)
# Timestamps
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
edited_at = Column(DateTime) # Last edit timestamp
deleted_at = Column(DateTime) # Soft delete timestamp
# Sequence number for FIFO ordering within a room
# Note: Autoincrement doesn't work for non-PK in SQLite, will be set in service layer
sequence_number = Column(BigInteger, nullable=False)
# Relationships
reactions = relationship("MessageReaction", back_populates="message", cascade="all, delete-orphan")
edit_history = relationship("MessageEditHistory", back_populates="message", cascade="all, delete-orphan")
# Indexes for common queries
__table_args__ = (
Index("ix_messages_room_created", "room_id", "created_at"),
Index("ix_messages_room_sequence", "room_id", "sequence_number"),
Index("ix_messages_sender", "sender_id"),
# PostgreSQL full-text search index on content (commented for SQLite compatibility)
# Note: Uncomment when using PostgreSQL with pg_trgm extension enabled
# Index("ix_messages_content_search", "content", postgresql_using='gin', postgresql_ops={'content': 'gin_trgm_ops'}),
)
class MessageReaction(Base):
"""Message reaction model for emoji reactions"""
__tablename__ = "message_reactions"
reaction_id = Column(Integer, primary_key=True, autoincrement=True)
message_id = Column(String(36), ForeignKey("messages.message_id", ondelete="CASCADE"), nullable=False)
user_id = Column(String(255), nullable=False) # User email/ID who reacted
emoji = Column(String(10), nullable=False) # Emoji character or code
# Timestamp
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
# Relationships
message = relationship("Message", back_populates="reactions")
# Constraints and indexes
__table_args__ = (
# Ensure unique reaction per user per message
UniqueConstraint("message_id", "user_id", "emoji", name="uq_message_reaction"),
Index("ix_message_reactions_message", "message_id"),
)
class MessageEditHistory(Base):
"""Message edit history model for audit trail"""
__tablename__ = "message_edit_history"
edit_id = Column(Integer, primary_key=True, autoincrement=True)
message_id = Column(String(36), ForeignKey("messages.message_id", ondelete="CASCADE"), nullable=False)
original_content = Column(Text, nullable=False) # Content before edit
edited_by = Column(String(255), nullable=False) # User who made the edit
# Timestamp
edited_at = Column(DateTime, default=datetime.utcnow, nullable=False)
# Relationships
message = relationship("Message", back_populates="edit_history")
# Indexes
__table_args__ = (
Index("ix_message_edit_history_message", "message_id", "edited_at"),
)

View File

@@ -0,0 +1,448 @@
"""WebSocket and REST API router for realtime messaging"""
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Depends, HTTPException, Query
from fastapi.responses import JSONResponse
from sqlalchemy.orm import Session
from typing import Optional
from datetime import datetime
import json
from app.core.database import get_db
from app.modules.auth.dependencies import get_current_user
from app.modules.chat_room.models import RoomMember, MemberRole
from app.modules.realtime.websocket_manager import manager
from app.modules.realtime.services.message_service import MessageService
from app.modules.realtime.schemas import (
WebSocketMessageIn,
MessageBroadcast,
SystemMessageBroadcast,
MessageAck,
ErrorMessage,
MessageCreate,
MessageUpdate,
MessageResponse,
MessageListResponse,
ReactionCreate,
WebSocketMessageType,
SystemEventType,
MessageTypeEnum
)
from app.modules.realtime.models import MessageType, Message
from sqlalchemy import and_
router = APIRouter(prefix="/api", tags=["realtime"])
SYSTEM_ADMIN_EMAIL = "ymirliu@panjit.com.tw"
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(
and_(
RoomMember.room_id == room_id,
RoomMember.user_id == user_id,
RoomMember.removed_at.is_(None)
)
).first()
def can_write_message(membership: Optional[RoomMember], user_id: str) -> bool:
"""Check if user has write permission (OWNER or EDITOR)"""
if user_id == SYSTEM_ADMIN_EMAIL:
return True
if not membership:
return False
return membership.role in [MemberRole.OWNER, MemberRole.EDITOR]
@router.websocket("/ws/{room_id}")
async def websocket_endpoint(
websocket: WebSocket,
room_id: str,
token: Optional[str] = Query(None)
):
"""
WebSocket endpoint for realtime messaging
Authentication:
- Token can be provided via query parameter: /ws/{room_id}?token=xxx
- Or via WebSocket headers
Connection flow:
1. Client connects with room_id
2. Server validates authentication and room membership
3. Connection added to pool
4. User joined event broadcast to room
5. Client can send/receive messages
"""
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
# Check room membership
membership = get_user_room_membership(db, room_id, user_id)
if not membership and user_id != SYSTEM_ADMIN_EMAIL:
await websocket.close(code=4001, reason="Not a member of this room")
return
# Connect to WebSocket manager
conn_info = await manager.connect(websocket, room_id, user_id)
# Broadcast user joined event
await manager.broadcast_to_room(
room_id,
SystemMessageBroadcast(
event=SystemEventType.USER_JOINED,
user_id=user_id,
room_id=room_id,
timestamp=datetime.utcnow()
).dict(),
exclude_user=user_id
)
try:
while True:
# Receive message from client
data = await websocket.receive_text()
message_data = json.loads(data)
# Parse incoming message
try:
ws_message = WebSocketMessageIn(**message_data)
except Exception as e:
await websocket.send_json(
ErrorMessage(error=str(e), code="INVALID_MESSAGE").dict()
)
continue
# Handle different message types
if ws_message.type == WebSocketMessageType.MESSAGE:
# Check write permission
if not can_write_message(membership, user_id):
await websocket.send_json(
ErrorMessage(
error="Insufficient permissions",
code="PERMISSION_DENIED"
).dict()
)
continue
# Create message in database
message = MessageService.create_message(
db=db,
room_id=room_id,
sender_id=user_id,
content=ws_message.content or "",
message_type=MessageType(ws_message.message_type.value) if ws_message.message_type else MessageType.TEXT,
metadata=ws_message.metadata
)
# Send acknowledgment to sender
await websocket.send_json(
MessageAck(
message_id=message.message_id,
sequence_number=message.sequence_number,
timestamp=message.created_at
).dict()
)
# Broadcast message to all room members
await manager.broadcast_to_room(
room_id,
MessageBroadcast(
message_id=message.message_id,
room_id=message.room_id,
sender_id=message.sender_id,
content=message.content,
message_type=MessageTypeEnum(message.message_type.value),
metadata=message.message_metadata,
created_at=message.created_at,
sequence_number=message.sequence_number
).dict()
)
elif ws_message.type == WebSocketMessageType.EDIT_MESSAGE:
if not ws_message.message_id or not ws_message.content:
await websocket.send_json(
ErrorMessage(error="Missing message_id or content", code="INVALID_REQUEST").dict()
)
continue
# Edit message
edited_message = MessageService.edit_message(
db=db,
message_id=ws_message.message_id,
user_id=user_id,
new_content=ws_message.content
)
if not edited_message:
await websocket.send_json(
ErrorMessage(error="Cannot edit message", code="EDIT_FAILED").dict()
)
continue
# Broadcast edit to all room members
await manager.broadcast_to_room(
room_id,
MessageBroadcast(
type="edit_message",
message_id=edited_message.message_id,
room_id=edited_message.room_id,
sender_id=edited_message.sender_id,
content=edited_message.content,
message_type=MessageTypeEnum(edited_message.message_type.value),
metadata=edited_message.message_metadata,
created_at=edited_message.created_at,
edited_at=edited_message.edited_at,
sequence_number=edited_message.sequence_number
).dict()
)
elif ws_message.type == WebSocketMessageType.DELETE_MESSAGE:
if not ws_message.message_id:
await websocket.send_json(
ErrorMessage(error="Missing message_id", code="INVALID_REQUEST").dict()
)
continue
# Delete message
is_admin = user_id == SYSTEM_ADMIN_EMAIL
deleted_message = MessageService.delete_message(
db=db,
message_id=ws_message.message_id,
user_id=user_id,
is_admin=is_admin
)
if not deleted_message:
await websocket.send_json(
ErrorMessage(error="Cannot delete message", code="DELETE_FAILED").dict()
)
continue
# Broadcast deletion to all room members
await manager.broadcast_to_room(
room_id,
{"type": "delete_message", "message_id": deleted_message.message_id}
)
elif ws_message.type == WebSocketMessageType.ADD_REACTION:
if not ws_message.message_id or not ws_message.emoji:
await websocket.send_json(
ErrorMessage(error="Missing message_id or emoji", code="INVALID_REQUEST").dict()
)
continue
# Add reaction
reaction = MessageService.add_reaction(
db=db,
message_id=ws_message.message_id,
user_id=user_id,
emoji=ws_message.emoji
)
if reaction:
# Broadcast reaction to all room members
await manager.broadcast_to_room(
room_id,
{
"type": "add_reaction",
"message_id": ws_message.message_id,
"user_id": user_id,
"emoji": ws_message.emoji
}
)
elif ws_message.type == WebSocketMessageType.REMOVE_REACTION:
if not ws_message.message_id or not ws_message.emoji:
await websocket.send_json(
ErrorMessage(error="Missing message_id or emoji", code="INVALID_REQUEST").dict()
)
continue
# Remove reaction
removed = MessageService.remove_reaction(
db=db,
message_id=ws_message.message_id,
user_id=user_id,
emoji=ws_message.emoji
)
if removed:
# Broadcast reaction removal to all room members
await manager.broadcast_to_room(
room_id,
{
"type": "remove_reaction",
"message_id": ws_message.message_id,
"user_id": user_id,
"emoji": ws_message.emoji
}
)
elif ws_message.type == WebSocketMessageType.TYPING:
# Set typing status
is_typing = message_data.get("is_typing", True)
await manager.set_typing(room_id, user_id, is_typing)
# Broadcast typing status to other room members
await manager.broadcast_to_room(
room_id,
{"type": "typing", "user_id": user_id, "is_typing": is_typing},
exclude_user=user_id
)
except WebSocketDisconnect:
pass
finally:
# Disconnect and broadcast user left event
await manager.disconnect(conn_info)
await manager.broadcast_to_room(
room_id,
SystemMessageBroadcast(
event=SystemEventType.USER_LEFT,
user_id=user_id,
room_id=room_id,
timestamp=datetime.utcnow()
).dict()
)
finally:
db.close()
# REST API endpoints
@router.get("/rooms/{room_id}/messages", response_model=MessageListResponse)
async def get_messages(
room_id: str,
limit: int = Query(50, ge=1, le=100),
before: Optional[datetime] = None,
offset: int = Query(0, ge=0),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Get message history for a room"""
user_id = current_user["username"]
# Check room membership
membership = get_user_room_membership(db, room_id, user_id)
if not membership and user_id != SYSTEM_ADMIN_EMAIL:
raise HTTPException(status_code=403, detail="Not a member of this room")
return MessageService.get_messages(
db=db,
room_id=room_id,
limit=limit,
before_timestamp=before,
offset=offset
)
@router.post("/rooms/{room_id}/messages", response_model=MessageResponse, status_code=201)
async def create_message(
room_id: str,
message: MessageCreate,
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Create a message via REST API (alternative to WebSocket)"""
user_id = current_user["username"]
# Check room membership and write permission
membership = get_user_room_membership(db, room_id, user_id)
if not can_write_message(membership, user_id):
raise HTTPException(status_code=403, detail="Insufficient permissions")
# Create message
created_message = MessageService.create_message(
db=db,
room_id=room_id,
sender_id=user_id,
content=message.content,
message_type=MessageType(message.message_type.value),
metadata=message.metadata
)
# Broadcast to WebSocket connections
await manager.broadcast_to_room(
room_id,
MessageBroadcast(
message_id=created_message.message_id,
room_id=created_message.room_id,
sender_id=created_message.sender_id,
content=created_message.content,
message_type=MessageTypeEnum(created_message.message_type.value),
metadata=created_message.message_metadata,
created_at=created_message.created_at,
sequence_number=created_message.sequence_number
).dict()
)
return MessageResponse.from_orm(created_message)
@router.get("/rooms/{room_id}/messages/search", response_model=MessageListResponse)
async def search_messages(
room_id: str,
q: str = Query(..., min_length=1),
limit: int = Query(50, ge=1, le=100),
offset: int = Query(0, ge=0),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Search messages in a room"""
user_id = current_user["username"]
# Check room membership
membership = get_user_room_membership(db, room_id, user_id)
if not membership and user_id != SYSTEM_ADMIN_EMAIL:
raise HTTPException(status_code=403, detail="Not a member of this room")
return MessageService.search_messages(
db=db,
room_id=room_id,
query=q,
limit=limit,
offset=offset
)
@router.get("/rooms/{room_id}/online")
async def get_online_users(
room_id: str,
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Get list of online users in a room"""
user_id = current_user["username"]
# Check room membership
membership = get_user_room_membership(db, room_id, user_id)
if not membership and user_id != SYSTEM_ADMIN_EMAIL:
raise HTTPException(status_code=403, detail="Not a member of this room")
online_users = manager.get_online_users(room_id)
return {"room_id": room_id, "online_users": online_users, "count": len(online_users)}
@router.get("/rooms/{room_id}/typing")
async def get_typing_users(
room_id: str,
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Get list of users currently typing in a room"""
user_id = current_user["username"]
# Check room membership
membership = get_user_room_membership(db, room_id, user_id)
if not membership and user_id != SYSTEM_ADMIN_EMAIL:
raise HTTPException(status_code=403, detail="Not a member of this room")
typing_users = manager.get_typing_users(room_id)
return {"room_id": room_id, "typing_users": typing_users, "count": len(typing_users)}

View File

@@ -0,0 +1,262 @@
"""Pydantic schemas for WebSocket messages and REST API"""
from pydantic import BaseModel, Field
from typing import Optional, Dict, Any, List
from datetime import datetime
from enum import Enum
class MessageTypeEnum(str, Enum):
"""Message type enumeration for validation"""
TEXT = "text"
IMAGE_REF = "image_ref"
FILE_REF = "file_ref"
SYSTEM = "system"
INCIDENT_DATA = "incident_data"
class WebSocketMessageType(str, Enum):
"""WebSocket message type for protocol"""
MESSAGE = "message"
EDIT_MESSAGE = "edit_message"
DELETE_MESSAGE = "delete_message"
ADD_REACTION = "add_reaction"
REMOVE_REACTION = "remove_reaction"
TYPING = "typing"
SYSTEM = "system"
class SystemEventType(str, Enum):
"""System event types"""
USER_JOINED = "user_joined"
USER_LEFT = "user_left"
ROOM_STATUS_CHANGED = "room_status_changed"
MEMBER_ADDED = "member_added"
MEMBER_REMOVED = "member_removed"
FILE_UPLOADED = "file_uploaded"
FILE_DELETED = "file_deleted"
# WebSocket Incoming Messages (from client)
class WebSocketMessageIn(BaseModel):
"""Incoming WebSocket message from client"""
type: WebSocketMessageType
content: Optional[str] = None
message_type: Optional[MessageTypeEnum] = MessageTypeEnum.TEXT
message_id: Optional[str] = None # For edit/delete/reaction operations
emoji: Optional[str] = None # For reactions
metadata: Optional[Dict[str, Any]] = None # For mentions, file refs, etc.
class TextMessageIn(BaseModel):
"""Text message input"""
content: str = Field(..., min_length=1, max_length=10000)
mentions: Optional[List[str]] = None
class ImageRefMessageIn(BaseModel):
"""Image reference message input"""
content: str # Description
file_id: str
file_url: str
class FileRefMessageIn(BaseModel):
"""File reference message input"""
content: str # Description
file_id: str
file_url: str
file_name: str
class IncidentDataMessageIn(BaseModel):
"""Structured incident data message input"""
content: Dict[str, Any] # Structured data (temperature, pressure, etc.)
# WebSocket Outgoing Messages (to client)
class MessageBroadcast(BaseModel):
"""Message broadcast to all room members"""
type: str = "message"
message_id: str
room_id: str
sender_id: str
content: str
message_type: MessageTypeEnum
metadata: Optional[Dict[str, Any]] = None
created_at: datetime
edited_at: Optional[datetime] = None
deleted_at: Optional[datetime] = None
sequence_number: int
class SystemMessageBroadcast(BaseModel):
"""System message broadcast"""
type: str = "system"
event: SystemEventType
user_id: Optional[str] = None
room_id: Optional[str] = None
timestamp: datetime
data: Optional[Dict[str, Any]] = None
class TypingBroadcast(BaseModel):
"""Typing indicator broadcast"""
type: str = "typing"
room_id: str
user_id: str
is_typing: bool
class MessageAck(BaseModel):
"""Message acknowledgment"""
type: str = "ack"
message_id: str
sequence_number: int
timestamp: datetime
class ErrorMessage(BaseModel):
"""Error message"""
type: str = "error"
error: str
code: str
details: Optional[Dict[str, Any]] = None
# REST API Schemas
class MessageCreate(BaseModel):
"""Create message via REST API"""
content: str = Field(..., min_length=1, max_length=10000)
message_type: MessageTypeEnum = MessageTypeEnum.TEXT
metadata: Optional[Dict[str, Any]] = None
class MessageUpdate(BaseModel):
"""Update message content"""
content: str = Field(..., min_length=1, max_length=10000)
class MessageResponse(BaseModel):
"""Message response"""
message_id: str
room_id: str
sender_id: str
content: str
message_type: MessageTypeEnum
metadata: Optional[Dict[str, Any]] = Field(None, alias="message_metadata")
created_at: datetime
edited_at: Optional[datetime] = None
deleted_at: Optional[datetime] = None
sequence_number: int
reaction_counts: Optional[Dict[str, int]] = None # emoji -> count
class Config:
from_attributes = True
populate_by_name = True # Allow both 'metadata' and 'message_metadata'
class MessageListResponse(BaseModel):
"""Paginated message list response"""
messages: List[MessageResponse]
total: int
limit: int
offset: int
has_more: bool
class ReactionCreate(BaseModel):
"""Add reaction to message"""
emoji: str = Field(..., min_length=1, max_length=10)
class ReactionResponse(BaseModel):
"""Reaction response"""
reaction_id: int
message_id: str
user_id: str
emoji: str
created_at: datetime
class Config:
from_attributes = True
class ReactionSummary(BaseModel):
"""Reaction summary for a message"""
emoji: str
count: int
users: List[str] # List of user IDs who reacted
class OnlineUser(BaseModel):
"""Online user in a room"""
user_id: str
connected_at: datetime
# File Upload WebSocket Schemas
class FileUploadedBroadcast(BaseModel):
"""Broadcast when a file is uploaded to a room"""
type: str = "file_uploaded"
file_id: str
room_id: str
uploader_id: str
filename: str
file_type: str # image, document, log
file_size: int
mime_type: str
download_url: Optional[str] = None
uploaded_at: datetime
def to_dict(self) -> dict:
"""Convert to dictionary for WebSocket broadcast"""
return {
"type": self.type,
"file_id": self.file_id,
"room_id": self.room_id,
"uploader_id": self.uploader_id,
"filename": self.filename,
"file_type": self.file_type,
"file_size": self.file_size,
"mime_type": self.mime_type,
"download_url": self.download_url,
"uploaded_at": self.uploaded_at.isoformat()
}
class FileUploadAck(BaseModel):
"""Acknowledgment sent to uploader after successful upload"""
type: str = "file_upload_ack"
file_id: str
status: str # success, error
download_url: Optional[str] = None
error_message: Optional[str] = None
def to_dict(self) -> dict:
"""Convert to dictionary for WebSocket message"""
return {
"type": self.type,
"file_id": self.file_id,
"status": self.status,
"download_url": self.download_url,
"error_message": self.error_message
}
class FileDeletedBroadcast(BaseModel):
"""Broadcast when a file is deleted from a room"""
type: str = "file_deleted"
file_id: str
room_id: str
deleted_by: str
deleted_at: datetime
def to_dict(self) -> dict:
"""Convert to dictionary for WebSocket broadcast"""
return {
"type": self.type,
"file_id": self.file_id,
"room_id": self.room_id,
"deleted_by": self.deleted_by,
"deleted_at": self.deleted_at.isoformat()
}

View File

@@ -0,0 +1 @@
"""Service layer for realtime messaging"""

View File

@@ -0,0 +1,406 @@
"""Message service layer for database operations"""
from sqlalchemy.orm import Session
from sqlalchemy import desc, and_, func
from typing import List, Optional, Dict, Any
from datetime import datetime, timedelta
import uuid
from app.modules.realtime.models import Message, MessageType, MessageReaction, MessageEditHistory
from app.modules.realtime.schemas import (
MessageCreate,
MessageResponse,
MessageListResponse,
ReactionSummary
)
class MessageService:
"""Service for message operations"""
@staticmethod
def create_message(
db: Session,
room_id: str,
sender_id: str,
content: str,
message_type: MessageType = MessageType.TEXT,
metadata: Optional[Dict[str, Any]] = None
) -> Message:
"""
Create a new message
Args:
db: Database session
room_id: Room ID
sender_id: User ID who sent the message
content: Message content
message_type: Type of message
metadata: Optional metadata (mentions, file refs, etc.)
Returns:
Created Message object
"""
# Get next sequence number for this room
max_seq = db.query(func.max(Message.sequence_number)).filter(
Message.room_id == room_id
).scalar()
next_seq = (max_seq or 0) + 1
message = Message(
message_id=str(uuid.uuid4()),
room_id=room_id,
sender_id=sender_id,
content=content,
message_type=message_type,
message_metadata=metadata or {},
created_at=datetime.utcnow(),
sequence_number=next_seq
)
db.add(message)
db.commit()
db.refresh(message)
return message
@staticmethod
def get_message(db: Session, message_id: str) -> Optional[Message]:
"""
Get a message by ID
Args:
db: Database session
message_id: Message ID
Returns:
Message object or None
"""
return db.query(Message).filter(
Message.message_id == message_id,
Message.deleted_at.is_(None)
).first()
@staticmethod
def get_messages(
db: Session,
room_id: str,
limit: int = 50,
before_timestamp: Optional[datetime] = None,
offset: int = 0,
include_deleted: bool = False
) -> MessageListResponse:
"""
Get paginated messages for a room
Args:
db: Database session
room_id: Room ID
limit: Number of messages to return
before_timestamp: Get messages before this timestamp
offset: Offset for pagination
include_deleted: Include soft-deleted messages
Returns:
MessageListResponse with messages and pagination info
"""
query = db.query(Message).filter(Message.room_id == room_id)
if not include_deleted:
query = query.filter(Message.deleted_at.is_(None))
if before_timestamp:
query = query.filter(Message.created_at < before_timestamp)
# Get total count
total = query.count()
# Get messages in reverse chronological order
messages = query.order_by(desc(Message.created_at)).offset(offset).limit(limit).all()
# Get reaction counts for each message
message_responses = []
for msg in messages:
reaction_counts = MessageService._get_reaction_counts(db, msg.message_id)
msg_response = MessageResponse.from_orm(msg)
msg_response.reaction_counts = reaction_counts
message_responses.append(msg_response)
return MessageListResponse(
messages=message_responses,
total=total,
limit=limit,
offset=offset,
has_more=(offset + len(messages)) < total
)
@staticmethod
def edit_message(
db: Session,
message_id: str,
user_id: str,
new_content: str
) -> Optional[Message]:
"""
Edit a message (must be own message and within 15 minutes)
Args:
db: Database session
message_id: Message ID to edit
user_id: User ID making the edit
new_content: New message content
Returns:
Updated Message object or None if not allowed
"""
message = db.query(Message).filter(Message.message_id == message_id).first()
if not message:
return None
# Check permissions
if message.sender_id != user_id:
return None
# Check time limit (15 minutes)
time_diff = datetime.utcnow() - message.created_at
if time_diff > timedelta(minutes=15):
return None
# Store original content in edit history
edit_history = MessageEditHistory(
message_id=message_id,
original_content=message.content,
edited_by=user_id,
edited_at=datetime.utcnow()
)
db.add(edit_history)
# Update message
message.content = new_content
message.edited_at = datetime.utcnow()
db.commit()
db.refresh(message)
return message
@staticmethod
def delete_message(
db: Session,
message_id: str,
user_id: str,
is_admin: bool = False
) -> Optional[Message]:
"""
Soft delete a message
Args:
db: Database session
message_id: Message ID to delete
user_id: User ID making the deletion
is_admin: Whether user is admin (can delete any message)
Returns:
Deleted Message object or None if not allowed
"""
message = db.query(Message).filter(Message.message_id == message_id).first()
if not message:
return None
# Check permissions (owner or admin)
if not is_admin and message.sender_id != user_id:
return None
# Soft delete
message.deleted_at = datetime.utcnow()
db.commit()
db.refresh(message)
return message
@staticmethod
def search_messages(
db: Session,
room_id: str,
query: str,
limit: int = 50,
offset: int = 0
) -> MessageListResponse:
"""
Search messages by content
Args:
db: Database session
room_id: Room ID to search in
query: Search query
limit: Number of results
offset: Offset for pagination
Returns:
MessageListResponse with search results
"""
# Simple LIKE search (for PostgreSQL, use full-text search)
search_filter = and_(
Message.room_id == room_id,
Message.deleted_at.is_(None),
Message.content.contains(query)
)
total = db.query(Message).filter(search_filter).count()
messages = (
db.query(Message)
.filter(search_filter)
.order_by(desc(Message.created_at))
.offset(offset)
.limit(limit)
.all()
)
message_responses = []
for msg in messages:
reaction_counts = MessageService._get_reaction_counts(db, msg.message_id)
msg_response = MessageResponse.from_orm(msg)
msg_response.reaction_counts = reaction_counts
message_responses.append(msg_response)
return MessageListResponse(
messages=message_responses,
total=total,
limit=limit,
offset=offset,
has_more=(offset + len(messages)) < total
)
@staticmethod
def add_reaction(
db: Session,
message_id: str,
user_id: str,
emoji: str
) -> Optional[MessageReaction]:
"""
Add a reaction to a message
Args:
db: Database session
message_id: Message ID
user_id: User ID adding reaction
emoji: Emoji character
Returns:
MessageReaction object or None if already exists
"""
# Check if reaction already exists
existing = db.query(MessageReaction).filter(
and_(
MessageReaction.message_id == message_id,
MessageReaction.user_id == user_id,
MessageReaction.emoji == emoji
)
).first()
if existing:
return existing
reaction = MessageReaction(
message_id=message_id,
user_id=user_id,
emoji=emoji,
created_at=datetime.utcnow()
)
db.add(reaction)
db.commit()
db.refresh(reaction)
return reaction
@staticmethod
def remove_reaction(
db: Session,
message_id: str,
user_id: str,
emoji: str
) -> bool:
"""
Remove a reaction from a message
Args:
db: Database session
message_id: Message ID
user_id: User ID removing reaction
emoji: Emoji character
Returns:
True if removed, False if not found
"""
reaction = db.query(MessageReaction).filter(
and_(
MessageReaction.message_id == message_id,
MessageReaction.user_id == user_id,
MessageReaction.emoji == emoji
)
).first()
if not reaction:
return False
db.delete(reaction)
db.commit()
return True
@staticmethod
def get_message_reactions(
db: Session,
message_id: str
) -> List[ReactionSummary]:
"""
Get aggregated reactions for a message
Args:
db: Database session
message_id: Message ID
Returns:
List of ReactionSummary objects
"""
reactions = db.query(MessageReaction).filter(
MessageReaction.message_id == message_id
).all()
# Group by emoji
reaction_map: Dict[str, List[str]] = {}
for reaction in reactions:
if reaction.emoji not in reaction_map:
reaction_map[reaction.emoji] = []
reaction_map[reaction.emoji].append(reaction.user_id)
return [
ReactionSummary(emoji=emoji, count=len(users), users=users)
for emoji, users in reaction_map.items()
]
@staticmethod
def _get_reaction_counts(db: Session, message_id: str) -> Dict[str, int]:
"""
Get reaction counts for a message
Args:
db: Database session
message_id: Message ID
Returns:
Dictionary of emoji -> count
"""
result = (
db.query(MessageReaction.emoji, func.count(MessageReaction.reaction_id))
.filter(MessageReaction.message_id == message_id)
.group_by(MessageReaction.emoji)
.all()
)
return {emoji: count for emoji, count in result}

View File

@@ -0,0 +1,231 @@
"""WebSocket connection pool management"""
from fastapi import WebSocket
from typing import Dict, List, Set
from datetime import datetime
import asyncio
import json
from collections import defaultdict
class ConnectionInfo:
"""Information about a WebSocket connection"""
def __init__(self, websocket: WebSocket, user_id: str, room_id: str):
self.websocket = websocket
self.user_id = user_id
self.room_id = room_id
self.connected_at = datetime.utcnow()
self.last_sequence = 0 # Track last received sequence number for reconnection
class WebSocketManager:
"""Manages WebSocket connections and message broadcasting"""
def __init__(self):
# room_id -> Set of ConnectionInfo
self._room_connections: Dict[str, Set[ConnectionInfo]] = defaultdict(set)
# user_id -> ConnectionInfo (for direct messaging)
self._user_connections: Dict[str, ConnectionInfo] = {}
# room_id -> Set of user_ids (typing users)
self._typing_users: Dict[str, Set[str]] = defaultdict(set)
# user_id -> asyncio.Task (typing timeout tasks)
self._typing_tasks: Dict[str, asyncio.Task] = {}
async def connect(self, websocket: WebSocket, room_id: str, user_id: str) -> ConnectionInfo:
"""
Add a WebSocket connection to the pool
Args:
websocket: The WebSocket connection
room_id: Room ID the user is connecting to
user_id: User ID
Returns:
ConnectionInfo object
"""
await websocket.accept()
conn_info = ConnectionInfo(websocket, user_id, room_id)
self._room_connections[room_id].add(conn_info)
self._user_connections[user_id] = conn_info
return conn_info
async def disconnect(self, conn_info: ConnectionInfo):
"""
Remove a WebSocket connection from the pool
Args:
conn_info: Connection info to remove
"""
room_id = conn_info.room_id
user_id = conn_info.user_id
# Remove from room connections
if room_id in self._room_connections:
self._room_connections[room_id].discard(conn_info)
if not self._room_connections[room_id]:
del self._room_connections[room_id]
# Remove from user connections
if user_id in self._user_connections:
del self._user_connections[user_id]
# Clear typing status
if user_id in self._typing_tasks:
self._typing_tasks[user_id].cancel()
del self._typing_tasks[user_id]
if room_id in self._typing_users:
self._typing_users[room_id].discard(user_id)
async def broadcast_to_room(self, room_id: str, message: dict, exclude_user: str = None):
"""
Broadcast a message to all connections in a room
Args:
room_id: Room ID to broadcast to
message: Message dictionary to broadcast
exclude_user: Optional user ID to exclude from broadcast
"""
if room_id not in self._room_connections:
return
message_json = json.dumps(message)
# Collect disconnected connections
disconnected = []
for conn_info in self._room_connections[room_id]:
if exclude_user and conn_info.user_id == exclude_user:
continue
try:
await conn_info.websocket.send_text(message_json)
except Exception as e:
# Connection failed, mark for removal
disconnected.append(conn_info)
# Clean up disconnected connections
for conn_info in disconnected:
await self.disconnect(conn_info)
async def send_personal(self, user_id: str, message: dict):
"""
Send a message to a specific user
Args:
user_id: User ID to send to
message: Message dictionary to send
"""
if user_id not in self._user_connections:
return
conn_info = self._user_connections[user_id]
message_json = json.dumps(message)
try:
await conn_info.websocket.send_text(message_json)
except Exception:
# Connection failed, disconnect
await self.disconnect(conn_info)
def get_room_connections(self, room_id: str) -> List[ConnectionInfo]:
"""
Get all active connections for a room
Args:
room_id: Room ID
Returns:
List of ConnectionInfo objects
"""
if room_id not in self._room_connections:
return []
return list(self._room_connections[room_id])
def get_online_users(self, room_id: str) -> List[str]:
"""
Get list of online user IDs in a room
Args:
room_id: Room ID
Returns:
List of user IDs
"""
return [conn.user_id for conn in self.get_room_connections(room_id)]
def is_user_online(self, user_id: str) -> bool:
"""
Check if a user is currently connected
Args:
user_id: User ID to check
Returns:
True if user is connected
"""
return user_id in self._user_connections
async def set_typing(self, room_id: str, user_id: str, is_typing: bool):
"""
Set typing status for a user in a room
Args:
room_id: Room ID
user_id: User ID
is_typing: Whether user is typing
"""
if is_typing:
self._typing_users[room_id].add(user_id)
# Cancel existing timeout task
if user_id in self._typing_tasks:
self._typing_tasks[user_id].cancel()
# Set new timeout (3 seconds)
async def clear_typing():
await asyncio.sleep(3)
self._typing_users[room_id].discard(user_id)
if user_id in self._typing_tasks:
del self._typing_tasks[user_id]
self._typing_tasks[user_id] = asyncio.create_task(clear_typing())
else:
self._typing_users[room_id].discard(user_id)
if user_id in self._typing_tasks:
self._typing_tasks[user_id].cancel()
del self._typing_tasks[user_id]
def get_typing_users(self, room_id: str) -> List[str]:
"""
Get list of users currently typing in a room
Args:
room_id: Room ID
Returns:
List of user IDs
"""
if room_id not in self._typing_users:
return []
return list(self._typing_users[room_id])
async def send_heartbeat(self, conn_info: ConnectionInfo):
"""
Send a ping to check connection health
Args:
conn_info: Connection to ping
"""
try:
await conn_info.websocket.send_json({"type": "ping"})
except Exception:
await self.disconnect(conn_info)
# Global WebSocket manager instance
manager = WebSocketManager()