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

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