feat: Add Chat UX improvements with notifications and @mention support

- Add ActionBar component with expandable toolbar for mobile
- Add @mention functionality with autocomplete dropdown
- Add browser notification system (push, sound, vibration)
- Add NotificationSettings modal for user preferences
- Add mention badges on room list cards
- Add ReportPreview with Markdown rendering and copy/download
- Add message copy functionality with hover actions
- Add backend mentions field to messages with Alembic migration
- Add lots field to rooms, remove templates
- Optimize WebSocket database session handling
- Various UX polish (animations, accessibility)

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
egg
2025-12-08 08:20:37 +08:00
parent 92834dbe0e
commit 599802b818
72 changed files with 6810 additions and 702 deletions

View File

@@ -61,6 +61,12 @@ class Settings(BaseSettings):
REPORT_MAX_MESSAGES: int = 200 # Summarize if exceeded
REPORT_STORAGE_PATH: str = "reports" # MinIO path prefix for reports
# Database Connection Pool
DB_POOL_SIZE: int = 20 # Number of persistent connections
DB_MAX_OVERFLOW: int = 30 # Max additional connections beyond pool_size
DB_POOL_TIMEOUT: int = 10 # Seconds to wait for available connection
DB_POOL_RECYCLE: int = 1800 # Recycle connections after 30 minutes
@field_validator("LOG_LEVEL")
@classmethod
def validate_log_level(cls, v: str) -> str:

View File

@@ -3,20 +3,23 @@
Supports MySQL database with connection pooling.
All tables use 'tr_' prefix to avoid conflicts in shared database.
"""
from contextlib import contextmanager
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
from sqlalchemy.orm import sessionmaker, Session
from app.core.config import get_settings
settings = get_settings()
# Create engine with MySQL connection pooling
# Pool settings are configurable via environment variables
engine = create_engine(
settings.DATABASE_URL,
pool_size=5,
max_overflow=10,
pool_size=settings.DB_POOL_SIZE,
max_overflow=settings.DB_MAX_OVERFLOW,
pool_timeout=settings.DB_POOL_TIMEOUT,
pool_pre_ping=True, # Verify connection before using
pool_recycle=3600, # Recycle connections after 1 hour
pool_recycle=settings.DB_POOL_RECYCLE,
echo=settings.DEBUG,
)
@@ -34,3 +37,21 @@ def get_db():
yield db
finally:
db.close()
@contextmanager
def get_db_context() -> Session:
"""Context manager for short-lived database sessions.
Use this for operations that need a database session but should
not hold onto it for the entire request/connection lifecycle.
Example:
with get_db_context() as db:
result = db.query(Model).filter(...).first()
"""
db = SessionLocal()
try:
yield db
finally:
db.close()

View File

@@ -13,10 +13,10 @@ from app.modules.auth import router as auth_router
from app.modules.auth.users_router import router as users_router
from app.modules.auth.middleware import auth_middleware
from app.modules.chat_room import router as chat_room_router
from app.modules.chat_room.services.template_service import template_service
from app.modules.realtime import router as realtime_router
from app.modules.file_storage import router as file_storage_router
from app.modules.report_generation import router as report_generation_router
from app.modules.report_generation import health_router as report_health_router
# Frontend build directory
FRONTEND_DIR = Path(__file__).parent.parent / "frontend" / "dist"
@@ -53,24 +53,17 @@ app.include_router(chat_room_router)
app.include_router(realtime_router)
app.include_router(file_storage_router)
app.include_router(report_generation_router)
app.include_router(report_health_router)
@app.on_event("startup")
async def startup_event():
"""Initialize application on startup"""
from app.core.database import SessionLocal
from app.core.minio_client import initialize_bucket
import logging
logger = logging.getLogger(__name__)
# Initialize default templates
db = SessionLocal()
try:
template_service.initialize_default_templates(db)
finally:
db.close()
# Initialize MinIO bucket
try:
if initialize_bucket():
@@ -80,6 +73,10 @@ async def startup_event():
except Exception as e:
logger.warning(f"MinIO connection failed: {e} - file uploads will be unavailable")
# Check DIFY API Key configuration
if not settings.DIFY_API_KEY:
logger.warning("DIFY_API_KEY not configured - AI report generation will be unavailable")
@app.get("/api/health")
async def health_check():

View File

@@ -26,6 +26,10 @@ class AuthMiddleware:
async def __call__(self, request: Request, call_next):
"""Process request through authentication checks"""
# Skip auth for CORS preflight requests (OPTIONS)
if request.method == "OPTIONS":
return await call_next(request)
# Skip auth for non-API routes (frontend), login/logout, and docs
path = request.url.path
if not path.startswith("/api") or path in ["/api/auth/login", "/api/auth/logout", "/api/health"]:

View File

@@ -3,17 +3,14 @@
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.models import IncidentRoom, RoomMember
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

@@ -3,11 +3,10 @@
Tables:
- tr_incident_rooms: Stores room metadata and configuration
- tr_room_members: User-room associations with roles
- tr_room_templates: Predefined templates for common incident types
Note: All tables use 'tr_' prefix to avoid conflicts in shared database.
"""
from sqlalchemy import Column, Integer, String, Text, DateTime, Enum, ForeignKey, UniqueConstraint, Index
from sqlalchemy import Column, Integer, String, Text, DateTime, Enum, ForeignKey, UniqueConstraint, Index, JSON
from sqlalchemy.orm import relationship
from datetime import datetime
import enum
@@ -58,6 +57,7 @@ class IncidentRoom(Base):
location = Column(String(255))
description = Column(Text)
resolution_notes = Column(Text)
lots = Column(JSON, default=list, nullable=False, comment="LOT batch numbers (JSON array)")
# User tracking
created_by = Column(String(255), nullable=False) # User email/ID who created the room
@@ -111,18 +111,4 @@ class RoomMember(Base):
UniqueConstraint("room_id", "user_id", "removed_at", name="uq_tr_room_member_active"),
Index("ix_tr_room_members_room_user", "room_id", "user_id"),
Index("ix_tr_room_members_user", "user_id"),
)
class RoomTemplate(Base):
"""Predefined templates for common incident types"""
__tablename__ = "tr_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

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

View File

@@ -46,7 +46,7 @@ class CreateRoomRequest(BaseModel):
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")
lots: Optional[List[str]] = Field(None, description="LOT batch numbers")
class UpdateRoomRequest(BaseModel):
@@ -57,6 +57,7 @@ class UpdateRoomRequest(BaseModel):
location: Optional[str] = Field(None, max_length=255)
description: Optional[str] = None
resolution_notes: Optional[str] = None
lots: Optional[List[str]] = Field(None, description="LOT batch numbers")
class AddMemberRequest(BaseModel):
@@ -111,6 +112,7 @@ class RoomResponse(BaseModel):
location: Optional[str] = None
description: Optional[str] = None
resolution_notes: Optional[str] = None
lots: List[str] = []
created_by: str
created_at: datetime
resolved_at: Optional[datetime] = None
@@ -137,18 +139,9 @@ class RoomListResponse(BaseModel):
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 AddLotRequest(BaseModel):
"""Request to add a LOT to a room"""
lot: str = Field(..., min_length=1, description="LOT batch number to add")
class PermissionResponse(BaseModel):

View File

@@ -4,10 +4,8 @@ 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

@@ -42,6 +42,7 @@ class RoomService:
severity=room_data.severity,
location=room_data.location,
description=room_data.description,
lots=room_data.lots or [],
status=RoomStatus.ACTIVE,
created_by=user_id,
created_at=datetime.utcnow(),
@@ -219,6 +220,9 @@ class RoomService:
if updates.resolution_notes is not None:
room.resolution_notes = updates.resolution_notes
if updates.lots is not None:
room.lots = updates.lots
# Handle status transitions
if updates.status is not None:
if not self._validate_status_transition(room.status, updates.status):

View File

@@ -1,179 +0,0 @@
"""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

@@ -35,9 +35,12 @@ class Message(Base):
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 for structured data, file references, etc.
message_metadata = Column(JSON)
# @Mention tracking - stores array of mentioned user_ids
mentions = Column(JSON, default=list)
# Timestamps
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
edited_at = Column(DateTime) # Last edit timestamp

View File

@@ -6,7 +6,7 @@ from typing import Optional
from datetime import datetime
import json
from app.core.database import get_db
from app.core.database import get_db, get_db_context
from app.core.config import get_settings
from app.modules.auth.dependencies import get_current_user
from app.modules.auth.services.session_service import session_service
@@ -87,15 +87,17 @@ async def websocket_endpoint(
3. Connection added to pool
4. User joined event broadcast to room
5. Client can send/receive messages
Note: Uses short-lived database sessions for each operation to prevent
connection pool exhaustion with many concurrent WebSocket connections.
"""
db: Session = next(get_db())
try:
# Authenticate token via session lookup
if not token:
await websocket.close(code=4001, reason="Authentication required")
return
# Authenticate and get user info using short-lived session
if not token:
await websocket.close(code=4001, reason="Authentication required")
return
# Authenticate token and check membership with short session
with get_db_context() as db:
user_session = session_service.get_session_by_token(db, token)
if not user_session:
await websocket.close(code=4001, reason="Invalid or expired token")
@@ -103,55 +105,59 @@ async def websocket_endpoint(
user_id = user_session.username
# Check room membership
# Check room membership and cache the role
membership = get_user_room_membership(db, room_id, user_id)
if not membership and not is_system_admin(user_id):
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)
# Cache membership role for permission checks (avoid holding DB reference)
user_role = membership.role if membership else None
# 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
)
# Connect to WebSocket manager (no DB needed)
conn_info = await manager.connect(websocket, room_id, user_id)
try:
while True:
# Receive message from client
data = await websocket.receive_text()
message_data = json.loads(data)
# Broadcast user joined event (no DB needed)
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
)
# Parse incoming message
try:
ws_message = WebSocketMessageIn(**message_data)
except Exception as e:
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 ws_send_json(websocket,
ErrorMessage(error=str(e), code="INVALID_MESSAGE").dict()
)
continue
# Handle different message types
if ws_message.type == WebSocketMessageType.MESSAGE:
# Check write permission using cached role
if not _can_write_with_role(user_role, user_id):
await ws_send_json(websocket,
ErrorMessage(error=str(e), code="INVALID_MESSAGE").dict()
ErrorMessage(
error="Insufficient permissions",
code="PERMISSION_DENIED"
).dict()
)
continue
# Handle different message types
if ws_message.type == WebSocketMessageType.MESSAGE:
# Check write permission
if not can_write_message(membership, user_id):
await ws_send_json(websocket,
ErrorMessage(
error="Insufficient permissions",
code="PERMISSION_DENIED"
).dict()
)
continue
# Create message in database
# Create message in database with short session
with get_db_context() as db:
message = MessageService.create_message(
db=db,
room_id=room_id,
@@ -160,131 +166,170 @@ async def websocket_endpoint(
message_type=MessageType(ws_message.message_type.value) if ws_message.message_type else MessageType.TEXT,
metadata=ws_message.metadata
)
# Get sender display name
display_name = MessageService.get_display_name(db, user_id)
# Extract data before session closes
msg_data = {
"message_id": message.message_id,
"room_id": message.room_id,
"sender_id": message.sender_id,
"sender_display_name": display_name or user_id,
"content": message.content,
"message_type": message.message_type.value,
"metadata": message.message_metadata,
"created_at": message.created_at,
"sequence_number": message.sequence_number
}
# Send acknowledgment to sender
# Send acknowledgment to sender
await ws_send_json(websocket,
MessageAck(
message_id=msg_data["message_id"],
sequence_number=msg_data["sequence_number"],
timestamp=msg_data["created_at"]
).dict()
)
# Broadcast message to all room members
await manager.broadcast_to_room(
room_id,
MessageBroadcast(
message_id=msg_data["message_id"],
room_id=msg_data["room_id"],
sender_id=msg_data["sender_id"],
sender_display_name=msg_data["sender_display_name"],
content=msg_data["content"],
message_type=MessageTypeEnum(msg_data["message_type"]),
metadata=msg_data["metadata"],
created_at=msg_data["created_at"],
sequence_number=msg_data["sequence_number"]
).dict()
)
elif ws_message.type == WebSocketMessageType.EDIT_MESSAGE:
if not ws_message.message_id or not ws_message.content:
await ws_send_json(websocket,
MessageAck(
message_id=message.message_id,
sequence_number=message.sequence_number,
timestamp=message.created_at
).dict()
ErrorMessage(error="Missing message_id or content", code="INVALID_REQUEST").dict()
)
continue
# 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 ws_send_json(websocket,
ErrorMessage(error="Missing message_id or content", code="INVALID_REQUEST").dict()
)
continue
# Edit message
# Edit message with short session
with get_db_context() as db:
edited_message = MessageService.edit_message(
db=db,
message_id=ws_message.message_id,
user_id=user_id,
new_content=ws_message.content
)
if edited_message:
# Get sender display name
display_name = MessageService.get_display_name(db, edited_message.sender_id)
edit_data = {
"message_id": edited_message.message_id,
"room_id": edited_message.room_id,
"sender_id": edited_message.sender_id,
"sender_display_name": display_name or edited_message.sender_id,
"content": edited_message.content,
"message_type": 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
}
else:
edit_data = None
if not edited_message:
await ws_send_json(websocket,
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()
if not edit_data:
await ws_send_json(websocket,
ErrorMessage(error="Cannot edit message", code="EDIT_FAILED").dict()
)
continue
elif ws_message.type == WebSocketMessageType.DELETE_MESSAGE:
if not ws_message.message_id:
await ws_send_json(websocket,
ErrorMessage(error="Missing message_id", code="INVALID_REQUEST").dict()
)
continue
# Broadcast edit to all room members
await manager.broadcast_to_room(
room_id,
MessageBroadcast(
type="edit_message",
message_id=edit_data["message_id"],
room_id=edit_data["room_id"],
sender_id=edit_data["sender_id"],
sender_display_name=edit_data["sender_display_name"],
content=edit_data["content"],
message_type=MessageTypeEnum(edit_data["message_type"]),
metadata=edit_data["metadata"],
created_at=edit_data["created_at"],
edited_at=edit_data["edited_at"],
sequence_number=edit_data["sequence_number"]
).dict()
)
# Delete message
elif ws_message.type == WebSocketMessageType.DELETE_MESSAGE:
if not ws_message.message_id:
await ws_send_json(websocket,
ErrorMessage(error="Missing message_id", code="INVALID_REQUEST").dict()
)
continue
# Delete message with short session
with get_db_context() as db:
deleted_message = MessageService.delete_message(
db=db,
message_id=ws_message.message_id,
user_id=user_id,
is_admin=is_system_admin(user_id)
)
deleted_msg_id = deleted_message.message_id if deleted_message else None
if not deleted_message:
await ws_send_json(websocket,
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}
if not deleted_msg_id:
await ws_send_json(websocket,
ErrorMessage(error="Cannot delete message", code="DELETE_FAILED").dict()
)
continue
elif ws_message.type == WebSocketMessageType.ADD_REACTION:
if not ws_message.message_id or not ws_message.emoji:
await ws_send_json(websocket,
ErrorMessage(error="Missing message_id or emoji", code="INVALID_REQUEST").dict()
)
continue
# Broadcast deletion to all room members
await manager.broadcast_to_room(
room_id,
{"type": "delete_message", "message_id": deleted_msg_id}
)
# Add reaction
elif ws_message.type == WebSocketMessageType.ADD_REACTION:
if not ws_message.message_id or not ws_message.emoji:
await ws_send_json(websocket,
ErrorMessage(error="Missing message_id or emoji", code="INVALID_REQUEST").dict()
)
continue
# Add reaction with short session
with get_db_context() as db:
reaction = MessageService.add_reaction(
db=db,
message_id=ws_message.message_id,
user_id=user_id,
emoji=ws_message.emoji
)
reaction_added = reaction is not None
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
}
)
if reaction_added:
# 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 ws_send_json(websocket,
ErrorMessage(error="Missing message_id or emoji", code="INVALID_REQUEST").dict()
)
continue
elif ws_message.type == WebSocketMessageType.REMOVE_REACTION:
if not ws_message.message_id or not ws_message.emoji:
await ws_send_json(websocket,
ErrorMessage(error="Missing message_id or emoji", code="INVALID_REQUEST").dict()
)
continue
# Remove reaction
# Remove reaction with short session
with get_db_context() as db:
removed = MessageService.remove_reaction(
db=db,
message_id=ws_message.message_id,
@@ -292,47 +337,53 @@ async def websocket_endpoint(
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
if removed:
# Broadcast reaction removal to all room members
await manager.broadcast_to_room(
room_id,
{"type": "typing", "user_id": user_id, "is_typing": is_typing},
exclude_user=user_id
{
"type": "remove_reaction",
"message_id": ws_message.message_id,
"user_id": user_id,
"emoji": ws_message.emoji
}
)
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()
)
elif ws_message.type == WebSocketMessageType.TYPING:
# Set typing status (no DB needed)
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:
db.close()
# 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()
)
def _can_write_with_role(role: Optional[MemberRole], user_id: str) -> bool:
"""Check if user has write permission based on cached role"""
if is_system_admin(user_id):
return True
if not role:
return False
return role in [MemberRole.OWNER, MemberRole.EDITOR]
# REST API endpoints
@@ -387,6 +438,10 @@ async def create_message(
metadata=message.metadata
)
# Get sender display name
display_name = MessageService.get_display_name(db, user_id)
sender_display_name = display_name or user_id
# Broadcast to WebSocket connections
await manager.broadcast_to_room(
room_id,
@@ -394,6 +449,7 @@ async def create_message(
message_id=created_message.message_id,
room_id=created_message.room_id,
sender_id=created_message.sender_id,
sender_display_name=sender_display_name,
content=created_message.content,
message_type=MessageTypeEnum(created_message.message_type.value),
metadata=created_message.message_metadata,
@@ -402,7 +458,10 @@ async def create_message(
).dict()
)
return MessageResponse.from_orm(created_message)
# Build response with display name
response = MessageResponse.from_orm(created_message)
response.sender_display_name = sender_display_name
return response
@router.get("/rooms/{room_id}/messages/search", response_model=MessageListResponse)

View File

@@ -80,6 +80,7 @@ class MessageBroadcast(BaseModel):
message_id: str
room_id: str
sender_id: str
sender_display_name: Optional[str] = None # Display name from users table
content: str
message_type: MessageTypeEnum
metadata: Optional[Dict[str, Any]] = None
@@ -141,6 +142,7 @@ class MessageResponse(BaseModel):
message_id: str
room_id: str
sender_id: str
sender_display_name: Optional[str] = None # Display name from users table
content: str
message_type: MessageTypeEnum
metadata: Optional[Dict[str, Any]] = Field(None, alias="message_metadata")

View File

@@ -1,9 +1,12 @@
"""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 sqlalchemy import desc, and_, func, text
from sqlalchemy.exc import IntegrityError
from typing import List, Optional, Dict, Any, Tuple
from datetime import datetime, timedelta
import uuid
import logging
import re
from app.core.config import get_settings
from app.modules.realtime.models import Message, MessageType, MessageReaction, MessageEditHistory
@@ -13,13 +16,48 @@ from app.modules.realtime.schemas import (
MessageListResponse,
ReactionSummary
)
from app.modules.auth.models import User
settings = get_settings()
logger = logging.getLogger(__name__)
class MessageService:
"""Service for message operations"""
@staticmethod
def parse_mentions(content: str, room_members: List[Dict[str, str]]) -> List[str]:
"""
Parse @mentions from message content and resolve to user IDs
Args:
content: Message content that may contain @mentions
room_members: List of room members with user_id and display_name
Returns:
List of mentioned user_ids
"""
# Pattern matches @displayname (alphanumeric, spaces, Chinese chars, etc.)
# Captures text after @ until we hit a character that's not part of a name
mention_pattern = r'@(\S+)'
matches = re.findall(mention_pattern, content)
mentioned_ids = []
for mention_text in matches:
# Try to match against display names or user IDs
for member in room_members:
display_name = member.get('display_name', '') or member.get('user_id', '')
user_id = member.get('user_id', '')
# Match against display_name or user_id (case-insensitive)
if (mention_text.lower() == display_name.lower() or
mention_text.lower() == user_id.lower()):
if user_id not in mentioned_ids:
mentioned_ids.append(user_id)
break
return mentioned_ids
@staticmethod
def create_message(
db: Session,
@@ -27,10 +65,16 @@ class MessageService:
sender_id: str,
content: str,
message_type: MessageType = MessageType.TEXT,
metadata: Optional[Dict[str, Any]] = None
metadata: Optional[Dict[str, Any]] = None,
mentions: Optional[List[str]] = None,
max_retries: int = 3
) -> Message:
"""
Create a new message
Create a new message with race condition protection
Uses SELECT ... FOR UPDATE to lock the sequence number calculation,
preventing duplicate sequence numbers when multiple users send
messages simultaneously.
Args:
db: Database session
@@ -38,34 +82,72 @@ class MessageService:
sender_id: User ID who sent the message
content: Message content
message_type: Type of message
metadata: Optional metadata (mentions, file refs, etc.)
metadata: Optional metadata (file refs, etc.)
mentions: List of mentioned user_ids (parsed from @mentions)
max_retries: Maximum retry attempts for deadlock handling
Returns:
Created Message object
Raises:
IntegrityError: If max retries exceeded
"""
# 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
last_error = None
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
for attempt in range(max_retries):
try:
# Use FOR UPDATE to lock rows while calculating next sequence
# This prevents race conditions where two transactions read
# the same max_seq and try to insert duplicate sequence numbers
result = db.execute(
text("""
SELECT COALESCE(MAX(sequence_number), 0)
FROM tr_messages
WHERE room_id = :room_id
FOR UPDATE
"""),
{"room_id": room_id}
)
max_seq = result.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 {},
mentions=mentions or [],
created_at=datetime.utcnow(),
sequence_number=next_seq
)
db.add(message)
db.commit()
db.refresh(message)
return message
except IntegrityError as e:
last_error = e
db.rollback()
logger.warning(
f"Sequence number conflict on attempt {attempt + 1}/{max_retries} "
f"for room {room_id}: {e}"
)
if attempt == max_retries - 1:
logger.error(
f"Failed to create message after {max_retries} attempts "
f"for room {room_id}"
)
raise
# Should not reach here, but just in case
raise last_error if last_error else IntegrityError(
"Failed to create message", None, None
)
db.add(message)
db.commit()
db.refresh(message)
return message
@staticmethod
def get_message(db: Session, message_id: str) -> Optional[Message]:
"""
@@ -83,6 +165,21 @@ class MessageService:
Message.deleted_at.is_(None)
).first()
@staticmethod
def get_display_name(db: Session, sender_id: str) -> Optional[str]:
"""
Get display name for a sender from users table
Args:
db: Database session
sender_id: User ID (email)
Returns:
Display name or None if not found
"""
user = db.query(User.display_name).filter(User.user_id == sender_id).first()
return user[0] if user else None
@staticmethod
def get_messages(
db: Session,
@@ -106,7 +203,10 @@ class MessageService:
Returns:
MessageListResponse with messages and pagination info
"""
query = db.query(Message).filter(Message.room_id == room_id)
# Build base query with LEFT JOIN to users table for display names
query = db.query(Message, User.display_name).outerjoin(
User, Message.sender_id == User.user_id
).filter(Message.room_id == room_id)
if not include_deleted:
query = query.filter(Message.deleted_at.is_(None))
@@ -114,18 +214,24 @@ class MessageService:
if before_timestamp:
query = query.filter(Message.created_at < before_timestamp)
# Get total count
total = query.count()
# Get total count (need separate query without join for accurate count)
count_query = db.query(Message).filter(Message.room_id == room_id)
if not include_deleted:
count_query = count_query.filter(Message.deleted_at.is_(None))
if before_timestamp:
count_query = count_query.filter(Message.created_at < before_timestamp)
total = count_query.count()
# Get messages in reverse chronological order
messages = query.order_by(desc(Message.created_at)).offset(offset).limit(limit).all()
# Get messages with display names in reverse chronological order
results = query.order_by(desc(Message.created_at)).offset(offset).limit(limit).all()
# Get reaction counts for each message
# Get reaction counts for each message and build responses
message_responses = []
for msg in messages:
for msg, display_name in results:
reaction_counts = MessageService._get_reaction_counts(db, msg.message_id)
msg_response = MessageResponse.from_orm(msg)
msg_response.reaction_counts = reaction_counts
msg_response.sender_display_name = display_name or msg.sender_id
message_responses.append(msg_response)
return MessageListResponse(
@@ -133,7 +239,7 @@ class MessageService:
total=total,
limit=limit,
offset=offset,
has_more=(offset + len(messages)) < total
has_more=(offset + len(results)) < total
)
@staticmethod
@@ -253,8 +359,10 @@ class MessageService:
total = db.query(Message).filter(search_filter).count()
messages = (
db.query(Message)
# Query with LEFT JOIN for display names
results = (
db.query(Message, User.display_name)
.outerjoin(User, Message.sender_id == User.user_id)
.filter(search_filter)
.order_by(desc(Message.created_at))
.offset(offset)
@@ -263,10 +371,11 @@ class MessageService:
)
message_responses = []
for msg in messages:
for msg, display_name in results:
reaction_counts = MessageService._get_reaction_counts(db, msg.message_id)
msg_response = MessageResponse.from_orm(msg)
msg_response.reaction_counts = reaction_counts
msg_response.sender_display_name = display_name or msg.sender_id
message_responses.append(msg_response)
return MessageListResponse(
@@ -274,7 +383,7 @@ class MessageService:
total=total,
limit=limit,
offset=offset,
has_more=(offset + len(messages)) < total
has_more=(offset + len(results)) < total
)
@staticmethod

View File

@@ -3,6 +3,6 @@
AI-powered incident report generation using DIFY service.
"""
from app.modules.report_generation.models import GeneratedReport, ReportStatus
from app.modules.report_generation.router import router
from app.modules.report_generation.router import router, health_router
__all__ = ["GeneratedReport", "ReportStatus", "router"]
__all__ = ["GeneratedReport", "ReportStatus", "router", "health_router"]

View File

@@ -89,6 +89,10 @@ def _format_room_info(room_data: Dict[str, Any]) -> str:
elif resolved_at is None:
resolved_at = "尚未解決"
# Format LOT batch numbers
lots = room_data.get('lots', [])
lots_str = ", ".join(lots) if lots else ""
lines = [
"## 事件資訊",
f"- 標題: {room_data.get('title', '未命名')}",
@@ -96,6 +100,7 @@ def _format_room_info(room_data: Dict[str, Any]) -> str:
f"- 嚴重程度: {severity}",
f"- 目前狀態: {status}",
f"- 發生地點: {room_data.get('location', '未指定')}",
f"- 影響批號 (LOT): {lots_str}",
f"- 建立時間: {created_at}",
f"- 解決時間: {resolved_at}",
]
@@ -189,7 +194,7 @@ def _format_instructions() -> str:
請根據以上資料,生成一份結構化的事件報告。報告必須為 JSON 格式,包含以下欄位:
1. **summary**: 事件摘要 (50-100字)
1. **summary**: 事件摘要 (50-300字)
2. **timeline**: 按時間順序的事件時間軸
3. **participants**: 參與人員及其角色
4. **resolution_process**: 詳細的處理過程描述

View File

@@ -7,6 +7,7 @@ FastAPI router with all report-related endpoints:
- GET /api/rooms/{room_id}/reports/{report_id}/download - Download report .docx
"""
import logging
from urllib.parse import quote
from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
@@ -64,6 +65,33 @@ async def _broadcast_report_progress(
await ws_manager.broadcast_to_room(room_id, payload)
router = APIRouter(prefix="/api/rooms/{room_id}/reports", tags=["Report Generation"])
health_router = APIRouter(prefix="/api/reports", tags=["Report Generation"])
@health_router.get("/health", response_model=schemas.HealthCheckResponse)
async def check_dify_health():
"""Check DIFY AI service connection status
Returns:
Health check result with status and message
"""
if not settings.DIFY_API_KEY:
return schemas.HealthCheckResponse(
status="error",
message="DIFY_API_KEY 未設定,請聯繫系統管理員"
)
try:
result = await dify_service.test_connection()
return schemas.HealthCheckResponse(
status=result["status"],
message=result["message"]
)
except DifyAPIError as e:
return schemas.HealthCheckResponse(
status="error",
message=str(e)
)
@router.post("/generate", response_model=schemas.ReportGenerateResponse, status_code=status.HTTP_202_ACCEPTED)
@@ -202,6 +230,75 @@ async def get_report_status(
)
@router.get("/{report_id}/markdown", response_model=schemas.ReportMarkdownResponse)
async def get_report_markdown(
room_id: str,
report_id: str,
db: Session = Depends(get_db),
room_context: dict = Depends(require_room_member),
):
"""Get report content as Markdown for in-page preview
Args:
room_id: Room ID
report_id: Report ID to get markdown for
Returns:
Markdown formatted report content
"""
report = (
db.query(GeneratedReport)
.filter(
GeneratedReport.report_id == report_id,
GeneratedReport.room_id == room_id,
)
.first()
)
if not report:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Report not found",
)
if report.status != ReportStatus.COMPLETED.value:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Report is not ready. Status: {report.status}",
)
if not report.report_json:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Report content not found",
)
# Collect room data for markdown generation
data_service = ReportDataService(db)
room_data = data_service.collect_room_data(room_id)
if not room_data:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Room not found",
)
prompt_data = data_service.to_prompt_dict(room_data)
# Generate markdown
markdown_content = docx_service.to_markdown(
room_data=prompt_data["room_data"],
ai_content=report.report_json,
files=prompt_data["files"],
)
return schemas.ReportMarkdownResponse(
report_id=report.report_id,
report_title=report.report_title,
markdown=markdown_content,
)
@router.get("/{report_id}/download")
async def download_report(
room_id: str,
@@ -262,11 +359,16 @@ async def download_report(
filename = report.report_title or f"report_{report.report_id[:8]}"
filename = f"{filename}.docx"
# Use RFC 5987 format for non-ASCII filenames
# Provide ASCII fallback for older clients + UTF-8 encoded version
ascii_filename = f"report_{report.report_id[:8]}.docx"
encoded_filename = quote(filename)
return StreamingResponse(
io.BytesIO(content),
media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
headers={
"Content-Disposition": f'attachment; filename="{filename}"',
"Content-Disposition": f"attachment; filename=\"{ascii_filename}\"; filename*=UTF-8''{encoded_filename}",
},
)

View File

@@ -67,6 +67,13 @@ class ReportListResponse(BaseModel):
total: int
class ReportMarkdownResponse(BaseModel):
"""Report content in Markdown format for in-page preview"""
report_id: str
report_title: Optional[str] = None
markdown: str = Field(..., description="Full report content in Markdown format")
# AI Report Content Schemas (validated JSON from DIFY)
class TimelineEvent(BaseModel):
"""Single event in timeline"""
@@ -103,3 +110,10 @@ class AIReportContent(BaseModel):
class ErrorResponse(BaseModel):
"""Error response"""
detail: str
# Health Check Response
class HealthCheckResponse(BaseModel):
"""DIFY service health check response"""
status: str = Field(..., description="Status: 'ok' or 'error'")
message: str = Field(..., description="Human-readable status message")

View File

@@ -252,6 +252,37 @@ class DifyService:
"final_resolution section missing 'content' field when has_resolution is true"
)
async def test_connection(self) -> Dict[str, Any]:
"""Test connection to DIFY API
Returns:
Dict with status and message
Raises:
DifyAPIError: If connection or API fails
"""
if not self.api_key:
raise DifyAPIError("DIFY_API_KEY 未設定,請聯繫系統管理員")
# Send a simple test query
url = f"{self.base_url}/parameters"
headers = {
"Authorization": f"Bearer {self.api_key}",
}
try:
response = await self._client.get(url, headers=headers)
response.raise_for_status()
return {"status": "ok", "message": "AI 服務連線正常"}
except httpx.TimeoutException:
raise DifyAPIError("無法連接 AI 服務,請求逾時")
except httpx.HTTPStatusError as e:
if e.response.status_code == 401:
raise DifyAPIError("DIFY API Key 無效,請檢查設定")
raise DifyAPIError(f"AI 服務回應錯誤: {e.response.status_code}")
except httpx.RequestError as e:
raise DifyAPIError(f"無法連接 AI 服務: {str(e)}")
async def close(self):
"""Close HTTP client"""
await self._client.aclose()

View File

@@ -140,7 +140,7 @@ class DocxAssemblyService:
def _add_metadata_table(self, doc: Document, room_data: Dict[str, Any]):
"""Add metadata summary table"""
table = doc.add_table(rows=4, cols=4)
table = doc.add_table(rows=5, cols=4)
table.style = "Table Grid"
# Row 1: Type and Severity
@@ -178,8 +178,15 @@ class DocxAssemblyService:
else:
cells[3].text = "尚未解決"
# Row 4: Description (spanning all columns)
# Row 4: LOT batch numbers (spanning all columns)
cells = table.rows[3].cells
cells[0].text = "影響批號"
cells[1].merge(cells[3])
lots = room_data.get("lots", [])
cells[1].text = ", ".join(lots) if lots else ""
# Row 5: Description (spanning all columns)
cells = table.rows[4].cells
cells[0].text = "事件描述"
# Merge remaining cells for description
cells[1].merge(cells[3])
@@ -401,6 +408,183 @@ class DocxAssemblyService:
logger.error(f"Failed to download file from MinIO: {object_path} - {e}")
return None
def to_markdown(
self,
room_data: Dict[str, Any],
ai_content: Dict[str, Any],
files: List[Dict[str, Any]] = None,
) -> str:
"""Convert report content to Markdown format
Args:
room_data: Room metadata (title, type, severity, status, etc.)
ai_content: AI-generated content (summary, timeline, participants, etc.)
files: List of files with metadata (optional)
Returns:
Markdown formatted string
"""
lines = []
# Title
title = room_data.get("title", "未命名事件")
lines.append(f"# 事件報告:{title}")
lines.append("")
# Generation timestamp
timestamp = datetime.utcnow().strftime("%Y-%m-%d %H:%M")
lines.append(f"*報告產生時間:{timestamp}*")
lines.append("")
# Metadata section
lines.append("## 基本資訊")
lines.append("")
lines.append("| 項目 | 內容 |")
lines.append("|------|------|")
incident_type = room_data.get("incident_type", "other")
lines.append(f"| 事件類型 | {self.INCIDENT_TYPE_MAP.get(incident_type, incident_type)} |")
severity = room_data.get("severity", "medium")
lines.append(f"| 嚴重程度 | {self.SEVERITY_MAP.get(severity, severity)} |")
status = room_data.get("status", "active")
lines.append(f"| 目前狀態 | {self.STATUS_MAP.get(status, status)} |")
lines.append(f"| 發生地點 | {room_data.get('location') or '未指定'} |")
created_at = room_data.get("created_at")
if isinstance(created_at, datetime):
lines.append(f"| 建立時間 | {created_at.strftime('%Y-%m-%d %H:%M')} |")
else:
lines.append(f"| 建立時間 | {str(created_at) if created_at else '未知'} |")
resolved_at = room_data.get("resolved_at")
if isinstance(resolved_at, datetime):
lines.append(f"| 解決時間 | {resolved_at.strftime('%Y-%m-%d %H:%M')} |")
elif resolved_at:
lines.append(f"| 解決時間 | {str(resolved_at)} |")
else:
lines.append("| 解決時間 | 尚未解決 |")
lots = room_data.get("lots", [])
lines.append(f"| 影響批號 | {', '.join(lots) if lots else ''} |")
lines.append("")
# Description
description = room_data.get("description")
if description:
lines.append("**事件描述:**")
lines.append(f"> {description}")
lines.append("")
# Summary section
lines.append("## 事件摘要")
lines.append("")
summary = ai_content.get("summary", {})
summary_content = summary.get("content", "無摘要內容")
lines.append(summary_content)
lines.append("")
# Timeline section
lines.append("## 事件時間軸")
lines.append("")
timeline = ai_content.get("timeline", {})
events = timeline.get("events", [])
if events:
lines.append("| 時間 | 事件 |")
lines.append("|------|------|")
for event in events:
time = event.get("time", "")
desc = event.get("description", "")
# Escape pipe characters in content
desc = desc.replace("|", "\\|")
lines.append(f"| {time} | {desc} |")
else:
lines.append("無時間軸記錄")
lines.append("")
# Participants section
lines.append("## 參與人員")
lines.append("")
participants = ai_content.get("participants", {})
members = participants.get("members", [])
if members:
lines.append("| 姓名 | 角色 |")
lines.append("|------|------|")
for member in members:
name = member.get("name", "")
role = member.get("role", "")
lines.append(f"| {name} | {role} |")
else:
lines.append("無參與人員記錄")
lines.append("")
# Resolution process section
lines.append("## 處理過程")
lines.append("")
resolution = ai_content.get("resolution_process", {})
resolution_content = resolution.get("content", "無處理過程記錄")
lines.append(resolution_content)
lines.append("")
# Current status section
lines.append("## 目前狀態")
lines.append("")
current_status = ai_content.get("current_status", {})
cs_status = current_status.get("status", "unknown")
cs_text = self.STATUS_MAP.get(cs_status, cs_status)
cs_description = current_status.get("description", "")
lines.append(f"**狀態:** {cs_text}")
if cs_description:
lines.append("")
lines.append(cs_description)
lines.append("")
# Final resolution section
lines.append("## 最終處置結果")
lines.append("")
final = ai_content.get("final_resolution", {})
has_resolution = final.get("has_resolution", False)
final_content = final.get("content", "")
if has_resolution:
if final_content:
lines.append(final_content)
else:
lines.append("事件已解決,但無詳細說明。")
else:
lines.append("事件尚未解決或無最終處置結果。")
lines.append("")
# File list section
if files:
lines.append("## 附件清單")
lines.append("")
lines.append("| 檔案名稱 | 類型 | 上傳者 | 上傳時間 |")
lines.append("|----------|------|--------|----------|")
file_type_map = {
"image": "圖片",
"document": "文件",
"log": "記錄檔",
}
for f in files:
filename = f.get("filename", "")
file_type = f.get("file_type", "file")
type_text = file_type_map.get(file_type, file_type)
uploader = f.get("uploader_name") or f.get("uploader_id", "")
uploaded_at = f.get("uploaded_at")
if isinstance(uploaded_at, datetime):
uploaded_text = uploaded_at.strftime("%Y-%m-%d %H:%M")
else:
uploaded_text = str(uploaded_at) if uploaded_at else ""
lines.append(f"| {filename} | {type_text} | {uploader} | {uploaded_text} |")
lines.append("")
return "\n".join(lines)
def upload_report(
self,
report_data: io.BytesIO,

View File

@@ -62,6 +62,7 @@ class RoomReportData:
location: Optional[str]
description: Optional[str]
resolution_notes: Optional[str]
lots: List[str] # Affected LOT batch numbers
created_at: datetime
resolved_at: Optional[datetime]
created_by: str
@@ -111,6 +112,7 @@ class ReportDataService:
location=room.location,
description=room.description,
resolution_notes=room.resolution_notes,
lots=room.lots or [], # LOT batch numbers (JSON array)
created_at=room.created_at,
resolved_at=room.resolved_at,
created_by=room.created_by,
@@ -214,6 +216,7 @@ class ReportDataService:
"location": data.location,
"description": data.description,
"resolution_notes": data.resolution_notes,
"lots": data.lots, # Affected LOT batch numbers
"created_at": data.created_at,
"resolved_at": data.resolved_at,
"created_by": data.created_by,