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

@@ -13,6 +13,25 @@
# Note: All tables use 'tr_' prefix to avoid conflicts in shared database # Note: All tables use 'tr_' prefix to avoid conflicts in shared database
DATABASE_URL=mysql+pymysql://user:password@localhost:3306/task_reporter?charset=utf8mb4 DATABASE_URL=mysql+pymysql://user:password@localhost:3306/task_reporter?charset=utf8mb4
# -----------------------------------------------------------------------------
# Database Connection Pool Settings
# -----------------------------------------------------------------------------
# These settings control the SQLAlchemy connection pool for production scalability.
# Adjust based on expected concurrent users and MySQL max_connections setting.
# Number of persistent connections in the pool (default: 20)
DB_POOL_SIZE=20
# Maximum additional connections beyond pool_size when pool is exhausted (default: 30)
# Total max connections = DB_POOL_SIZE + DB_MAX_OVERFLOW = 50
DB_MAX_OVERFLOW=30
# Seconds to wait for an available connection before timeout error (default: 10)
DB_POOL_TIMEOUT=10
# Recycle connections after this many seconds to prevent stale connections (default: 1800 = 30 min)
DB_POOL_RECYCLE=1800
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Security Configuration # Security Configuration
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------

View File

@@ -23,7 +23,7 @@ from app.core.database import Base
# Import all models to register them with Base.metadata # Import all models to register them with Base.metadata
from app.modules.auth.models import UserSession, User from app.modules.auth.models import UserSession, User
from app.modules.chat_room.models import IncidentRoom, RoomMember, RoomTemplate from app.modules.chat_room.models import IncidentRoom, RoomMember
from app.modules.realtime.models import Message, MessageReaction, MessageEditHistory from app.modules.realtime.models import Message, MessageReaction, MessageEditHistory
from app.modules.file_storage.models import RoomFile from app.modules.file_storage.models import RoomFile
from app.modules.report_generation.models import GeneratedReport from app.modules.report_generation.models import GeneratedReport

View File

@@ -0,0 +1,30 @@
"""add_mentions_field_to_messages
Revision ID: 4c5eb6e941db
Revises: 7e6983a72e7b
Create Date: 2025-12-08 07:44:11.115314
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '4c5eb6e941db'
down_revision: Union[str, None] = '7e6983a72e7b'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('tr_messages', sa.Column('mentions', sa.JSON(), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('tr_messages', 'mentions')
# ### end Alembic commands ###

View File

@@ -0,0 +1,46 @@
"""remove templates add lots field
Revision ID: 7e6983a72e7b
Revises: ea3798f776f4
Create Date: 2025-12-07 14:53:33.722821
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision: str = '7e6983a72e7b'
down_revision: Union[str, None] = 'ea3798f776f4'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index('name', table_name='tr_room_templates')
op.drop_table('tr_room_templates')
op.add_column('tr_incident_rooms', sa.Column('lots', sa.JSON(), nullable=False, comment='LOT batch numbers (JSON array)'))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('tr_incident_rooms', 'lots')
op.create_table('tr_room_templates',
sa.Column('template_id', mysql.INTEGER(), autoincrement=True, nullable=False),
sa.Column('name', mysql.VARCHAR(length=100), nullable=False),
sa.Column('description', mysql.TEXT(), nullable=True),
sa.Column('incident_type', mysql.ENUM('EQUIPMENT_FAILURE', 'MATERIAL_SHORTAGE', 'QUALITY_ISSUE', 'OTHER'), nullable=False),
sa.Column('default_severity', mysql.ENUM('LOW', 'MEDIUM', 'HIGH', 'CRITICAL'), nullable=False),
sa.Column('default_members', mysql.TEXT(), nullable=True),
sa.Column('metadata_fields', mysql.TEXT(), nullable=True),
sa.PrimaryKeyConstraint('template_id'),
mysql_collate='utf8mb4_0900_ai_ci',
mysql_default_charset='utf8mb4',
mysql_engine='InnoDB'
)
op.create_index('name', 'tr_room_templates', ['name'], unique=True)
# ### end Alembic commands ###

View File

@@ -61,6 +61,12 @@ class Settings(BaseSettings):
REPORT_MAX_MESSAGES: int = 200 # Summarize if exceeded REPORT_MAX_MESSAGES: int = 200 # Summarize if exceeded
REPORT_STORAGE_PATH: str = "reports" # MinIO path prefix for reports 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") @field_validator("LOG_LEVEL")
@classmethod @classmethod
def validate_log_level(cls, v: str) -> str: def validate_log_level(cls, v: str) -> str:

View File

@@ -3,20 +3,23 @@
Supports MySQL database with connection pooling. Supports MySQL database with connection pooling.
All tables use 'tr_' prefix to avoid conflicts in shared database. All tables use 'tr_' prefix to avoid conflicts in shared database.
""" """
from contextlib import contextmanager
from sqlalchemy import create_engine from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base 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 from app.core.config import get_settings
settings = get_settings() settings = get_settings()
# Create engine with MySQL connection pooling # Create engine with MySQL connection pooling
# Pool settings are configurable via environment variables
engine = create_engine( engine = create_engine(
settings.DATABASE_URL, settings.DATABASE_URL,
pool_size=5, pool_size=settings.DB_POOL_SIZE,
max_overflow=10, max_overflow=settings.DB_MAX_OVERFLOW,
pool_timeout=settings.DB_POOL_TIMEOUT,
pool_pre_ping=True, # Verify connection before using 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, echo=settings.DEBUG,
) )
@@ -34,3 +37,21 @@ def get_db():
yield db yield db
finally: finally:
db.close() 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.users_router import router as users_router
from app.modules.auth.middleware import auth_middleware from app.modules.auth.middleware import auth_middleware
from app.modules.chat_room import router as chat_room_router 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.realtime import router as realtime_router
from app.modules.file_storage import router as file_storage_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 router as report_generation_router
from app.modules.report_generation import health_router as report_health_router
# Frontend build directory # Frontend build directory
FRONTEND_DIR = Path(__file__).parent.parent / "frontend" / "dist" 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(realtime_router)
app.include_router(file_storage_router) app.include_router(file_storage_router)
app.include_router(report_generation_router) app.include_router(report_generation_router)
app.include_router(report_health_router)
@app.on_event("startup") @app.on_event("startup")
async def startup_event(): async def startup_event():
"""Initialize application on startup""" """Initialize application on startup"""
from app.core.database import SessionLocal
from app.core.minio_client import initialize_bucket from app.core.minio_client import initialize_bucket
import logging import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Initialize default templates
db = SessionLocal()
try:
template_service.initialize_default_templates(db)
finally:
db.close()
# Initialize MinIO bucket # Initialize MinIO bucket
try: try:
if initialize_bucket(): if initialize_bucket():
@@ -80,6 +73,10 @@ async def startup_event():
except Exception as e: except Exception as e:
logger.warning(f"MinIO connection failed: {e} - file uploads will be unavailable") 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") @app.get("/api/health")
async def health_check(): async def health_check():

View File

@@ -26,6 +26,10 @@ class AuthMiddleware:
async def __call__(self, request: Request, call_next): async def __call__(self, request: Request, call_next):
"""Process request through authentication checks""" """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 # Skip auth for non-API routes (frontend), login/logout, and docs
path = request.url.path path = request.url.path
if not path.startswith("/api") or path in ["/api/auth/login", "/api/auth/logout", "/api/health"]: 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 Provides functionality for creating and managing incident response chat rooms
""" """
from app.modules.chat_room.router import router 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.room_service import room_service
from app.modules.chat_room.services.membership_service import membership_service from app.modules.chat_room.services.membership_service import membership_service
from app.modules.chat_room.services.template_service import template_service
__all__ = [ __all__ = [
"router", "router",
"IncidentRoom", "IncidentRoom",
"RoomMember", "RoomMember",
"RoomTemplate",
"room_service", "room_service",
"membership_service", "membership_service",
"template_service"
] ]

View File

@@ -3,11 +3,10 @@
Tables: Tables:
- tr_incident_rooms: Stores room metadata and configuration - tr_incident_rooms: Stores room metadata and configuration
- tr_room_members: User-room associations with roles - 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. 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 sqlalchemy.orm import relationship
from datetime import datetime from datetime import datetime
import enum import enum
@@ -58,6 +57,7 @@ class IncidentRoom(Base):
location = Column(String(255)) location = Column(String(255))
description = Column(Text) description = Column(Text)
resolution_notes = Column(Text) resolution_notes = Column(Text)
lots = Column(JSON, default=list, nullable=False, comment="LOT batch numbers (JSON array)")
# User tracking # User tracking
created_by = Column(String(255), nullable=False) # User email/ID who created the room created_by = Column(String(255), nullable=False) # User email/ID who created the room
@@ -112,17 +112,3 @@ class RoomMember(Base):
Index("ix_tr_room_members_room_user", "room_id", "user_id"), Index("ix_tr_room_members_room_user", "room_id", "user_id"),
Index("ix_tr_room_members_user", "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.models import MemberRole, RoomStatus
from app.modules.chat_room.services.room_service import room_service 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.membership_service import membership_service
from app.modules.chat_room.services.template_service import template_service
from app.modules.chat_room.dependencies import ( from app.modules.chat_room.dependencies import (
get_current_room, get_current_room,
require_room_permission, require_room_permission,
@@ -36,24 +35,6 @@ async def create_room(
"""Create a new incident room""" """Create a new incident room"""
user_email = current_user["username"] 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 # Get user role for response
@@ -508,12 +489,56 @@ async def get_user_permissions(
return permissions[role] return permissions[role]
# Template Endpoints # LOT Endpoints
@router.get("/templates", response_model=List[schemas.TemplateResponse]) @router.post("/{room_id}/lots", response_model=List[str])
async def list_templates( async def add_lot(
db: Session = Depends(get_db), room_id: str,
_: dict = Depends(get_current_user) request: schemas.AddLotRequest,
_: None = Depends(require_room_permission("update_metadata")),
db: Session = Depends(get_db)
): ):
"""List available room templates""" """Add a LOT batch number to the room"""
templates = template_service.get_templates(db) from app.modules.chat_room.models import IncidentRoom
return [schemas.TemplateResponse.from_orm(t) for t in templates]
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") severity: SeverityLevel = Field(..., description="Severity level")
location: Optional[str] = Field(None, max_length=255, description="Incident location") location: Optional[str] = Field(None, max_length=255, description="Incident location")
description: Optional[str] = Field(None, description="Detailed description") 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): class UpdateRoomRequest(BaseModel):
@@ -57,6 +57,7 @@ class UpdateRoomRequest(BaseModel):
location: Optional[str] = Field(None, max_length=255) location: Optional[str] = Field(None, max_length=255)
description: Optional[str] = None description: Optional[str] = None
resolution_notes: Optional[str] = None resolution_notes: Optional[str] = None
lots: Optional[List[str]] = Field(None, description="LOT batch numbers")
class AddMemberRequest(BaseModel): class AddMemberRequest(BaseModel):
@@ -111,6 +112,7 @@ class RoomResponse(BaseModel):
location: Optional[str] = None location: Optional[str] = None
description: Optional[str] = None description: Optional[str] = None
resolution_notes: Optional[str] = None resolution_notes: Optional[str] = None
lots: List[str] = []
created_by: str created_by: str
created_at: datetime created_at: datetime
resolved_at: Optional[datetime] = None resolved_at: Optional[datetime] = None
@@ -137,18 +139,9 @@ class RoomListResponse(BaseModel):
offset: int offset: int
class TemplateResponse(BaseModel): class AddLotRequest(BaseModel):
"""Room template information""" """Request to add a LOT to a room"""
template_id: int lot: str = Field(..., min_length=1, description="LOT batch number to add")
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): 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.room_service import room_service
from app.modules.chat_room.services.membership_service import membership_service from app.modules.chat_room.services.membership_service import membership_service
from app.modules.chat_room.services.template_service import template_service
__all__ = [ __all__ = [
"room_service", "room_service",
"membership_service", "membership_service",
"template_service"
] ]

View File

@@ -42,6 +42,7 @@ class RoomService:
severity=room_data.severity, severity=room_data.severity,
location=room_data.location, location=room_data.location,
description=room_data.description, description=room_data.description,
lots=room_data.lots or [],
status=RoomStatus.ACTIVE, status=RoomStatus.ACTIVE,
created_by=user_id, created_by=user_id,
created_at=datetime.utcnow(), created_at=datetime.utcnow(),
@@ -219,6 +220,9 @@ class RoomService:
if updates.resolution_notes is not None: if updates.resolution_notes is not None:
room.resolution_notes = updates.resolution_notes room.resolution_notes = updates.resolution_notes
if updates.lots is not None:
room.lots = updates.lots
# Handle status transitions # Handle status transitions
if updates.status is not None: if updates.status is not None:
if not self._validate_status_transition(room.status, updates.status): 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) content = Column(Text, nullable=False)
message_type = Column(Enum(MessageType), default=MessageType.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) message_metadata = Column(JSON)
# @Mention tracking - stores array of mentioned user_ids
mentions = Column(JSON, default=list)
# Timestamps # Timestamps
created_at = Column(DateTime, default=datetime.utcnow, nullable=False) created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
edited_at = Column(DateTime) # Last edit timestamp edited_at = Column(DateTime) # Last edit timestamp

View File

@@ -6,7 +6,7 @@ from typing import Optional
from datetime import datetime from datetime import datetime
import json 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.core.config import get_settings
from app.modules.auth.dependencies import get_current_user from app.modules.auth.dependencies import get_current_user
from app.modules.auth.services.session_service import session_service from app.modules.auth.services.session_service import session_service
@@ -87,15 +87,17 @@ async def websocket_endpoint(
3. Connection added to pool 3. Connection added to pool
4. User joined event broadcast to room 4. User joined event broadcast to room
5. Client can send/receive messages 5. Client can send/receive messages
"""
db: Session = next(get_db())
try: Note: Uses short-lived database sessions for each operation to prevent
# Authenticate token via session lookup connection pool exhaustion with many concurrent WebSocket connections.
"""
# Authenticate and get user info using short-lived session
if not token: if not token:
await websocket.close(code=4001, reason="Authentication required") await websocket.close(code=4001, reason="Authentication required")
return 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) user_session = session_service.get_session_by_token(db, token)
if not user_session: if not user_session:
await websocket.close(code=4001, reason="Invalid or expired token") await websocket.close(code=4001, reason="Invalid or expired token")
@@ -103,16 +105,19 @@ async def websocket_endpoint(
user_id = user_session.username 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) membership = get_user_room_membership(db, room_id, user_id)
if not membership and not is_system_admin(user_id): if not membership and not is_system_admin(user_id):
await websocket.close(code=4001, reason="Not a member of this room") await websocket.close(code=4001, reason="Not a member of this room")
return return
# Connect to WebSocket manager # Cache membership role for permission checks (avoid holding DB reference)
user_role = membership.role if membership else None
# Connect to WebSocket manager (no DB needed)
conn_info = await manager.connect(websocket, room_id, user_id) conn_info = await manager.connect(websocket, room_id, user_id)
# Broadcast user joined event # Broadcast user joined event (no DB needed)
await manager.broadcast_to_room( await manager.broadcast_to_room(
room_id, room_id,
SystemMessageBroadcast( SystemMessageBroadcast(
@@ -141,8 +146,8 @@ async def websocket_endpoint(
# Handle different message types # Handle different message types
if ws_message.type == WebSocketMessageType.MESSAGE: if ws_message.type == WebSocketMessageType.MESSAGE:
# Check write permission # Check write permission using cached role
if not can_write_message(membership, user_id): if not _can_write_with_role(user_role, user_id):
await ws_send_json(websocket, await ws_send_json(websocket,
ErrorMessage( ErrorMessage(
error="Insufficient permissions", error="Insufficient permissions",
@@ -151,7 +156,8 @@ async def websocket_endpoint(
) )
continue continue
# Create message in database # Create message in database with short session
with get_db_context() as db:
message = MessageService.create_message( message = MessageService.create_message(
db=db, db=db,
room_id=room_id, room_id=room_id,
@@ -160,13 +166,27 @@ async def websocket_endpoint(
message_type=MessageType(ws_message.message_type.value) if ws_message.message_type else MessageType.TEXT, message_type=MessageType(ws_message.message_type.value) if ws_message.message_type else MessageType.TEXT,
metadata=ws_message.metadata 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, await ws_send_json(websocket,
MessageAck( MessageAck(
message_id=message.message_id, message_id=msg_data["message_id"],
sequence_number=message.sequence_number, sequence_number=msg_data["sequence_number"],
timestamp=message.created_at timestamp=msg_data["created_at"]
).dict() ).dict()
) )
@@ -174,14 +194,15 @@ async def websocket_endpoint(
await manager.broadcast_to_room( await manager.broadcast_to_room(
room_id, room_id,
MessageBroadcast( MessageBroadcast(
message_id=message.message_id, message_id=msg_data["message_id"],
room_id=message.room_id, room_id=msg_data["room_id"],
sender_id=message.sender_id, sender_id=msg_data["sender_id"],
content=message.content, sender_display_name=msg_data["sender_display_name"],
message_type=MessageTypeEnum(message.message_type.value), content=msg_data["content"],
metadata=message.message_metadata, message_type=MessageTypeEnum(msg_data["message_type"]),
created_at=message.created_at, metadata=msg_data["metadata"],
sequence_number=message.sequence_number created_at=msg_data["created_at"],
sequence_number=msg_data["sequence_number"]
).dict() ).dict()
) )
@@ -192,15 +213,33 @@ async def websocket_endpoint(
) )
continue continue
# Edit message # Edit message with short session
with get_db_context() as db:
edited_message = MessageService.edit_message( edited_message = MessageService.edit_message(
db=db, db=db,
message_id=ws_message.message_id, message_id=ws_message.message_id,
user_id=user_id, user_id=user_id,
new_content=ws_message.content 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: if not edit_data:
await ws_send_json(websocket, await ws_send_json(websocket,
ErrorMessage(error="Cannot edit message", code="EDIT_FAILED").dict() ErrorMessage(error="Cannot edit message", code="EDIT_FAILED").dict()
) )
@@ -211,15 +250,16 @@ async def websocket_endpoint(
room_id, room_id,
MessageBroadcast( MessageBroadcast(
type="edit_message", type="edit_message",
message_id=edited_message.message_id, message_id=edit_data["message_id"],
room_id=edited_message.room_id, room_id=edit_data["room_id"],
sender_id=edited_message.sender_id, sender_id=edit_data["sender_id"],
content=edited_message.content, sender_display_name=edit_data["sender_display_name"],
message_type=MessageTypeEnum(edited_message.message_type.value), content=edit_data["content"],
metadata=edited_message.message_metadata, message_type=MessageTypeEnum(edit_data["message_type"]),
created_at=edited_message.created_at, metadata=edit_data["metadata"],
edited_at=edited_message.edited_at, created_at=edit_data["created_at"],
sequence_number=edited_message.sequence_number edited_at=edit_data["edited_at"],
sequence_number=edit_data["sequence_number"]
).dict() ).dict()
) )
@@ -230,15 +270,17 @@ async def websocket_endpoint(
) )
continue continue
# Delete message # Delete message with short session
with get_db_context() as db:
deleted_message = MessageService.delete_message( deleted_message = MessageService.delete_message(
db=db, db=db,
message_id=ws_message.message_id, message_id=ws_message.message_id,
user_id=user_id, user_id=user_id,
is_admin=is_system_admin(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: if not deleted_msg_id:
await ws_send_json(websocket, await ws_send_json(websocket,
ErrorMessage(error="Cannot delete message", code="DELETE_FAILED").dict() ErrorMessage(error="Cannot delete message", code="DELETE_FAILED").dict()
) )
@@ -247,7 +289,7 @@ async def websocket_endpoint(
# Broadcast deletion to all room members # Broadcast deletion to all room members
await manager.broadcast_to_room( await manager.broadcast_to_room(
room_id, room_id,
{"type": "delete_message", "message_id": deleted_message.message_id} {"type": "delete_message", "message_id": deleted_msg_id}
) )
elif ws_message.type == WebSocketMessageType.ADD_REACTION: elif ws_message.type == WebSocketMessageType.ADD_REACTION:
@@ -257,15 +299,17 @@ async def websocket_endpoint(
) )
continue continue
# Add reaction # Add reaction with short session
with get_db_context() as db:
reaction = MessageService.add_reaction( reaction = MessageService.add_reaction(
db=db, db=db,
message_id=ws_message.message_id, message_id=ws_message.message_id,
user_id=user_id, user_id=user_id,
emoji=ws_message.emoji emoji=ws_message.emoji
) )
reaction_added = reaction is not None
if reaction: if reaction_added:
# Broadcast reaction to all room members # Broadcast reaction to all room members
await manager.broadcast_to_room( await manager.broadcast_to_room(
room_id, room_id,
@@ -284,7 +328,8 @@ async def websocket_endpoint(
) )
continue continue
# Remove reaction # Remove reaction with short session
with get_db_context() as db:
removed = MessageService.remove_reaction( removed = MessageService.remove_reaction(
db=db, db=db,
message_id=ws_message.message_id, message_id=ws_message.message_id,
@@ -305,7 +350,7 @@ async def websocket_endpoint(
) )
elif ws_message.type == WebSocketMessageType.TYPING: elif ws_message.type == WebSocketMessageType.TYPING:
# Set typing status # Set typing status (no DB needed)
is_typing = message_data.get("is_typing", True) is_typing = message_data.get("is_typing", True)
await manager.set_typing(room_id, user_id, is_typing) await manager.set_typing(room_id, user_id, is_typing)
@@ -331,8 +376,14 @@ async def websocket_endpoint(
).dict() ).dict()
) )
finally:
db.close() 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 # REST API endpoints
@@ -387,6 +438,10 @@ async def create_message(
metadata=message.metadata 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 # Broadcast to WebSocket connections
await manager.broadcast_to_room( await manager.broadcast_to_room(
room_id, room_id,
@@ -394,6 +449,7 @@ async def create_message(
message_id=created_message.message_id, message_id=created_message.message_id,
room_id=created_message.room_id, room_id=created_message.room_id,
sender_id=created_message.sender_id, sender_id=created_message.sender_id,
sender_display_name=sender_display_name,
content=created_message.content, content=created_message.content,
message_type=MessageTypeEnum(created_message.message_type.value), message_type=MessageTypeEnum(created_message.message_type.value),
metadata=created_message.message_metadata, metadata=created_message.message_metadata,
@@ -402,7 +458,10 @@ async def create_message(
).dict() ).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) @router.get("/rooms/{room_id}/messages/search", response_model=MessageListResponse)

View File

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

View File

@@ -1,9 +1,12 @@
"""Message service layer for database operations""" """Message service layer for database operations"""
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import desc, and_, func from sqlalchemy import desc, and_, func, text
from typing import List, Optional, Dict, Any from sqlalchemy.exc import IntegrityError
from typing import List, Optional, Dict, Any, Tuple
from datetime import datetime, timedelta from datetime import datetime, timedelta
import uuid import uuid
import logging
import re
from app.core.config import get_settings from app.core.config import get_settings
from app.modules.realtime.models import Message, MessageType, MessageReaction, MessageEditHistory from app.modules.realtime.models import Message, MessageType, MessageReaction, MessageEditHistory
@@ -13,13 +16,48 @@ from app.modules.realtime.schemas import (
MessageListResponse, MessageListResponse,
ReactionSummary ReactionSummary
) )
from app.modules.auth.models import User
settings = get_settings() settings = get_settings()
logger = logging.getLogger(__name__)
class MessageService: class MessageService:
"""Service for message operations""" """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 @staticmethod
def create_message( def create_message(
db: Session, db: Session,
@@ -27,10 +65,16 @@ class MessageService:
sender_id: str, sender_id: str,
content: str, content: str,
message_type: MessageType = MessageType.TEXT, 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: ) -> 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: Args:
db: Database session db: Database session
@@ -38,15 +82,33 @@ class MessageService:
sender_id: User ID who sent the message sender_id: User ID who sent the message
content: Message content content: Message content
message_type: Type of message 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: Returns:
Created Message object Created Message object
Raises:
IntegrityError: If max retries exceeded
""" """
# Get next sequence number for this room last_error = None
max_seq = db.query(func.max(Message.sequence_number)).filter(
Message.room_id == room_id for attempt in range(max_retries):
).scalar() 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 next_seq = (max_seq or 0) + 1
message = Message( message = Message(
@@ -56,6 +118,7 @@ class MessageService:
content=content, content=content,
message_type=message_type, message_type=message_type,
message_metadata=metadata or {}, message_metadata=metadata or {},
mentions=mentions or [],
created_at=datetime.utcnow(), created_at=datetime.utcnow(),
sequence_number=next_seq sequence_number=next_seq
) )
@@ -66,6 +129,25 @@ class MessageService:
return 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
)
@staticmethod @staticmethod
def get_message(db: Session, message_id: str) -> Optional[Message]: def get_message(db: Session, message_id: str) -> Optional[Message]:
""" """
@@ -83,6 +165,21 @@ class MessageService:
Message.deleted_at.is_(None) Message.deleted_at.is_(None)
).first() ).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 @staticmethod
def get_messages( def get_messages(
db: Session, db: Session,
@@ -106,7 +203,10 @@ class MessageService:
Returns: Returns:
MessageListResponse with messages and pagination info 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: if not include_deleted:
query = query.filter(Message.deleted_at.is_(None)) query = query.filter(Message.deleted_at.is_(None))
@@ -114,18 +214,24 @@ class MessageService:
if before_timestamp: if before_timestamp:
query = query.filter(Message.created_at < before_timestamp) query = query.filter(Message.created_at < before_timestamp)
# Get total count # Get total count (need separate query without join for accurate count)
total = query.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 # Get messages with display names in reverse chronological order
messages = query.order_by(desc(Message.created_at)).offset(offset).limit(limit).all() 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 = [] message_responses = []
for msg in messages: for msg, display_name in results:
reaction_counts = MessageService._get_reaction_counts(db, msg.message_id) reaction_counts = MessageService._get_reaction_counts(db, msg.message_id)
msg_response = MessageResponse.from_orm(msg) msg_response = MessageResponse.from_orm(msg)
msg_response.reaction_counts = reaction_counts msg_response.reaction_counts = reaction_counts
msg_response.sender_display_name = display_name or msg.sender_id
message_responses.append(msg_response) message_responses.append(msg_response)
return MessageListResponse( return MessageListResponse(
@@ -133,7 +239,7 @@ class MessageService:
total=total, total=total,
limit=limit, limit=limit,
offset=offset, offset=offset,
has_more=(offset + len(messages)) < total has_more=(offset + len(results)) < total
) )
@staticmethod @staticmethod
@@ -253,8 +359,10 @@ class MessageService:
total = db.query(Message).filter(search_filter).count() total = db.query(Message).filter(search_filter).count()
messages = ( # Query with LEFT JOIN for display names
db.query(Message) results = (
db.query(Message, User.display_name)
.outerjoin(User, Message.sender_id == User.user_id)
.filter(search_filter) .filter(search_filter)
.order_by(desc(Message.created_at)) .order_by(desc(Message.created_at))
.offset(offset) .offset(offset)
@@ -263,10 +371,11 @@ class MessageService:
) )
message_responses = [] message_responses = []
for msg in messages: for msg, display_name in results:
reaction_counts = MessageService._get_reaction_counts(db, msg.message_id) reaction_counts = MessageService._get_reaction_counts(db, msg.message_id)
msg_response = MessageResponse.from_orm(msg) msg_response = MessageResponse.from_orm(msg)
msg_response.reaction_counts = reaction_counts msg_response.reaction_counts = reaction_counts
msg_response.sender_display_name = display_name or msg.sender_id
message_responses.append(msg_response) message_responses.append(msg_response)
return MessageListResponse( return MessageListResponse(
@@ -274,7 +383,7 @@ class MessageService:
total=total, total=total,
limit=limit, limit=limit,
offset=offset, offset=offset,
has_more=(offset + len(messages)) < total has_more=(offset + len(results)) < total
) )
@staticmethod @staticmethod

View File

@@ -3,6 +3,6 @@
AI-powered incident report generation using DIFY service. AI-powered incident report generation using DIFY service.
""" """
from app.modules.report_generation.models import GeneratedReport, ReportStatus 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: elif resolved_at is None:
resolved_at = "尚未解決" resolved_at = "尚未解決"
# Format LOT batch numbers
lots = room_data.get('lots', [])
lots_str = ", ".join(lots) if lots else ""
lines = [ lines = [
"## 事件資訊", "## 事件資訊",
f"- 標題: {room_data.get('title', '未命名')}", f"- 標題: {room_data.get('title', '未命名')}",
@@ -96,6 +100,7 @@ def _format_room_info(room_data: Dict[str, Any]) -> str:
f"- 嚴重程度: {severity}", f"- 嚴重程度: {severity}",
f"- 目前狀態: {status}", f"- 目前狀態: {status}",
f"- 發生地點: {room_data.get('location', '未指定')}", f"- 發生地點: {room_data.get('location', '未指定')}",
f"- 影響批號 (LOT): {lots_str}",
f"- 建立時間: {created_at}", f"- 建立時間: {created_at}",
f"- 解決時間: {resolved_at}", f"- 解決時間: {resolved_at}",
] ]
@@ -189,7 +194,7 @@ def _format_instructions() -> str:
請根據以上資料,生成一份結構化的事件報告。報告必須為 JSON 格式,包含以下欄位: 請根據以上資料,生成一份結構化的事件報告。報告必須為 JSON 格式,包含以下欄位:
1. **summary**: 事件摘要 (50-100字) 1. **summary**: 事件摘要 (50-300字)
2. **timeline**: 按時間順序的事件時間軸 2. **timeline**: 按時間順序的事件時間軸
3. **participants**: 參與人員及其角色 3. **participants**: 參與人員及其角色
4. **resolution_process**: 詳細的處理過程描述 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 - GET /api/rooms/{room_id}/reports/{report_id}/download - Download report .docx
""" """
import logging import logging
from urllib.parse import quote
from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -64,6 +65,33 @@ async def _broadcast_report_progress(
await ws_manager.broadcast_to_room(room_id, payload) await ws_manager.broadcast_to_room(room_id, payload)
router = APIRouter(prefix="/api/rooms/{room_id}/reports", tags=["Report Generation"]) 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) @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") @router.get("/{report_id}/download")
async def download_report( async def download_report(
room_id: str, room_id: str,
@@ -262,11 +359,16 @@ async def download_report(
filename = report.report_title or f"report_{report.report_id[:8]}" filename = report.report_title or f"report_{report.report_id[:8]}"
filename = f"{filename}.docx" 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( return StreamingResponse(
io.BytesIO(content), io.BytesIO(content),
media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document", media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
headers={ 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 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) # AI Report Content Schemas (validated JSON from DIFY)
class TimelineEvent(BaseModel): class TimelineEvent(BaseModel):
"""Single event in timeline""" """Single event in timeline"""
@@ -103,3 +110,10 @@ class AIReportContent(BaseModel):
class ErrorResponse(BaseModel): class ErrorResponse(BaseModel):
"""Error response""" """Error response"""
detail: str 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" "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): async def close(self):
"""Close HTTP client""" """Close HTTP client"""
await self._client.aclose() await self._client.aclose()

View File

@@ -140,7 +140,7 @@ class DocxAssemblyService:
def _add_metadata_table(self, doc: Document, room_data: Dict[str, Any]): def _add_metadata_table(self, doc: Document, room_data: Dict[str, Any]):
"""Add metadata summary table""" """Add metadata summary table"""
table = doc.add_table(rows=4, cols=4) table = doc.add_table(rows=5, cols=4)
table.style = "Table Grid" table.style = "Table Grid"
# Row 1: Type and Severity # Row 1: Type and Severity
@@ -178,8 +178,15 @@ class DocxAssemblyService:
else: else:
cells[3].text = "尚未解決" cells[3].text = "尚未解決"
# Row 4: Description (spanning all columns) # Row 4: LOT batch numbers (spanning all columns)
cells = table.rows[3].cells 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 = "事件描述" cells[0].text = "事件描述"
# Merge remaining cells for description # Merge remaining cells for description
cells[1].merge(cells[3]) cells[1].merge(cells[3])
@@ -401,6 +408,183 @@ class DocxAssemblyService:
logger.error(f"Failed to download file from MinIO: {object_path} - {e}") logger.error(f"Failed to download file from MinIO: {object_path} - {e}")
return None 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( def upload_report(
self, self,
report_data: io.BytesIO, report_data: io.BytesIO,

View File

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

View File

@@ -6,7 +6,6 @@
# Access MinIO Console at: http://localhost:${MINIO_CONSOLE_PORT:-9001} # Access MinIO Console at: http://localhost:${MINIO_CONSOLE_PORT:-9001}
# S3 API endpoint at: http://localhost:${MINIO_API_PORT:-9000} # S3 API endpoint at: http://localhost:${MINIO_API_PORT:-9000}
version: '3.8'
services: services:
minio: minio:

3
frontend/.env.production Normal file
View File

@@ -0,0 +1,3 @@
# Production environment configuration
# API Base URL - point directly to the backend server
VITE_API_BASE_URL=http://localhost:8000/api

File diff suppressed because it is too large Load Diff

View File

@@ -17,7 +17,9 @@
"axios": "^1.13.2", "axios": "^1.13.2",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-markdown": "^10.1.0",
"react-router": "^7.9.6", "react-router": "^7.9.6",
"remark-gfm": "^4.0.1",
"zustand": "^5.0.9" "zustand": "^5.0.9"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -0,0 +1,269 @@
import { useState, useRef } from 'react'
interface ActionBarProps {
// Toggles
showFiles: boolean
showMembers: boolean
memberCount: number
onFilesToggle: () => void
onMembersToggle: () => void
// Actions
canWrite: boolean
canManageMembers: boolean
isGeneratingReport: boolean
uploadProgress: number | null
onFileSelect: (files: FileList | null) => void
onGenerateReport: () => void
onAddMemberClick: () => void
// Mobile
isMobile?: boolean
}
export function ActionBar({
showFiles,
showMembers,
memberCount,
onFilesToggle,
onMembersToggle,
canWrite,
canManageMembers,
isGeneratingReport,
uploadProgress,
onFileSelect,
onGenerateReport,
onAddMemberClick,
isMobile = false,
}: ActionBarProps) {
const [expanded, setExpanded] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const toggleExpand = () => setExpanded(!expanded)
const handleFileClick = () => {
fileInputRef.current?.click()
}
// Compact mode button style
const buttonClass = isMobile
? 'flex flex-col items-center justify-center p-2 rounded-lg text-gray-600 hover:bg-gray-100 active:bg-gray-200 touch-target'
: 'flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-gray-600 hover:bg-gray-100 text-sm'
const activeButtonClass = isMobile
? 'flex flex-col items-center justify-center p-2 rounded-lg text-blue-600 bg-blue-50 touch-target'
: 'flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-blue-600 bg-blue-50 text-sm'
return (
<div className="bg-white border-t border-gray-200">
{/* Hidden file input */}
<input
type="file"
ref={fileInputRef}
onChange={(e) => onFileSelect(e.target.files)}
className="hidden"
accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.txt,.log"
/>
{/* Action bar content */}
<div className="px-3 py-2">
{/* Mobile: Expandable toolbar */}
{isMobile ? (
<div className="flex items-center justify-between">
{/* Left side - expand toggle */}
<button
onClick={toggleExpand}
className={`p-2 rounded-lg ${expanded ? 'text-blue-600 bg-blue-50' : 'text-gray-500'}`}
aria-expanded={expanded}
aria-label="Toggle action toolbar"
>
<svg className={`w-6 h-6 transition-transform ${expanded ? 'rotate-45' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
</button>
{/* Right side - quick toggles */}
<div className="flex items-center gap-1">
{/* Files toggle */}
<button
onClick={onFilesToggle}
className={showFiles ? activeButtonClass : buttonClass}
aria-label="Toggle files"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
</button>
{/* Members toggle */}
<button
onClick={onMembersToggle}
className={showMembers ? activeButtonClass : buttonClass}
aria-label="Toggle members"
>
<div className="relative">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
<span className="absolute -top-1 -right-2 bg-gray-500 text-white text-[10px] rounded-full w-4 h-4 flex items-center justify-center">
{memberCount > 9 ? '9+' : memberCount}
</span>
</div>
</button>
</div>
</div>
) : (
/* Desktop: Inline toolbar */
<div className="flex items-center justify-between">
{/* Left side - action buttons */}
<div className="flex items-center gap-1">
{/* Upload file */}
{canWrite && (
<button
onClick={handleFileClick}
disabled={uploadProgress !== null}
className={buttonClass}
title="Upload file"
>
{uploadProgress !== null ? (
<div className="flex items-center gap-1.5">
<div className="w-4 h-4 border-2 border-blue-600 border-t-transparent rounded-full animate-spin" />
<span>{uploadProgress}%</span>
</div>
) : (
<>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13" />
</svg>
<span>Upload</span>
</>
)}
</button>
)}
{/* Generate report */}
<button
onClick={onGenerateReport}
disabled={isGeneratingReport}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-purple-600 hover:bg-purple-50 text-sm disabled:opacity-50"
title="Generate AI report"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<span>{isGeneratingReport ? 'Generating...' : 'Report'}</span>
</button>
{/* Add member */}
{canManageMembers && (
<button
onClick={onAddMemberClick}
className={buttonClass}
title="Add member"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z" />
</svg>
<span>Add Member</span>
</button>
)}
</div>
{/* Right side - panel toggles */}
<div className="flex items-center gap-1">
{/* Files toggle */}
<button
onClick={onFilesToggle}
className={showFiles ? activeButtonClass : buttonClass}
title="Toggle files panel"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
<span>Files</span>
</button>
{/* Members toggle */}
<button
onClick={onMembersToggle}
className={showMembers ? activeButtonClass : buttonClass}
title="Toggle members panel"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
<span>{memberCount}</span>
</button>
</div>
</div>
)}
{/* Mobile expanded actions */}
{isMobile && (
<div
className={`overflow-hidden transition-all duration-200 ease-out ${
expanded ? 'max-h-24 opacity-100 mt-2 pt-2 border-t border-gray-100' : 'max-h-0 opacity-0'
}`}
>
<div className="flex items-center justify-around">
{/* Upload file */}
{canWrite && (
<button
onClick={() => {
handleFileClick()
setExpanded(false)
}}
disabled={uploadProgress !== null}
className={buttonClass}
>
{uploadProgress !== null ? (
<>
<div className="w-5 h-5 border-2 border-blue-600 border-t-transparent rounded-full animate-spin" />
<span className="text-xs">{uploadProgress}%</span>
</>
) : (
<>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13" />
</svg>
<span className="text-xs">Upload</span>
</>
)}
</button>
)}
{/* Generate report */}
<button
onClick={() => {
onGenerateReport()
setExpanded(false)
}}
disabled={isGeneratingReport}
className="flex flex-col items-center justify-center p-2 rounded-lg text-purple-600 hover:bg-purple-50 active:bg-purple-100 touch-target disabled:opacity-50"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<span className="text-xs">{isGeneratingReport ? 'Generating' : 'Report'}</span>
</button>
{/* Add member */}
{canManageMembers && (
<button
onClick={() => {
onAddMemberClick()
setExpanded(false)
}}
className={buttonClass}
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z" />
</svg>
<span className="text-xs">Add Member</span>
</button>
)}
</div>
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,278 @@
import React, { useState, useRef, useEffect, useCallback } from 'react'
import type { KeyboardEvent, ChangeEvent } from 'react'
interface Member {
user_id: string
display_name?: string
role?: string
}
interface MentionInputProps {
value: string
onChange: (value: string) => void
onSubmit: () => void
members: Member[]
placeholder?: string
disabled?: boolean
className?: string
isMobile?: boolean
}
export function MentionInput({
value,
onChange,
onSubmit,
members,
placeholder = 'Type a message...',
disabled = false,
className = '',
isMobile = false,
}: MentionInputProps) {
const [showMentionDropdown, setShowMentionDropdown] = useState(false)
const [mentionQuery, setMentionQuery] = useState('')
const [mentionStartIndex, setMentionStartIndex] = useState(-1)
const [selectedIndex, setSelectedIndex] = useState(0)
const inputRef = useRef<HTMLInputElement>(null)
const dropdownRef = useRef<HTMLDivElement>(null)
// Filter members based on mention query
const filteredMembers = members.filter((member) => {
const displayName = member.display_name || member.user_id
const query = mentionQuery.toLowerCase()
return (
displayName.toLowerCase().includes(query) ||
member.user_id.toLowerCase().includes(query)
)
})
// Reset selected index when filtered results change
useEffect(() => {
setSelectedIndex(0)
}, [filteredMembers.length])
// Scroll selected item into view
useEffect(() => {
if (showMentionDropdown && dropdownRef.current) {
const selectedElement = dropdownRef.current.children[selectedIndex] as HTMLElement
if (selectedElement) {
selectedElement.scrollIntoView({ block: 'nearest' })
}
}
}, [selectedIndex, showMentionDropdown])
const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value
const cursorPos = e.target.selectionStart || 0
onChange(newValue)
// Check for @ trigger
const textBeforeCursor = newValue.slice(0, cursorPos)
const lastAtIndex = textBeforeCursor.lastIndexOf('@')
if (lastAtIndex !== -1) {
// Check if @ is at start or preceded by whitespace
const charBeforeAt = lastAtIndex > 0 ? newValue[lastAtIndex - 1] : ' '
if (charBeforeAt === ' ' || charBeforeAt === '\n' || lastAtIndex === 0) {
const queryText = textBeforeCursor.slice(lastAtIndex + 1)
// Only show dropdown if query doesn't contain spaces (still typing the mention)
if (!queryText.includes(' ')) {
setMentionQuery(queryText)
setMentionStartIndex(lastAtIndex)
setShowMentionDropdown(true)
return
}
}
}
setShowMentionDropdown(false)
setMentionQuery('')
setMentionStartIndex(-1)
}
const insertMention = useCallback((member: Member) => {
const displayName = member.display_name || member.user_id
const beforeMention = value.slice(0, mentionStartIndex)
const afterMention = value.slice(mentionStartIndex + mentionQuery.length + 1) // +1 for @
// Insert mention with space after
const newValue = `${beforeMention}@${displayName} ${afterMention}`
onChange(newValue)
// Close dropdown
setShowMentionDropdown(false)
setMentionQuery('')
setMentionStartIndex(-1)
// Focus input and set cursor position
if (inputRef.current) {
inputRef.current.focus()
const newCursorPos = beforeMention.length + displayName.length + 2 // @name + space
setTimeout(() => {
inputRef.current?.setSelectionRange(newCursorPos, newCursorPos)
}, 0)
}
}, [value, mentionStartIndex, mentionQuery, onChange])
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (showMentionDropdown && filteredMembers.length > 0) {
switch (e.key) {
case 'ArrowDown':
e.preventDefault()
setSelectedIndex((prev) =>
prev < filteredMembers.length - 1 ? prev + 1 : 0
)
break
case 'ArrowUp':
e.preventDefault()
setSelectedIndex((prev) =>
prev > 0 ? prev - 1 : filteredMembers.length - 1
)
break
case 'Enter':
e.preventDefault()
insertMention(filteredMembers[selectedIndex])
break
case 'Escape':
e.preventDefault()
setShowMentionDropdown(false)
break
case 'Tab':
if (filteredMembers.length > 0) {
e.preventDefault()
insertMention(filteredMembers[selectedIndex])
}
break
}
} else if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
onSubmit()
}
}
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(e.target as Node) &&
inputRef.current &&
!inputRef.current.contains(e.target as Node)
) {
setShowMentionDropdown(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
return (
<div className="relative flex-1">
<input
ref={inputRef}
type="text"
value={value}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
placeholder={placeholder}
disabled={disabled}
className={`w-full px-4 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none ${
isMobile ? 'py-3 text-base' : 'py-2'
} ${className}`}
/>
{/* Mention Dropdown */}
{showMentionDropdown && filteredMembers.length > 0 && (
<div
ref={dropdownRef}
className="absolute bottom-full left-0 right-0 mb-1 bg-white border border-gray-200 rounded-lg shadow-lg max-h-48 overflow-y-auto z-50"
>
{filteredMembers.map((member, index) => {
const displayName = member.display_name || member.user_id
return (
<button
key={member.user_id}
type="button"
onClick={() => insertMention(member)}
className={`w-full px-3 py-2 text-left flex items-center gap-2 hover:bg-gray-100 ${
index === selectedIndex ? 'bg-blue-50' : ''
}`}
>
<div className="w-8 h-8 rounded-full bg-gray-300 flex items-center justify-center text-gray-600 text-sm font-medium">
{displayName.charAt(0).toUpperCase()}
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-gray-900 truncate">
{displayName}
</div>
{member.display_name && member.display_name !== member.user_id && (
<div className="text-xs text-gray-500 truncate">
{member.user_id}
</div>
)}
</div>
{member.role && (
<span className="text-xs text-gray-400 capitalize">
{member.role}
</span>
)}
</button>
)
})}
</div>
)}
{/* No results message */}
{showMentionDropdown && mentionQuery && filteredMembers.length === 0 && (
<div className="absolute bottom-full left-0 right-0 mb-1 bg-white border border-gray-200 rounded-lg shadow-lg p-3 text-sm text-gray-500">
No members found matching "@{mentionQuery}"
</div>
)}
</div>
)
}
/**
* Utility function to highlight @mentions in message content
*/
export function highlightMentions(content: string, currentUserId?: string): React.ReactNode {
const mentionPattern = /@(\S+)/g
const parts: React.ReactNode[] = []
let lastIndex = 0
let match: RegExpExecArray | null
while ((match = mentionPattern.exec(content)) !== null) {
// Add text before the mention
if (match.index > lastIndex) {
parts.push(content.slice(lastIndex, match.index))
}
const mentionedName = match[1]
const isCurrentUser = currentUserId &&
(mentionedName.toLowerCase() === currentUserId.toLowerCase())
// Add the highlighted mention
parts.push(
<span
key={match.index}
className={`px-1 rounded ${
isCurrentUser
? 'bg-yellow-200 text-yellow-800 font-medium'
: 'bg-blue-100 text-blue-700'
}`}
>
@{mentionedName}
</span>
)
lastIndex = match.index + match[0].length
}
// Add remaining text
if (lastIndex < content.length) {
parts.push(content.slice(lastIndex))
}
return <>{parts}</>
}

View File

@@ -0,0 +1,220 @@
import { useState, useEffect } from 'react'
import { notificationService } from '../../services/notification'
import type { NotificationSettings as NotificationSettingsType } from '../../services/notification'
interface NotificationSettingsProps {
isOpen: boolean
onClose: () => void
}
export function NotificationSettings({ isOpen, onClose }: NotificationSettingsProps) {
const [settings, setSettings] = useState<NotificationSettingsType>(notificationService.getSettings())
const [permission, setPermission] = useState(notificationService.getPermission())
const [requesting, setRequesting] = useState(false)
useEffect(() => {
if (isOpen) {
setSettings(notificationService.getSettings())
setPermission(notificationService.getPermission())
}
}, [isOpen])
const handleRequestPermission = async () => {
setRequesting(true)
const newPermission = await notificationService.requestPermission()
setPermission(newPermission)
setRequesting(false)
}
const handleToggle = (key: keyof NotificationSettingsType) => {
const newSettings = { ...settings, [key]: !settings[key] }
setSettings(newSettings)
notificationService.saveSettings(newSettings)
}
const handleTestSound = () => {
notificationService.playSound(false)
}
const handleTestMentionSound = () => {
notificationService.playSound(true)
}
if (!isOpen) return null
return (
<div
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 transition-opacity duration-200"
role="dialog"
aria-modal="true"
aria-labelledby="notification-settings-title"
onClick={(e) => e.target === e.currentTarget && onClose()}
>
<div
className="bg-white rounded-lg shadow-xl max-w-md w-full mx-4 p-6 transform transition-all duration-200"
style={{ animation: 'modal-pop-in 0.2s ease-out' }}
>
<style>{`
@keyframes modal-pop-in {
from { opacity: 0; transform: scale(0.95); }
to { opacity: 1; transform: scale(1); }
}
`}</style>
<div className="flex items-center justify-between mb-4">
<h2 id="notification-settings-title" className="text-lg font-semibold text-gray-900">
Notification Settings
</h2>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 transition-colors p-1 rounded-lg hover:bg-gray-100"
aria-label="Close notification settings"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Permission Status */}
<div className="mb-6 p-4 bg-gray-50 rounded-lg">
<div className="flex items-center justify-between">
<div>
<div className="font-medium text-gray-900">Browser Notifications</div>
<div className="text-sm text-gray-500">
{permission === 'granted' ? (
<span className="text-green-600">Enabled</span>
) : permission === 'denied' ? (
<span className="text-red-600">Blocked (enable in browser settings)</span>
) : (
<span className="text-yellow-600">Not yet requested</span>
)}
</div>
</div>
{permission === 'default' && (
<button
onClick={handleRequestPermission}
disabled={requesting}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 text-sm"
>
{requesting ? 'Requesting...' : 'Enable'}
</button>
)}
</div>
</div>
{/* Settings Toggles */}
<div className="space-y-4">
{/* Push Notifications */}
<label className="flex items-center justify-between cursor-pointer">
<div>
<div className="font-medium text-gray-900">Push Notifications</div>
<div className="text-sm text-gray-500">Show browser notifications for new messages</div>
</div>
<div className="relative">
<input
type="checkbox"
checked={settings.pushEnabled}
onChange={() => handleToggle('pushEnabled')}
className="sr-only"
disabled={permission !== 'granted'}
/>
<div className={`w-11 h-6 rounded-full transition-colors ${
settings.pushEnabled && permission === 'granted' ? 'bg-blue-600' : 'bg-gray-300'
}`}>
<div className={`w-5 h-5 rounded-full bg-white shadow transform transition-transform ${
settings.pushEnabled && permission === 'granted' ? 'translate-x-5' : 'translate-x-0.5'
} mt-0.5`} />
</div>
</div>
</label>
{/* Sound Notifications */}
<label className="flex items-center justify-between cursor-pointer">
<div className="flex-1">
<div className="font-medium text-gray-900">Sound Alerts</div>
<div className="text-sm text-gray-500">Play sound for new messages</div>
</div>
<button
onClick={handleTestSound}
className="text-xs text-blue-600 hover:text-blue-700 mr-3"
>
Test
</button>
<div className="relative">
<input
type="checkbox"
checked={settings.soundEnabled}
onChange={() => handleToggle('soundEnabled')}
className="sr-only"
/>
<div className={`w-11 h-6 rounded-full transition-colors ${
settings.soundEnabled ? 'bg-blue-600' : 'bg-gray-300'
}`}>
<div className={`w-5 h-5 rounded-full bg-white shadow transform transition-transform ${
settings.soundEnabled ? 'translate-x-5' : 'translate-x-0.5'
} mt-0.5`} />
</div>
</div>
</label>
{/* Mention Sound */}
<label className="flex items-center justify-between cursor-pointer">
<div className="flex-1">
<div className="font-medium text-gray-900">Mention Alerts</div>
<div className="text-sm text-gray-500">Special sound when @mentioned</div>
</div>
<button
onClick={handleTestMentionSound}
className="text-xs text-blue-600 hover:text-blue-700 mr-3"
>
Test
</button>
<div className="relative">
<input
type="checkbox"
checked={settings.mentionSoundEnabled}
onChange={() => handleToggle('mentionSoundEnabled')}
className="sr-only"
/>
<div className={`w-11 h-6 rounded-full transition-colors ${
settings.mentionSoundEnabled ? 'bg-blue-600' : 'bg-gray-300'
}`}>
<div className={`w-5 h-5 rounded-full bg-white shadow transform transition-transform ${
settings.mentionSoundEnabled ? 'translate-x-5' : 'translate-x-0.5'
} mt-0.5`} />
</div>
</div>
</label>
{/* Vibration */}
<label className="flex items-center justify-between cursor-pointer">
<div>
<div className="font-medium text-gray-900">Vibration</div>
<div className="text-sm text-gray-500">Vibrate on mobile devices</div>
</div>
<div className="relative">
<input
type="checkbox"
checked={settings.vibrationEnabled}
onChange={() => handleToggle('vibrationEnabled')}
className="sr-only"
/>
<div className={`w-11 h-6 rounded-full transition-colors ${
settings.vibrationEnabled ? 'bg-blue-600' : 'bg-gray-300'
}`}>
<div className={`w-5 h-5 rounded-full bg-white shadow transform transition-transform ${
settings.vibrationEnabled ? 'translate-x-5' : 'translate-x-0.5'
} mt-0.5`} />
</div>
</div>
</label>
</div>
{/* Info */}
<div className="mt-6 text-xs text-gray-500">
<p>Notifications are only shown when the browser tab is not focused.</p>
</div>
</div>
</div>
)
}

View File

@@ -12,6 +12,7 @@ interface MobileHeaderProps {
onGenerateReport: () => void onGenerateReport: () => void
onStatusChange: (status: RoomStatus) => void onStatusChange: (status: RoomStatus) => void
onPermanentDelete: () => void onPermanentDelete: () => void
onNotificationSettings?: () => void
} }
const statusColors: Record<RoomStatus, string> = { const statusColors: Record<RoomStatus, string> = {
@@ -30,6 +31,7 @@ export function MobileHeader({
onGenerateReport, onGenerateReport,
onStatusChange, onStatusChange,
onPermanentDelete, onPermanentDelete,
onNotificationSettings,
}: MobileHeaderProps) { }: MobileHeaderProps) {
const [showMenu, setShowMenu] = useState(false) const [showMenu, setShowMenu] = useState(false)
@@ -122,6 +124,22 @@ export function MobileHeader({
<span className="text-gray-900">{isGeneratingReport ? '生成中...' : '生成報告'}</span> <span className="text-gray-900">{isGeneratingReport ? '生成中...' : '生成報告'}</span>
</button> </button>
{/* Notification Settings */}
{onNotificationSettings && (
<button
onClick={() => {
onNotificationSettings()
setShowMenu(false)
}}
className="w-full flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-gray-50 active:bg-gray-100"
>
<svg className="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
</svg>
<span className="text-gray-900">Notification Settings</span>
</button>
)}
{/* Status Actions */} {/* Status Actions */}
{canUpdateStatus && status === 'active' && ( {canUpdateStatus && status === 'active' && (
<> <>

View File

@@ -0,0 +1,141 @@
import { useState, useEffect } from 'react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { reportsService } from '../../services/reports'
interface ReportPreviewProps {
isOpen: boolean
onClose: () => void
roomId: string
reportId: string
reportTitle?: string | null
onDownload: () => void
}
export default function ReportPreview({
isOpen,
onClose,
roomId,
reportId,
reportTitle,
onDownload,
}: ReportPreviewProps) {
const [markdown, setMarkdown] = useState<string>('')
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [copied, setCopied] = useState(false)
useEffect(() => {
if (isOpen && roomId && reportId) {
loadMarkdown()
}
}, [isOpen, roomId, reportId])
const loadMarkdown = async () => {
setIsLoading(true)
setError(null)
try {
const response = await reportsService.getReportMarkdown(roomId, reportId)
setMarkdown(response.markdown)
} catch (err) {
setError('Failed to load report content')
console.error('Failed to load report markdown:', err)
} finally {
setIsLoading(false)
}
}
const handleCopyMarkdown = async () => {
try {
await navigator.clipboard.writeText(markdown)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch (err) {
console.error('Failed to copy markdown:', err)
}
}
if (!isOpen) return null
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl w-full max-w-4xl max-h-[90vh] mx-4 flex flex-col">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
<h2 className="text-lg font-semibold text-gray-900 truncate">
{reportTitle || 'Report Preview'}
</h2>
<button
onClick={onClose}
className="p-2 text-gray-400 hover:text-gray-600 rounded-lg hover:bg-gray-100"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto px-6 py-4">
{isLoading ? (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
<span className="ml-3 text-gray-500">Loading report...</span>
</div>
) : error ? (
<div className="text-center py-12">
<p className="text-red-500 mb-4">{error}</p>
<button
onClick={loadMarkdown}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
Retry
</button>
</div>
) : (
<div className="prose prose-sm max-w-none prose-table:border-collapse prose-th:border prose-th:border-gray-300 prose-th:p-2 prose-th:bg-gray-100 prose-td:border prose-td:border-gray-300 prose-td:p-2">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{markdown}
</ReactMarkdown>
</div>
)}
</div>
{/* Footer Actions */}
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-gray-200 bg-gray-50">
<button
onClick={handleCopyMarkdown}
disabled={isLoading || !!error}
className="flex items-center gap-2 px-4 py-2 text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
{copied ? (
<>
<svg className="w-4 h-4 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<span className="text-green-600">Copied!</span>
</>
) : (
<>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
<span>Copy Markdown</span>
</>
)}
</button>
<button
onClick={onDownload}
disabled={isLoading || !!error}
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
<span>Download Word</span>
</button>
</div>
</div>
</div>
)
}

View File

@@ -4,6 +4,7 @@
*/ */
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import type { ReportStatus } from '../../types' import type { ReportStatus } from '../../types'
import ReportPreview from './ReportPreview'
interface ReportProgressProps { interface ReportProgressProps {
isOpen: boolean isOpen: boolean
@@ -12,6 +13,8 @@ interface ReportProgressProps {
message: string message: string
error?: string error?: string
reportId?: string reportId?: string
roomId?: string
reportTitle?: string | null
onDownload?: () => void onDownload?: () => void
} }
@@ -34,11 +37,13 @@ export default function ReportProgress({
status, status,
message, message,
error, error,
reportId: _reportId, reportId,
roomId,
reportTitle,
onDownload, onDownload,
}: ReportProgressProps) { }: ReportProgressProps) {
// reportId is available for future use (e.g., polling status)
const [animatedStep, setAnimatedStep] = useState(0) const [animatedStep, setAnimatedStep] = useState(0)
const [showPreview, setShowPreview] = useState(false)
const currentStep = getStepIndex(status) const currentStep = getStepIndex(status)
const isCompleted = status === 'completed' const isCompleted = status === 'completed'
const isFailed = status === 'failed' const isFailed = status === 'failed'
@@ -176,6 +181,14 @@ export default function ReportProgress({
> >
</button> </button>
{roomId && reportId && (
<button
onClick={() => setShowPreview(true)}
className="px-4 py-2 text-sm font-medium text-purple-700 bg-purple-50 border border-purple-200 rounded-md hover:bg-purple-100"
>
</button>
)}
{onDownload && ( {onDownload && (
<button <button
onClick={onDownload} onClick={onDownload}
@@ -194,6 +207,21 @@ export default function ReportProgress({
</div> </div>
</div> </div>
</div> </div>
{/* Report Preview Modal */}
{roomId && reportId && (
<ReportPreview
isOpen={showPreview}
onClose={() => setShowPreview(false)}
roomId={roomId}
reportId={reportId}
reportTitle={reportTitle}
onDownload={() => {
onDownload?.()
setShowPreview(false)
}}
/>
)}
</div> </div>
) )
} }

View File

@@ -2,7 +2,6 @@ export { useAuth } from './useAuth'
export { export {
useRooms, useRooms,
useRoom, useRoom,
useRoomTemplates,
useRoomPermissions, useRoomPermissions,
useCreateRoom, useCreateRoom,
useUpdateRoom, useUpdateRoom,
@@ -11,6 +10,8 @@ export {
useUpdateMemberRole, useUpdateMemberRole,
useRemoveMember, useRemoveMember,
useTransferOwnership, useTransferOwnership,
useAddLot,
useRemoveLot,
roomKeys, roomKeys,
} from './useRooms' } from './useRooms'
export { export {

View File

@@ -2,9 +2,10 @@
* useReports Hook * useReports Hook
* React Query hooks for report generation and management * React Query hooks for report generation and management
*/ */
import { useState, useCallback, useEffect, useRef } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { reportsService } from '../services/reports' import { reportsService } from '../services/reports'
import type { ReportGenerateRequest } from '../types' import type { ReportGenerateRequest, ReportStatus } from '../types'
// Query Keys // Query Keys
const reportKeys = { const reportKeys = {
@@ -90,3 +91,141 @@ export function useInvalidateReports(roomId: string) {
}), }),
} }
} }
// Status message translations
const STATUS_MESSAGES: Record<ReportStatus, string> = {
pending: '準備中...',
collecting_data: '正在收集聊天室資料...',
generating_content: 'AI 正在分析並生成報告內容...',
assembling_document: '正在組裝報告文件...',
completed: '報告生成完成!',
failed: '報告生成失敗',
}
// Polling configuration
const POLL_INTERVAL = 2000 // 2 seconds
const MAX_POLL_DURATION = 120000 // 2 minutes timeout
/**
* Hook to poll report status until completed or failed
* Used as a fallback when WebSocket updates are unreliable
*/
export function useReportPolling(roomId: string, reportId: string | null) {
const [status, setStatus] = useState<ReportStatus | null>(null)
const [message, setMessage] = useState<string>('')
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const [isPolling, setIsPolling] = useState(false)
const pollIntervalRef = useRef<number | null>(null)
const pollStartTimeRef = useRef<number | null>(null)
const queryClient = useQueryClient()
const stopPolling = useCallback(() => {
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current)
pollIntervalRef.current = null
}
setIsPolling(false)
pollStartTimeRef.current = null
}, [])
const pollStatus = useCallback(async () => {
if (!roomId || !reportId) return
// Check timeout
if (pollStartTimeRef.current) {
const elapsed = Date.now() - pollStartTimeRef.current
if (elapsed > MAX_POLL_DURATION) {
stopPolling()
setErrorMessage('報告生成超時,請重試')
setStatus('failed')
return
}
}
try {
const report = await reportsService.getReport(roomId, reportId)
setStatus(report.status)
setMessage(STATUS_MESSAGES[report.status] || '')
if (report.status === 'completed' || report.status === 'failed') {
stopPolling()
if (report.status === 'failed') {
setErrorMessage(report.error_message || '報告生成失敗')
}
// Refresh the reports list
queryClient.invalidateQueries({ queryKey: reportKeys.list(roomId) })
}
} catch (error) {
console.error('Failed to poll report status:', error)
// Don't stop polling on temporary errors
}
}, [roomId, reportId, stopPolling, queryClient])
const startPolling = useCallback(() => {
if (!reportId || isPolling) return
setIsPolling(true)
setErrorMessage(null)
pollStartTimeRef.current = Date.now()
// Poll immediately
pollStatus()
// Then poll at interval
pollIntervalRef.current = window.setInterval(pollStatus, POLL_INTERVAL)
}, [reportId, isPolling, pollStatus])
// Cleanup on unmount or reportId change
useEffect(() => {
return () => {
stopPolling()
}
}, [stopPolling, reportId])
return {
status,
message,
errorMessage,
isPolling,
startPolling,
stopPolling,
}
}
/**
* Hook for generating report with automatic polling
*/
export function useGenerateReportWithPolling(roomId: string) {
const generateReport = useGenerateReport(roomId)
const [currentReportId, setCurrentReportId] = useState<string | null>(null)
const polling = useReportPolling(roomId, currentReportId)
const generate = useCallback(
async (options?: ReportGenerateRequest) => {
try {
const result = await generateReport.mutateAsync(options)
setCurrentReportId(result.report_id)
polling.startPolling()
return result
} catch (error) {
throw error
}
},
[generateReport, polling]
)
const reset = useCallback(() => {
setCurrentReportId(null)
polling.stopPolling()
}, [polling])
return {
generate,
reset,
isGenerating: generateReport.isPending || polling.isPolling,
status: polling.status,
message: polling.message,
errorMessage: polling.errorMessage || (generateReport.error as Error)?.message,
currentReportId,
}
}

View File

@@ -16,7 +16,8 @@ vi.mock('../services/rooms', () => ({
removeMember: vi.fn(), removeMember: vi.fn(),
transferOwnership: vi.fn(), transferOwnership: vi.fn(),
getPermissions: vi.fn(), getPermissions: vi.fn(),
getTemplates: vi.fn(), addLot: vi.fn(),
removeLot: vi.fn(),
}, },
})) }))

View File

@@ -9,7 +9,6 @@ export const roomKeys = {
list: (filters: RoomFilters) => [...roomKeys.lists(), filters] as const, list: (filters: RoomFilters) => [...roomKeys.lists(), filters] as const,
details: () => [...roomKeys.all, 'detail'] as const, details: () => [...roomKeys.all, 'detail'] as const,
detail: (id: string) => [...roomKeys.details(), id] as const, detail: (id: string) => [...roomKeys.details(), id] as const,
templates: () => [...roomKeys.all, 'templates'] as const,
permissions: (id: string) => [...roomKeys.all, 'permissions', id] as const, permissions: (id: string) => [...roomKeys.all, 'permissions', id] as const,
} }
@@ -28,14 +27,6 @@ export function useRoom(roomId: string) {
}) })
} }
export function useRoomTemplates() {
return useQuery({
queryKey: roomKeys.templates(),
queryFn: () => roomsService.getTemplates(),
staleTime: 1000 * 60 * 5, // 5 minutes
})
}
export function useRoomPermissions(roomId: string) { export function useRoomPermissions(roomId: string) {
return useQuery({ return useQuery({
queryKey: roomKeys.permissions(roomId), queryKey: roomKeys.permissions(roomId),
@@ -146,3 +137,27 @@ export function useTransferOwnership(roomId: string) {
}, },
}) })
} }
export function useAddLot(roomId: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (lot: string) => roomsService.addLot(roomId, lot),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: roomKeys.detail(roomId) })
queryClient.invalidateQueries({ queryKey: roomKeys.lists() })
},
})
}
export function useRemoveLot(roomId: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (lot: string) => roomsService.removeLot(roomId, lot),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: roomKeys.detail(roomId) })
queryClient.invalidateQueries({ queryKey: roomKeys.lists() })
},
})
}

View File

@@ -29,26 +29,54 @@ export function useWebSocket(roomId: string | null, options?: UseWebSocketOption
const wsRef = useRef<WebSocket | null>(null) const wsRef = useRef<WebSocket | null>(null)
const reconnectTimeoutRef = useRef<number | null>(null) const reconnectTimeoutRef = useRef<number | null>(null)
const reconnectDelayRef = useRef(RECONNECT_DELAY) const reconnectDelayRef = useRef(RECONNECT_DELAY)
const isConnectingRef = useRef(false)
const currentRoomIdRef = useRef<string | null>(null)
const disconnectTimeoutRef = useRef<number | null>(null)
const shouldBeConnectedRef = useRef(false)
const token = useAuthStore((state) => state.token) const token = useAuthStore((state) => state.token)
const { // Use individual selectors to get stable function references
setConnectionStatus, const setConnectionStatus = useChatStore((state) => state.setConnectionStatus)
addMessage, const addMessage = useChatStore((state) => state.addMessage)
updateMessage, const updateMessage = useChatStore((state) => state.updateMessage)
removeMessage, const removeMessage = useChatStore((state) => state.removeMessage)
setUserTyping, const setUserTyping = useChatStore((state) => state.setUserTyping)
addOnlineUser, const addOnlineUser = useChatStore((state) => state.addOnlineUser)
removeOnlineUser, const removeOnlineUser = useChatStore((state) => state.removeOnlineUser)
} = useChatStore()
const connect = useCallback(() => { const connect = useCallback(() => {
if (!roomId || !token) { if (!roomId || !token) {
return return
} }
// Mark that we want to be connected (cancels pending disconnect)
shouldBeConnectedRef.current = true
if (disconnectTimeoutRef.current) {
clearTimeout(disconnectTimeoutRef.current)
disconnectTimeoutRef.current = null
}
// Prevent multiple simultaneous connection attempts
if (isConnectingRef.current) {
return
}
// Don't reconnect if already connected to the same room
if (
wsRef.current &&
wsRef.current.readyState === WebSocket.OPEN &&
currentRoomIdRef.current === roomId
) {
return
}
isConnectingRef.current = true
currentRoomIdRef.current = roomId
// Close existing connection // Close existing connection
if (wsRef.current) { if (wsRef.current) {
wsRef.current.close() wsRef.current.close()
wsRef.current = null
} }
// Build WebSocket URL - use env variable or default to current host // Build WebSocket URL - use env variable or default to current host
@@ -69,6 +97,7 @@ export function useWebSocket(roomId: string | null, options?: UseWebSocketOption
const ws = new WebSocket(wsUrl) const ws = new WebSocket(wsUrl)
ws.onopen = () => { ws.onopen = () => {
isConnectingRef.current = false
setConnectionStatus('connected') setConnectionStatus('connected')
reconnectDelayRef.current = RECONNECT_DELAY reconnectDelayRef.current = RECONNECT_DELAY
} }
@@ -83,16 +112,23 @@ export function useWebSocket(roomId: string | null, options?: UseWebSocketOption
} }
ws.onerror = () => { ws.onerror = () => {
isConnectingRef.current = false
setConnectionStatus('error') setConnectionStatus('error')
} }
ws.onclose = () => { ws.onclose = () => {
isConnectingRef.current = false
wsRef.current = null
setConnectionStatus('disconnected') setConnectionStatus('disconnected')
// Only reconnect if we're still supposed to be connected to this room
if (currentRoomIdRef.current === roomId) {
scheduleReconnect() scheduleReconnect()
} }
}
wsRef.current = ws wsRef.current = ws
}, [roomId, token, setConnectionStatus]) // eslint-disable-next-line react-hooks/exhaustive-deps
}, [roomId, token])
const handleMessage = useCallback( const handleMessage = useCallback(
(data: unknown) => { (data: unknown) => {
@@ -106,6 +142,7 @@ export function useWebSocket(roomId: string | null, options?: UseWebSocketOption
message_id: messageBroadcast.message_id, message_id: messageBroadcast.message_id,
room_id: messageBroadcast.room_id, room_id: messageBroadcast.room_id,
sender_id: messageBroadcast.sender_id, sender_id: messageBroadcast.sender_id,
sender_display_name: messageBroadcast.sender_display_name,
content: messageBroadcast.content, content: messageBroadcast.content,
message_type: messageBroadcast.message_type, message_type: messageBroadcast.message_type,
metadata: messageBroadcast.metadata, metadata: messageBroadcast.metadata,
@@ -260,25 +297,43 @@ export function useWebSocket(roomId: string | null, options?: UseWebSocketOption
) )
const disconnect = useCallback(() => { const disconnect = useCallback(() => {
// Mark that we want to disconnect
shouldBeConnectedRef.current = false
currentRoomIdRef.current = null
// Clear any pending reconnect
if (reconnectTimeoutRef.current) { if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current) clearTimeout(reconnectTimeoutRef.current)
reconnectTimeoutRef.current = null
} }
if (wsRef.current) {
// Clear any pending disconnect
if (disconnectTimeoutRef.current) {
clearTimeout(disconnectTimeoutRef.current)
}
// Delay the actual disconnect to handle React StrictMode remount
// If connect() is called again within 100ms, the disconnect will be cancelled
disconnectTimeoutRef.current = window.setTimeout(() => {
if (!shouldBeConnectedRef.current && wsRef.current) {
wsRef.current.close() wsRef.current.close()
wsRef.current = null wsRef.current = null
} }
disconnectTimeoutRef.current = null
}, 100)
}, []) }, [])
// Connect when roomId changes // Connect when roomId or token changes
useEffect(() => { useEffect(() => {
if (roomId) { if (roomId && token) {
connect() connect()
} }
return () => { return () => {
disconnect() disconnect()
} }
}, [roomId, connect, disconnect]) // eslint-disable-next-line react-hooks/exhaustive-deps
}, [roomId, token])
return { return {
sendTextMessage, sendTextMessage,

View File

@@ -8,6 +8,8 @@ import {
useUpdateMemberRole, useUpdateMemberRole,
useRemoveMember, useRemoveMember,
usePermanentDeleteRoom, usePermanentDeleteRoom,
useAddLot,
useRemoveLot,
} from '../hooks/useRooms' } from '../hooks/useRooms'
import { useMessages } from '../hooks/useMessages' import { useMessages } from '../hooks/useMessages'
import { useWebSocket } from '../hooks/useWebSocket' import { useWebSocket } from '../hooks/useWebSocket'
@@ -16,12 +18,18 @@ import { useGenerateReport, useDownloadReport } from '../hooks/useReports'
import { useUserSearch } from '../hooks/useUsers' import { useUserSearch } from '../hooks/useUsers'
import { useIsMobile } from '../hooks/useMediaQuery' import { useIsMobile } from '../hooks/useMediaQuery'
import { filesService } from '../services/files' import { filesService } from '../services/files'
import { notificationService } from '../services/notification'
import { useChatStore } from '../stores/chatStore' import { useChatStore } from '../stores/chatStore'
import { useMentionStore } from '../stores/mentionStore'
import { useAuthStore, useIsAdmin } from '../stores/authStore' import { useAuthStore, useIsAdmin } from '../stores/authStore'
import { Breadcrumb } from '../components/common' import { Breadcrumb } from '../components/common'
import { MobileHeader, BottomToolbar, SlidePanel } from '../components/mobile' import { MobileHeader, SlidePanel } from '../components/mobile'
import { ActionBar } from '../components/chat/ActionBar'
import { MentionInput, highlightMentions } from '../components/chat/MentionInput'
import { NotificationSettings } from '../components/chat/NotificationSettings'
import ReportProgress from '../components/report/ReportProgress' import ReportProgress from '../components/report/ReportProgress'
import type { SeverityLevel, RoomStatus, MemberRole, FileMetadata, ReportStatus } from '../types' import { formatMessageTime } from '../utils/datetime'
import type { SeverityLevel, RoomStatus, MemberRole, FileMetadata, ReportStatus, Message } from '../types'
const statusColors: Record<RoomStatus, string> = { const statusColors: Record<RoomStatus, string> = {
active: 'bg-green-100 text-green-800', active: 'bg-green-100 text-green-800',
@@ -56,6 +64,7 @@ export default function RoomDetail() {
const { data: messagesData, isLoading: messagesLoading } = useMessages(roomId || '', { limit: 50 }) const { data: messagesData, isLoading: messagesLoading } = useMessages(roomId || '', { limit: 50 })
const { messages, connectionStatus, typingUsers, onlineUsers, setMessages, setCurrentRoom } = useChatStore() const { messages, connectionStatus, typingUsers, onlineUsers, setMessages, setCurrentRoom } = useChatStore()
const { addMention, clearMentions } = useMentionStore()
// Handle room deleted event from WebSocket // Handle room deleted event from WebSocket
const handleRoomDeleted = useCallback((deletedRoomId: string) => { const handleRoomDeleted = useCallback((deletedRoomId: string) => {
@@ -65,9 +74,32 @@ export default function RoomDetail() {
} }
}, [roomId, navigate]) }, [roomId, navigate])
// Handle new message notification
const handleNewMessage = useCallback((message: Message) => {
// Don't notify for own messages
if (message.sender_id === user?.username) return
// Check if current user is mentioned
const isMentioned = message.content.toLowerCase().includes(`@${user?.username?.toLowerCase()}`)
// Track mention in store if tab is not focused
if (isMentioned && !document.hasFocus() && roomId) {
addMention(roomId)
}
// Trigger notification
notificationService.notifyNewMessage(
message.sender_display_name || message.sender_id,
message.content,
roomId || '',
room?.title || 'Chat Room',
isMentioned
)
}, [user?.username, roomId, room?.title, addMention])
const { sendTextMessage, sendTyping, editMessage, deleteMessage, addReaction, removeReaction } = useWebSocket( const { sendTextMessage, sendTyping, editMessage, deleteMessage, addReaction, removeReaction } = useWebSocket(
roomId || null, roomId || null,
{ onRoomDeleted: handleRoomDeleted } { onRoomDeleted: handleRoomDeleted, onMessage: handleNewMessage }
) )
// Mutations // Mutations
@@ -76,6 +108,8 @@ export default function RoomDetail() {
const updateMemberRole = useUpdateMemberRole(roomId || '') const updateMemberRole = useUpdateMemberRole(roomId || '')
const removeMember = useRemoveMember(roomId || '') const removeMember = useRemoveMember(roomId || '')
const permanentDeleteRoom = usePermanentDeleteRoom() const permanentDeleteRoom = usePermanentDeleteRoom()
const addLot = useAddLot(roomId || '')
const removeLot = useRemoveLot(roomId || '')
// File hooks // File hooks
const { data: filesData, isLoading: filesLoading } = useFiles(roomId || '') const { data: filesData, isLoading: filesLoading } = useFiles(roomId || '')
@@ -102,6 +136,7 @@ export default function RoomDetail() {
const [editingMessageId, setEditingMessageId] = useState<string | null>(null) const [editingMessageId, setEditingMessageId] = useState<string | null>(null)
const [editContent, setEditContent] = useState('') const [editContent, setEditContent] = useState('')
const [showEmojiPickerFor, setShowEmojiPickerFor] = useState<string | null>(null) const [showEmojiPickerFor, setShowEmojiPickerFor] = useState<string | null>(null)
const [copiedMessageId, setCopiedMessageId] = useState<string | null>(null)
const [uploadProgress, setUploadProgress] = useState<number | null>(null) const [uploadProgress, setUploadProgress] = useState<number | null>(null)
const [isDragging, setIsDragging] = useState(false) const [isDragging, setIsDragging] = useState(false)
const [previewFile, setPreviewFile] = useState<FileMetadata | null>(null) const [previewFile, setPreviewFile] = useState<FileMetadata | null>(null)
@@ -113,6 +148,8 @@ export default function RoomDetail() {
const [showUserDropdown, setShowUserDropdown] = useState(false) const [showUserDropdown, setShowUserDropdown] = useState(false)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [deleteConfirmInput, setDeleteConfirmInput] = useState('') const [deleteConfirmInput, setDeleteConfirmInput] = useState('')
const [newLotInput, setNewLotInput] = useState('')
const [showNotificationSettings, setShowNotificationSettings] = useState(false)
const messagesEndRef = useRef<HTMLDivElement>(null) const messagesEndRef = useRef<HTMLDivElement>(null)
const typingTimeoutRef = useRef<number | null>(null) const typingTimeoutRef = useRef<number | null>(null)
const searchTimeoutRef = useRef<number | null>(null) const searchTimeoutRef = useRef<number | null>(null)
@@ -138,15 +175,17 @@ export default function RoomDetail() {
} }
}, [userSearchQuery]) }, [userSearchQuery])
// Initialize room // Initialize room and clear mention badges
useEffect(() => { useEffect(() => {
if (roomId) { if (roomId) {
setCurrentRoom(roomId) setCurrentRoom(roomId)
// Clear unread mentions when entering the room
clearMentions(roomId)
} }
return () => { return () => {
setCurrentRoom(null) setCurrentRoom(null)
} }
}, [roomId, setCurrentRoom]) }, [roomId, setCurrentRoom, clearMentions])
// Load initial messages // Load initial messages
useEffect(() => { useEffect(() => {
@@ -160,24 +199,6 @@ export default function RoomDetail() {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages]) }, [messages])
// Handle typing indicator
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setMessageInput(e.target.value)
// Send typing indicator
sendTyping(true)
// Clear previous timeout
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current)
}
// Stop typing after 2 seconds of inactivity
typingTimeoutRef.current = window.setTimeout(() => {
sendTyping(false)
}, 2000)
}
const handleSendMessage = (e: React.FormEvent) => { const handleSendMessage = (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
if (!messageInput.trim()) return if (!messageInput.trim()) return
@@ -210,6 +231,16 @@ export default function RoomDetail() {
} }
} }
const handleCopyMessage = async (messageId: string, content: string) => {
try {
await navigator.clipboard.writeText(content)
setCopiedMessageId(messageId)
setTimeout(() => setCopiedMessageId(null), 2000)
} catch (err) {
console.error('Failed to copy message:', err)
}
}
const handleAddReaction = (messageId: string, emoji: string) => { const handleAddReaction = (messageId: string, emoji: string) => {
addReaction(messageId, emoji) addReaction(messageId, emoji)
setShowEmojiPickerFor(null) setShowEmojiPickerFor(null)
@@ -253,6 +284,21 @@ export default function RoomDetail() {
} }
} }
// LOT handlers
const handleAddLot = (e: React.FormEvent) => {
e.preventDefault()
if (!newLotInput.trim()) return
addLot.mutate(newLotInput.trim(), {
onSuccess: () => setNewLotInput(''),
})
}
const handleRemoveLot = (lot: string) => {
if (window.confirm(`Remove LOT "${lot}"?`)) {
removeLot.mutate(lot)
}
}
// Permanent delete room (admin only) // Permanent delete room (admin only)
const handlePermanentDelete = () => { const handlePermanentDelete = () => {
if (!roomId || !room) return if (!roomId || !room) return
@@ -361,21 +407,52 @@ export default function RoomDetail() {
} }
} }
// Listen for WebSocket report progress updates // Listen for WebSocket report progress updates with polling fallback
useEffect(() => { useEffect(() => {
// This effect sets up listening for report progress via WebSocket if (!showReportProgress || !reportProgress.reportId || !roomId) return
// The actual WebSocket handling should be done in the useWebSocket hook
// For now, we'll poll the report status if a report is being generated
if (!showReportProgress || !reportProgress.reportId) return
if (reportProgress.status === 'completed' || reportProgress.status === 'failed') return if (reportProgress.status === 'completed' || reportProgress.status === 'failed') return
// Status message translations
const statusMessages: Record<string, string> = {
pending: '準備中...',
collecting_data: '正在收集聊天室資料...',
generating_content: 'AI 正在分析並生成報告內容...',
assembling_document: '正在組裝報告文件...',
completed: '報告生成完成!',
failed: '報告生成失敗',
}
// Poll every 2 seconds for status updates (fallback for WebSocket) // Poll every 2 seconds for status updates (fallback for WebSocket)
const pollInterval = setInterval(async () => { const pollStatus = async () => {
// The WebSocket should handle this, but we keep polling as fallback try {
}, 2000) const response = await fetch(
`${import.meta.env.VITE_API_BASE_URL || '/api'}/rooms/${roomId}/reports/${reportProgress.reportId}`,
{
headers: {
Authorization: `Bearer ${localStorage.getItem('auth-storage') ? JSON.parse(localStorage.getItem('auth-storage')!).state?.token : ''}`,
},
}
)
if (response.ok) {
const report = await response.json()
setReportProgress((prev) => ({
...prev,
status: report.status,
message: statusMessages[report.status] || prev.message,
error: report.error_message,
}))
}
} catch (error) {
console.error('Failed to poll report status:', error)
}
}
// Poll immediately, then every 2 seconds
pollStatus()
const pollInterval = setInterval(pollStatus, 2000)
return () => clearInterval(pollInterval) return () => clearInterval(pollInterval)
}, [showReportProgress, reportProgress.reportId, reportProgress.status]) }, [showReportProgress, reportProgress.reportId, reportProgress.status, roomId])
if (roomLoading) { if (roomLoading) {
return ( return (
@@ -426,6 +503,7 @@ export default function RoomDetail() {
onGenerateReport={handleGenerateReport} onGenerateReport={handleGenerateReport}
onStatusChange={handleStatusChange} onStatusChange={handleStatusChange}
onPermanentDelete={() => setShowDeleteConfirm(true)} onPermanentDelete={() => setShowDeleteConfirm(true)}
onNotificationSettings={() => setShowNotificationSettings(true)}
/> />
) : ( ) : (
<header className="bg-white shadow-sm flex-shrink-0"> <header className="bg-white shadow-sm flex-shrink-0">
@@ -453,6 +531,71 @@ export default function RoomDetail() {
</span> </span>
{room.location && <span className="text-gray-500">{room.location}</span>} {room.location && <span className="text-gray-500">{room.location}</span>}
</div> </div>
{/* LOT Display */}
{room.lots && room.lots.length > 0 && (
<div className="flex items-center gap-2 mt-1">
<span className="text-xs text-gray-500">LOT:</span>
<div className="flex flex-wrap gap-1">
{room.lots.map((lot) => (
<span
key={lot}
className="inline-flex items-center gap-1 px-2 py-0.5 bg-purple-100 text-purple-700 rounded text-xs"
>
{lot}
{permissions?.can_write && (
<button
onClick={() => handleRemoveLot(lot)}
className="hover:text-purple-900"
title="Remove LOT"
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</span>
))}
</div>
{permissions?.can_write && (
<form onSubmit={handleAddLot} className="flex items-center gap-1">
<input
type="text"
value={newLotInput}
onChange={(e) => setNewLotInput(e.target.value)}
placeholder="Add LOT"
className="w-24 px-2 py-0.5 text-xs border border-gray-300 rounded focus:ring-1 focus:ring-purple-500 focus:border-purple-500 outline-none"
/>
<button
type="submit"
disabled={!newLotInput.trim() || addLot.isPending}
className="px-2 py-0.5 text-xs bg-purple-600 text-white rounded hover:bg-purple-700 disabled:opacity-50"
>
+
</button>
</form>
)}
</div>
)}
{/* Show add LOT form when no LOTs exist */}
{(!room.lots || room.lots.length === 0) && permissions?.can_write && (
<form onSubmit={handleAddLot} className="flex items-center gap-2 mt-1">
<span className="text-xs text-gray-500">LOT:</span>
<input
type="text"
value={newLotInput}
onChange={(e) => setNewLotInput(e.target.value)}
placeholder="Add LOT batch number"
className="w-32 px-2 py-0.5 text-xs border border-gray-300 rounded focus:ring-1 focus:ring-purple-500 focus:border-purple-500 outline-none"
/>
<button
type="submit"
disabled={!newLotInput.trim() || addLot.isPending}
className="px-2 py-0.5 text-xs bg-purple-600 text-white rounded hover:bg-purple-700 disabled:opacity-50"
>
+ Add
</button>
</form>
)}
</div> </div>
</div> </div>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
@@ -472,21 +615,15 @@ export default function RoomDetail() {
</span> </span>
</div> </div>
{/* Generate Report Button */} {/* Notification Settings */}
<button <button
onClick={handleGenerateReport} onClick={() => setShowNotificationSettings(true)}
disabled={generateReport.isPending} className="p-1.5 text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded"
className="flex items-center gap-1 px-3 py-1.5 text-sm bg-purple-600 text-white rounded-md hover:bg-purple-700 disabled:opacity-50 disabled:cursor-not-allowed" title="Notification settings"
> >
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg> </svg>
{generateReport.isPending ? '生成中...' : '生成報告'}
</button> </button>
{/* Status Actions (Owner only) */} {/* Status Actions (Owner only) */}
@@ -517,38 +654,6 @@ export default function RoomDetail() {
Delete Permanently Delete Permanently
</button> </button>
)} )}
{/* Files Toggle */}
<button
onClick={handleFilesToggle}
className={`flex items-center gap-1 ${showFiles ? 'text-blue-600' : 'text-gray-600 hover:text-gray-800'}`}
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"
/>
</svg>
<span className="text-sm">Files</span>
</button>
{/* Members Toggle */}
<button
onClick={handleMembersToggle}
className={`flex items-center gap-1 ${showMembers ? 'text-blue-600' : 'text-gray-600 hover:text-gray-800'}`}
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"
/>
</svg>
<span className="text-sm">{room.member_count}</span>
</button>
</div> </div>
</div> </div>
</div> </div>
@@ -588,7 +693,7 @@ export default function RoomDetail() {
> >
{!isOwnMessage && ( {!isOwnMessage && (
<div className="text-xs font-medium text-gray-500 mb-1"> <div className="text-xs font-medium text-gray-500 mb-1">
{message.sender_id} {message.sender_display_name || message.sender_id}
</div> </div>
)} )}
@@ -623,7 +728,7 @@ export default function RoomDetail() {
) : ( ) : (
<> <>
<p className={isOwnMessage ? 'text-white' : 'text-gray-900'}> <p className={isOwnMessage ? 'text-white' : 'text-gray-900'}>
{message.content} {highlightMentions(message.content, user?.username)}
</p> </p>
{/* Reactions Display */} {/* Reactions Display */}
@@ -653,7 +758,7 @@ export default function RoomDetail() {
isOwnMessage ? 'text-blue-200' : 'text-gray-400' isOwnMessage ? 'text-blue-200' : 'text-gray-400'
}`} }`}
> >
{new Date(message.created_at).toLocaleTimeString()} {formatMessageTime(message.created_at)}
{message.edited_at && ' (edited)'} {message.edited_at && ' (edited)'}
</div> </div>
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity"> <div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
@@ -685,6 +790,22 @@ export default function RoomDetail() {
</div> </div>
)} )}
</div> </div>
{/* Copy Message Button */}
<button
onClick={() => handleCopyMessage(message.message_id, message.content)}
className={`p-1 ${isOwnMessage ? 'text-blue-200 hover:text-white' : 'text-gray-400 hover:text-gray-600'}`}
title={copiedMessageId === message.message_id ? 'Copied!' : 'Copy message'}
>
{copiedMessageId === message.message_id ? (
<svg className="w-3 h-3 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
) : (
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
)}
</button>
{/* Edit/Delete (own messages only) */} {/* Edit/Delete (own messages only) */}
{isOwnMessage && ( {isOwnMessage && (
<> <>
@@ -727,18 +848,60 @@ export default function RoomDetail() {
</div> </div>
)} )}
{/* Message Input */} {/* Action Bar - Above message input */}
<ActionBar
showFiles={showFiles}
showMembers={showMembers}
memberCount={room.member_count || 0}
onFilesToggle={handleFilesToggle}
onMembersToggle={handleMembersToggle}
canWrite={permissions?.can_write || false}
canManageMembers={permissions?.can_manage_members || false}
isGeneratingReport={generateReport.isPending}
uploadProgress={uploadProgress}
onFileSelect={handleFileUpload}
onGenerateReport={handleGenerateReport}
onAddMemberClick={() => setShowAddMember(true)}
isMobile={isMobile}
/>
{/* Message Input with @Mention Support */}
{permissions?.can_write && ( {permissions?.can_write && (
<form onSubmit={handleSendMessage} className={`p-4 bg-white border-t ${isMobile ? 'pb-2' : ''}`}> <form onSubmit={handleSendMessage} className={`p-4 bg-white border-t ${isMobile ? 'pb-2' : ''}`}>
<div className="flex gap-2"> <div className="flex gap-2">
<input <MentionInput
type="text"
value={messageInput} value={messageInput}
onChange={handleInputChange} onChange={(value) => {
placeholder="Type a message..." setMessageInput(value)
className={`flex-1 px-4 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none ${ // Trigger typing indicator
isMobile ? 'py-3 text-base' : 'py-2' sendTyping(true)
}`} // Clear previous timeout
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current)
}
// Stop typing after 2 seconds of inactivity
typingTimeoutRef.current = window.setTimeout(() => {
sendTyping(false)
}, 2000)
}}
onSubmit={() => {
if (messageInput.trim()) {
sendTextMessage(messageInput.trim())
setMessageInput('')
// Clear typing timeout
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current)
}
sendTyping(false)
}
}}
members={(room.members || []).map((m) => ({
user_id: m.user_id,
display_name: m.user_id, // TODO: Add display_name to RoomMember API
role: m.role,
}))}
placeholder="Type a message... (@ to mention)"
isMobile={isMobile}
/> />
<button <button
type="submit" type="submit"
@@ -752,17 +915,6 @@ export default function RoomDetail() {
</div> </div>
</form> </form>
)} )}
{/* Bottom Toolbar - Mobile Only */}
{isMobile && (
<BottomToolbar
showFiles={showFiles}
showMembers={showMembers}
memberCount={room.member_count || 0}
onFilesToggle={handleFilesToggle}
onMembersToggle={handleMembersToggle}
/>
)}
</div> </div>
{/* Members Sidebar - Desktop Only */} {/* Members Sidebar - Desktop Only */}
@@ -977,11 +1129,11 @@ export default function RoomDetail() {
<div className="text-center py-4"> <div className="text-center py-4">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600 mx-auto"></div> <div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600 mx-auto"></div>
</div> </div>
) : filesData?.files.length === 0 ? ( ) : filesData?.files?.length === 0 ? (
<p className="text-sm text-gray-500 text-center py-4">No files uploaded yet</p> <p className="text-sm text-gray-500 text-center py-4">No files uploaded yet</p>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
{filesData?.files.map((file) => ( {filesData?.files?.map((file) => (
<div <div
key={file.file_id} key={file.file_id}
className="flex items-center gap-2 p-2 hover:bg-gray-50 rounded" className="flex items-center gap-2 p-2 hover:bg-gray-50 rounded"
@@ -1253,11 +1405,11 @@ export default function RoomDetail() {
<div className="text-center py-8"> <div className="text-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div> <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
</div> </div>
) : filesData?.files.length === 0 ? ( ) : filesData?.files?.length === 0 ? (
<p className="text-base text-gray-500 text-center py-8">No files uploaded yet</p> <p className="text-base text-gray-500 text-center py-8">No files uploaded yet</p>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
{filesData?.files.map((file) => ( {filesData?.files?.map((file) => (
<div <div
key={file.file_id} key={file.file_id}
className="flex items-center gap-3 p-3 hover:bg-gray-50 rounded-lg" className="flex items-center gap-3 p-3 hover:bg-gray-50 rounded-lg"
@@ -1369,6 +1521,8 @@ export default function RoomDetail() {
message={reportProgress.message} message={reportProgress.message}
error={reportProgress.error} error={reportProgress.error}
reportId={reportProgress.reportId} reportId={reportProgress.reportId}
roomId={roomId}
reportTitle={room?.title}
onDownload={handleDownloadReport} onDownload={handleDownloadReport}
/> />
@@ -1439,6 +1593,12 @@ export default function RoomDetail() {
</div> </div>
</div> </div>
)} )}
{/* Notification Settings Modal */}
<NotificationSettings
isOpen={showNotificationSettings}
onClose={() => setShowNotificationSettings(false)}
/>
</div> </div>
) )
} }

View File

@@ -1,9 +1,11 @@
import { useState } from 'react' import { useState } from 'react'
import { Link, useNavigate } from 'react-router' import { Link, useNavigate } from 'react-router'
import { useRooms, useCreateRoom, useRoomTemplates, useJoinRoom } from '../hooks/useRooms' import { useRooms, useCreateRoom, useJoinRoom } from '../hooks/useRooms'
import { useAuthStore, useIsAdmin } from '../stores/authStore' import { useAuthStore, useIsAdmin } from '../stores/authStore'
import { useMentionStore } from '../stores/mentionStore'
import { useIsMobile } from '../hooks/useMediaQuery' import { useIsMobile } from '../hooks/useMediaQuery'
import { Breadcrumb } from '../components/common' import { Breadcrumb } from '../components/common'
import { formatRelativeTimeGMT8 } from '../utils/datetime'
import type { RoomStatus, IncidentType, SeverityLevel, CreateRoomRequest, Room } from '../types' import type { RoomStatus, IncidentType, SeverityLevel, CreateRoomRequest, Room } from '../types'
const statusColors: Record<RoomStatus, string> = { const statusColors: Record<RoomStatus, string> = {
@@ -42,6 +44,7 @@ export default function RoomList() {
const [myRoomsOnly, setMyRoomsOnly] = useState(false) const [myRoomsOnly, setMyRoomsOnly] = useState(false)
const joinRoom = useJoinRoom() const joinRoom = useJoinRoom()
const unreadMentions = useMentionStore((state) => state.unreadMentions)
// Reset page when filters change // Reset page when filters change
const handleStatusChange = (status: RoomStatus | '') => { const handleStatusChange = (status: RoomStatus | '') => {
@@ -182,7 +185,7 @@ export default function RoomList() {
<div className="text-center py-12"> <div className="text-center py-12">
<p className="text-red-500">Failed to load rooms</p> <p className="text-red-500">Failed to load rooms</p>
</div> </div>
) : data?.rooms.length === 0 ? ( ) : !data?.rooms?.length ? (
<div className="text-center py-12"> <div className="text-center py-12">
<p className="text-gray-500">No rooms found</p> <p className="text-gray-500">No rooms found</p>
<button <button
@@ -195,13 +198,14 @@ export default function RoomList() {
) : ( ) : (
<> <>
<div className={`grid gap-4 ${isMobile ? 'grid-cols-1' : 'md:grid-cols-2 lg:grid-cols-3'}`}> <div className={`grid gap-4 ${isMobile ? 'grid-cols-1' : 'md:grid-cols-2 lg:grid-cols-3'}`}>
{data?.rooms.map((room) => ( {data?.rooms?.map((room) => (
<RoomCard <RoomCard
key={room.room_id} key={room.room_id}
room={room} room={room}
onJoin={handleJoinRoom} onJoin={handleJoinRoom}
isJoining={joinRoom.isPending} isJoining={joinRoom.isPending}
isMobile={isMobile} isMobile={isMobile}
mentionCount={unreadMentions[room.room_id] || 0}
/> />
))} ))}
</div> </div>
@@ -249,11 +253,13 @@ function RoomCard({
onJoin, onJoin,
isJoining, isJoining,
isMobile, isMobile,
mentionCount,
}: { }: {
room: Room room: Room
onJoin: (e: React.MouseEvent, roomId: string) => void onJoin: (e: React.MouseEvent, roomId: string) => void
isJoining: boolean isJoining: boolean
isMobile: boolean isMobile: boolean
mentionCount: number
}) { }) {
const isMember = room.is_member || room.current_user_role !== null const isMember = room.is_member || room.current_user_role !== null
@@ -261,10 +267,17 @@ function RoomCard({
<Link <Link
to={isMember ? `/rooms/${room.room_id}` : '#'} to={isMember ? `/rooms/${room.room_id}` : '#'}
onClick={!isMember ? (e) => e.preventDefault() : undefined} onClick={!isMember ? (e) => e.preventDefault() : undefined}
className={`bg-white rounded-lg shadow-sm border border-gray-200 hover:shadow-md transition-shadow block ${ className={`bg-white rounded-lg shadow-sm border border-gray-200 hover:shadow-md transition-shadow block relative ${
isMobile ? 'p-4' : 'p-4' isMobile ? 'p-4' : 'p-4'
}`} }`}
> >
{/* Mention Badge */}
{mentionCount > 0 && (
<div className="absolute -top-2 -right-2 bg-red-500 text-white text-xs font-bold rounded-full min-w-[20px] h-5 flex items-center justify-center px-1.5 shadow-sm">
{mentionCount > 99 ? '99+' : mentionCount}
</div>
)}
{/* Room Header */} {/* Room Header */}
<div className="flex justify-between items-start mb-2"> <div className="flex justify-between items-start mb-2">
<h3 className={`font-semibold text-gray-900 truncate flex-1 ${isMobile ? 'text-base' : ''}`}> <h3 className={`font-semibold text-gray-900 truncate flex-1 ${isMobile ? 'text-base' : ''}`}>
@@ -326,7 +339,7 @@ function RoomCard({
</button> </button>
) : ( ) : (
<span className={`text-gray-400 ${isMobile ? 'text-sm' : 'text-xs'}`}> <span className={`text-gray-400 ${isMobile ? 'text-sm' : 'text-xs'}`}>
{new Date(room.last_activity_at).toLocaleDateString()} {formatRelativeTimeGMT8(room.last_activity_at)}
</span> </span>
)} )}
</div> </div>
@@ -343,18 +356,39 @@ function CreateRoomModal({ onClose }: { onClose: () => void }) {
const [location, setLocation] = useState('') const [location, setLocation] = useState('')
const createRoom = useCreateRoom() const createRoom = useCreateRoom()
// Templates loaded for future use (template selection feature) const [lots, setLots] = useState<string[]>([''])
useRoomTemplates()
const handleAddLot = () => {
setLots([...lots, ''])
}
const handleRemoveLot = (index: number) => {
if (lots.length > 1) {
setLots(lots.filter((_, i) => i !== index))
} else {
setLots([''])
}
}
const handleLotChange = (index: number, value: string) => {
const newLots = [...lots]
newLots[index] = value
setLots(newLots)
}
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
// Filter out empty LOT values
const validLots = lots.filter(lot => lot.trim() !== '')
const data: CreateRoomRequest = { const data: CreateRoomRequest = {
title, title,
incident_type: incidentType, incident_type: incidentType,
severity, severity,
description: description || undefined, description: description || undefined,
location: location || undefined, location: location || undefined,
lots: validLots.length > 0 ? validLots : undefined,
} }
createRoom.mutate(data, { createRoom.mutate(data, {
@@ -447,6 +481,46 @@ function CreateRoomModal({ onClose }: { onClose: () => void }) {
/> />
</div> </div>
{/* LOT Batch Numbers */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
LOT Batch Numbers
</label>
<div className="space-y-2">
{lots.map((lot, index) => (
<div key={index} className="flex gap-2">
<input
type="text"
value={lot}
onChange={(e) => handleLotChange(index, e.target.value)}
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none"
placeholder="e.g., LOT-2024-001"
/>
<button
type="button"
onClick={() => handleRemoveLot(index)}
className="px-3 py-2 text-red-600 hover:bg-red-50 rounded-lg"
title="Remove LOT"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
))}
<button
type="button"
onClick={handleAddLot}
className="flex items-center gap-1 text-blue-600 hover:text-blue-700 text-sm"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
Add LOT
</button>
</div>
</div>
{/* Error */} {/* Error */}
{createRoom.isError && ( {createRoom.isError && (
<div className="bg-red-50 text-red-600 px-3 py-2 rounded-lg text-sm"> <div className="bg-red-50 text-red-600 px-3 py-2 rounded-lg text-sm">

View File

@@ -0,0 +1,217 @@
/**
* Notification Service
* Handles browser push notifications, sounds, and vibration for chat messages
*/
// Notification permission states
export type NotificationPermission = 'default' | 'granted' | 'denied'
// Notification settings stored in localStorage
export interface NotificationSettings {
soundEnabled: boolean
pushEnabled: boolean
vibrationEnabled: boolean
mentionSoundEnabled: boolean
}
const DEFAULT_SETTINGS: NotificationSettings = {
soundEnabled: true,
pushEnabled: true,
vibrationEnabled: true,
mentionSoundEnabled: true,
}
const SETTINGS_KEY = 'task_reporter_notification_settings'
class NotificationService {
private audio: HTMLAudioElement | null = null
private mentionAudio: HTMLAudioElement | null = null
constructor() {
// Initialize audio elements
if (typeof window !== 'undefined') {
// Default notification sound (a simple beep)
this.audio = new Audio()
this.audio.src = 'data:audio/wav;base64,UklGRnoGAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQoGAACBhYqFbF1fdJivrJBhNjVgodDbq2EcBj+a2teleXV8gIF/gH2FjJF6AkB6mq+iblYoL0F+oMTTpGYjEFKV0dyjhng9MUOF3P/Xl1xBK0J/u97GbT4jQ3u90MGBSCoWRLXg7MR1PSUkUans5JlaMBdClenv2YhaGABmqO/z0JR0MBpDjuvs1pNwIg9GoeXwz59vIxVWq+/s0ZdsIyBbm+rqyY5dFAtYpO3ozJJdDwlko+vix5JbDghmpeHiyI9VDhhuoeLYxI5aEBJ0ouDhxpJmJA5rmuLhvYtdHBZ0ouLTvYhnIhd1mNvNsIVeGRt6mN3VsYBiIRd/mNzUsYFlIBh+mt/XsIBnHxaLo+XWw41pJR92oufYwJNrKBZ3ouvYyJl0Khh1oeXYypVzKBd5purrz5l8Lx93oefmzJt8LBl8penmz5x+MRd1oejpzZ2CMhV3pO3nzp2INBVypPHoyqCILRFwo/Trx6OLKw5vpfbszKiMLA5xpvfqy6iOMQ1ro/nuzquQMQ5spfvvz6yTMw5spv7wyqySMQ5sqP/xxKyQMhRspP3vwq2SMxBupP3vwa6UNBFtp/zuv7KUOQ9up/ztv7GWOw9vp/3swLOWOQ1wp/zuv7SWOw9wpv7tvraYPA5xpv3twLWYOxBwp/3uvriaPA5xp/zuwLeaPA5xqP3uvriaPg9xqP3uvrmaPw5xqP7vvbmaPg5xqPzvvbmaQA5xqP7vvbmaQA5yqP3vvbibQQ1yqP3wvbibQA5yqP/wvbibQA5zqP3wvridQA5zqP7xvbidQQ5zqP7xvbmeQQ5zqP7wvLqeQg5zqf7wvbqfQw1zqf7wvbqfQg5zqf/xvbqfQw50qf7xvbufQw50qf/xvbugRQ50qf/xvruiRQ5zqf/xvbyhRg50qv/xvLuhRg90qv/xvbuhRQ50qv/yvbyhRg50q//yvLyjRw50q//yvLyjRw50q//zvLykSA50q//yvbylSA50rP/zvbylSQ50rP/zvbylSQ50rP/zvbyuTw50q//0vLyuTw10q//0u7uvUA50q//0u7uwUQ50q//0u7uwUA50q//0u7uwUQ10q//0u7uwUA10rP/0u7uxUQ50rP/0u7uxUQ10q//0u7uxUg10rP/0u7uxUg10rP/0u7uyUg50rP/0uruzUw10rP/0uruzUw50rP/0uruzUw50rP/0uruzVA50rP/0ubu0VQ10rP/0ubu0VQ50rP/0ubu1VQ50rP/0ubu1Vg10rP/0ubu1Vg50rP/0ubu1Vg50rP/0ubu2Vw10rP/0ubu2Vw50rP/0ubu2Vw50rP/0ubu2WA50rP/0ubu2WA50rP/0uLu3WQ50rP/0uLu3WQ10rP/0uLu3WQ50rP/0uLu3Wg50rP/0uLu3Wg10rP/0uLu3Wg50q//0uLu4Ww10rP/0t7u4Ww50q//0t7u4Ww10rP/0t7u4XA10q//0t7u4XA50q//0t7u5XA10rP/0t7u5XA50q//0t7u5XQ10rP/0t7u5XQ50q//0t7u5XQ10rP/0t7u5Xg50q//0t7q5Xg10rP/0t7q5Xg50q//0t7q5Xw10rP/0t7q6Xw50q//0t7q6Xw10rP/0trq6YA50q//0trq6YA10rP/0trq6YA50q//0trq6YA10rP/0trq6YQ50q//0trq6YQ10rP/0trq7YQ50q//0trq7YQ10rP/0trq7Yg50q//0trq7Yg10rP/0trq7Yg50q//0trq7Yg50rP/0trq7Yg50q//0trq7Yg50rP/0trq7Yw50q//0trq7Yw50rP/0trq7Yw50q//0trq7Yw50rP/0trq8ZA50q//0trq8ZA50rP/0trq8ZA50q//0trm8ZA50rP/0trm8ZQ50q//0trm8ZQ50rP/0trm8ZQ50q//0trm8ZQ='
this.audio.volume = 0.5
// Mention notification sound (slightly different tone)
this.mentionAudio = new Audio()
this.mentionAudio.src = 'data:audio/wav;base64,UklGRnoGAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQoGAACBhYqFbF1fdJivrJBhNjVgodDbq2EcBj+a2teleXV8gIF/gH2FjJF6AkB6mq+iblYoL0F+oMTTpGYjEFKV0dyjhng9MUOF3P/Xl1xBK0J/u97GbT4jQ3u90MGBSCoWRLXg7MR1PSUkUans5JlaMBdClenv2YhaGABmqO/z0JR0MBpDjuvs1pNwIg9GoeXwz59vIxVWq+/s0ZdsIyBbm+rqyY5dFAtYpO3ozJJdDwlko+vix5JbDghmpeHiyI9VDhhuoeLYxI5aEBJ0ouDhxpJmJA5rmuLhvYtdHBZ0ouLTvYhnIhd1mNvNsIVeGRt6mN3VsYBiIRd/mNzUsYFlIBh+mt/XsIBnHxaLo+XWw41pJR92oufYwJNrKBZ3ouvYyJl0Khh1oeXYypVzKBd5purrz5l8Lx93oefmzJt8LBl8penmz5x+MRd1oejpzZ2CMhV3pO3nzp2INBVypPHoyqCILRFwo/Trx6OLKw5vpfbszKiMLA5xpvfqy6iOMQ1ro/nuzquQMQ5spfvvz6yTMw5spv7wyqySMQ5sqP/xxKyQMhRspP3vwq2SMxBupP3vwa6UNBFtp/zuv7KUOQ9up/ztv7GWOw9vp/3swLOWOQ1wp/zuv7SWOw9wpv7tvraYPA5xpv3twLWYOxBwp/3uvriaPA5xp/zuwLeaPA5xqP3uvriaPg9xqP3uvrmaPw5xqP7vvbmaPg5xqPzvvbmaQA5xqP7vvbmaQA5yqP3vvbibQQ1yqP3wvbibQA5yqP/wvbibQA5zqP3wvridQA5zqP7xvbidQQ5zqP7xvbmeQQ5zqP7wvLqeQg5zqf7wvbqfQw1zqf7wvbqfQg5zqf/xvbqfQw50qf7xvbufQw50qf/xvbugRQ50qf/xvruiRQ5zqf/xvbyhRg50qv/xvLuhRg90qv/xvbuhRQ50qv/yvbyhRg50q//yvLyjRw50q//yvLyjRw50q//zvLykSA50q//yvbylSA50rP/zvbylSQ50rP/zvbylSQ50rP/zvbyuTw50q//0vLyuTw10q//0u7uvUA50q//0u7uwUQ50q//0u7uwUA50q//0u7uwUQ10q//0u7uwUA10rP/0u7uxUQ50rP/0u7uxUQ10q//0u7uxUg10rP/0u7uxUg10rP/0u7uyUg50rP/0uruzUw10rP/0uruzUw50rP/0uruzUw50rP/0uruzVA50rP/0ubu0VQ10rP/0ubu0VQ50rP/0ubu1VQ50rP/0ubu1Vg10rP/0ubu1Vg50rP/0ubu1Vg50rP/0ubu2Vw10rP/0ubu2Vw50rP/0ubu2Vw50rP/0ubu2WA50rP/0ubu2WA50rP/0uLu3WQ50rP/0uLu3WQ10rP/0uLu3WQ50rP/0uLu3Wg50rP/0uLu3Wg10rP/0uLu3Wg50q//0uLu4Ww10rP/0t7u4Ww50q//0t7u4Ww10rP/0t7u4XA10q//0t7u4XA50q//0t7u5XA10rP/0t7u5XA50q//0t7u5XQ10rP/0t7u5XQ50q//0t7u5XQ10rP/0t7u5Xg50q//0t7q5Xg10rP/0t7q5Xg50q//0t7q5Xw10rP/0t7q6Xw50q//0t7q6Xw10rP/0trq6YA50q//0trq6YA10rP/0trq6YA50q//0trq6YA10rP/0trq6YQ50q//0trq6YQ10rP/0trq7YQ50q//0trq7YQ10rP/0trq7Yg50q//0trq7Yg10rP/0trq7Yg50q//0trq7Yg50rP/0trq7Yg50q//0trq7Yg50rP/0trq7Yw50q//0trq7Yw50rP/0trq7Yw50q//0trq7Yw50rP/0trq8ZA50q//0trq8ZA50rP/0trq8ZA50q//0trm8ZA50rP/0trm8ZQ50q//0trm8ZQ50rP/0trm8ZQ50q//0trm8ZQ='
this.mentionAudio.volume = 0.7
}
}
/**
* Get current notification permission status
*/
getPermission(): NotificationPermission {
if (typeof Notification === 'undefined') {
return 'denied'
}
return Notification.permission as NotificationPermission
}
/**
* Request notification permission from the user
*/
async requestPermission(): Promise<NotificationPermission> {
if (typeof Notification === 'undefined') {
return 'denied'
}
try {
const permission = await Notification.requestPermission()
return permission as NotificationPermission
} catch {
return 'denied'
}
}
/**
* Get notification settings from localStorage
*/
getSettings(): NotificationSettings {
if (typeof localStorage === 'undefined') {
return DEFAULT_SETTINGS
}
try {
const stored = localStorage.getItem(SETTINGS_KEY)
if (stored) {
return { ...DEFAULT_SETTINGS, ...JSON.parse(stored) }
}
} catch {
// Ignore parse errors
}
return DEFAULT_SETTINGS
}
/**
* Save notification settings to localStorage
*/
saveSettings(settings: Partial<NotificationSettings>): void {
if (typeof localStorage === 'undefined') return
const current = this.getSettings()
const updated = { ...current, ...settings }
localStorage.setItem(SETTINGS_KEY, JSON.stringify(updated))
}
/**
* Show a browser notification
*/
async showNotification(
title: string,
options: NotificationOptions & { roomId?: string } = {}
): Promise<void> {
const settings = this.getSettings()
if (!settings.pushEnabled) return
if (this.getPermission() !== 'granted') return
// Don't show notification if tab is focused
if (document.hasFocus()) return
try {
const notification = new Notification(title, {
icon: '/favicon.ico',
badge: '/favicon.ico',
tag: options.tag || 'message',
...options,
} as NotificationOptions)
// Handle click - focus the window and navigate to room
notification.onclick = () => {
window.focus()
if (options.roomId) {
window.location.href = `/rooms/${options.roomId}`
}
notification.close()
}
// Auto-close after 5 seconds
setTimeout(() => notification.close(), 5000)
} catch {
// Notification failed, ignore silently
}
}
/**
* Play notification sound
*/
playSound(isMention = false): void {
const settings = this.getSettings()
if (isMention && settings.mentionSoundEnabled && this.mentionAudio) {
this.mentionAudio.currentTime = 0
this.mentionAudio.play().catch(() => {
// Audio play failed (e.g., user hasn't interacted with page)
})
} else if (settings.soundEnabled && this.audio) {
this.audio.currentTime = 0
this.audio.play().catch(() => {
// Audio play failed
})
}
}
/**
* Trigger device vibration
*/
vibrate(pattern: number | number[] = 200): void {
const settings = this.getSettings()
if (!settings.vibrationEnabled) return
if (typeof navigator === 'undefined' || !navigator.vibrate) return
try {
navigator.vibrate(pattern)
} catch {
// Vibration not supported
}
}
/**
* Handle new message notification
*/
notifyNewMessage(
senderName: string,
content: string,
roomId: string,
roomTitle: string,
isMention = false
): void {
const settings = this.getSettings()
// Play sound
if (isMention) {
this.playSound(true)
this.vibrate([100, 50, 100]) // Double vibration for mentions
} else {
this.playSound(false)
this.vibrate(200)
}
// Show browser notification
if (settings.pushEnabled) {
const title = isMention
? `${senderName} mentioned you in ${roomTitle}`
: `New message in ${roomTitle}`
const truncatedContent = content.length > 100
? content.slice(0, 100) + '...'
: content
this.showNotification(title, {
body: truncatedContent,
tag: `room-${roomId}`,
roomId,
})
}
}
}
// Singleton instance
export const notificationService = new NotificationService()

View File

@@ -8,6 +8,7 @@ import type {
ReportGenerateResponse, ReportGenerateResponse,
Report, Report,
ReportListResponse, ReportListResponse,
ReportMarkdownResponse,
} from '../types' } from '../types'
export const reportsService = { export const reportsService = {
@@ -45,6 +46,19 @@ export const reportsService = {
return response.data return response.data
}, },
/**
* Get report content as Markdown for preview
*/
async getReportMarkdown(
roomId: string,
reportId: string
): Promise<ReportMarkdownResponse> {
const response = await api.get<ReportMarkdownResponse>(
`/rooms/${roomId}/reports/${reportId}/markdown`
)
return response.data
},
/** /**
* Download report as .docx file * Download report as .docx file
*/ */

View File

@@ -5,7 +5,6 @@ import type {
CreateRoomRequest, CreateRoomRequest,
UpdateRoomRequest, UpdateRoomRequest,
RoomMember, RoomMember,
RoomTemplate,
PermissionResponse, PermissionResponse,
MemberRole, MemberRole,
RoomStatus, RoomStatus,
@@ -143,10 +142,18 @@ export const roomsService = {
}, },
/** /**
* Get room templates * Add a LOT batch number to room
*/ */
async getTemplates(): Promise<RoomTemplate[]> { async addLot(roomId: string, lot: string): Promise<string[]> {
const response = await api.get<RoomTemplate[]>('/rooms/templates') const response = await api.post<string[]>(`/rooms/${roomId}/lots`, { lot })
return response.data
},
/**
* Remove a LOT batch number from room
*/
async removeLot(roomId: string, lot: string): Promise<string[]> {
const response = await api.delete<string[]>(`/rooms/${roomId}/lots/${encodeURIComponent(lot)}`)
return response.data return response.data
}, },
} }

View File

@@ -0,0 +1,58 @@
/**
* Store for tracking unread @mentions across rooms
* Uses localStorage for persistence
*/
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
interface MentionState {
// Map of roomId -> unread mention count
unreadMentions: Record<string, number>
// Add a mention for a room
addMention: (roomId: string) => void
// Clear mentions for a room (when user views the room)
clearMentions: (roomId: string) => void
// Get mention count for a room
getMentionCount: (roomId: string) => number
// Get total unread mentions across all rooms
getTotalMentions: () => number
}
export const useMentionStore = create<MentionState>()(
persist(
(set, get) => ({
unreadMentions: {},
addMention: (roomId: string) => {
set((state) => ({
unreadMentions: {
...state.unreadMentions,
[roomId]: (state.unreadMentions[roomId] || 0) + 1,
},
}))
},
clearMentions: (roomId: string) => {
set((state) => {
const { [roomId]: _, ...rest } = state.unreadMentions
return { unreadMentions: rest }
})
},
getMentionCount: (roomId: string) => {
return get().unreadMentions[roomId] || 0
},
getTotalMentions: () => {
return Object.values(get().unreadMentions).reduce((sum, count) => sum + count, 0)
},
}),
{
name: 'task_reporter_mentions',
}
)
)

View File

@@ -37,6 +37,7 @@ export interface Room {
location?: string | null location?: string | null
description?: string | null description?: string | null
resolution_notes?: string | null resolution_notes?: string | null
lots?: string[]
created_by: string created_by: string
created_at: string created_at: string
resolved_at?: string | null resolved_at?: string | null
@@ -58,7 +59,7 @@ export interface CreateRoomRequest {
severity: SeverityLevel severity: SeverityLevel
location?: string location?: string
description?: string description?: string
template?: string lots?: string[]
} }
export interface UpdateRoomRequest { export interface UpdateRoomRequest {
@@ -68,6 +69,7 @@ export interface UpdateRoomRequest {
location?: string location?: string
description?: string description?: string
resolution_notes?: string resolution_notes?: string
lots?: string[]
} }
export interface RoomListResponse { export interface RoomListResponse {
@@ -77,16 +79,6 @@ export interface RoomListResponse {
offset: number offset: number
} }
export interface RoomTemplate {
template_id: number
name: string
description?: string
incident_type: IncidentType
default_severity: SeverityLevel
default_members?: Record<string, unknown>[]
metadata_fields?: Record<string, unknown>
}
export interface PermissionResponse { export interface PermissionResponse {
role: MemberRole | null role: MemberRole | null
is_admin: boolean is_admin: boolean
@@ -105,6 +97,7 @@ export interface Message {
message_id: string message_id: string
room_id: string room_id: string
sender_id: string sender_id: string
sender_display_name?: string | null // Display name from users table
content: string content: string
message_type: MessageType message_type: MessageType
metadata?: Record<string, unknown> metadata?: Record<string, unknown>
@@ -201,6 +194,7 @@ export interface MessageBroadcast {
message_id: string message_id: string
room_id: string room_id: string
sender_id: string sender_id: string
sender_display_name?: string | null // Display name from users table
content: string content: string
message_type: MessageType message_type: MessageType
metadata?: Record<string, unknown> metadata?: Record<string, unknown>
@@ -291,6 +285,12 @@ export interface ReportListResponse {
total: number total: number
} }
export interface ReportMarkdownResponse {
report_id: string
report_title?: string | null
markdown: string
}
export interface ReportProgressBroadcast { export interface ReportProgressBroadcast {
type: 'report_progress' type: 'report_progress'
report_id: string report_id: string

View File

@@ -0,0 +1,114 @@
/**
* Datetime utilities for GMT+8 (Taiwan time) formatting
*
* All timestamps in the backend are stored in UTC.
* This module provides functions to display them in GMT+8 timezone.
*/
const TIMEZONE = 'Asia/Taipei'
/**
* Format datetime to GMT+8 with full date and time
* @param date - Date object or ISO string
* @returns Formatted string like "2025/12/07 14:30"
*/
export function formatDateTimeGMT8(date: Date | string): string {
const d = typeof date === 'string' ? new Date(date) : date
return d.toLocaleString('zh-TW', {
timeZone: TIMEZONE,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
})
}
/**
* Format time only to GMT+8
* @param date - Date object or ISO string
* @returns Formatted string like "14:30"
*/
export function formatTimeGMT8(date: Date | string): string {
const d = typeof date === 'string' ? new Date(date) : date
return d.toLocaleString('zh-TW', {
timeZone: TIMEZONE,
hour: '2-digit',
minute: '2-digit',
hour12: false,
})
}
/**
* Format date only to GMT+8
* @param date - Date object or ISO string
* @returns Formatted string like "12/07"
*/
export function formatDateGMT8(date: Date | string): string {
const d = typeof date === 'string' ? new Date(date) : date
return d.toLocaleString('zh-TW', {
timeZone: TIMEZONE,
month: '2-digit',
day: '2-digit',
})
}
/**
* Check if the given date is today (in GMT+8 timezone)
* @param date - Date object or ISO string
* @returns true if the date is today in Taiwan timezone
*/
export function isTodayGMT8(date: Date | string): boolean {
const d = typeof date === 'string' ? new Date(date) : date
const now = new Date()
// Get date strings in Taiwan timezone for comparison
const dateStr = d.toLocaleDateString('en-CA', { timeZone: TIMEZONE })
const todayStr = now.toLocaleDateString('en-CA', { timeZone: TIMEZONE })
return dateStr === todayStr
}
/**
* Format message timestamp - shows time only for today, date+time for older messages
* @param date - Date object or ISO string
* @returns Formatted string
*/
export function formatMessageTime(date: Date | string): string {
if (isTodayGMT8(date)) {
return formatTimeGMT8(date)
}
return `${formatDateGMT8(date)} ${formatTimeGMT8(date)}`
}
/**
* Format relative time in Chinese
* @param date - Date object or ISO string
* @returns Relative time string like "3分鐘前", "2小時前", "昨天 14:30"
*/
export function formatRelativeTimeGMT8(date: Date | string): string {
const d = typeof date === 'string' ? new Date(date) : date
const now = new Date()
const diffMs = now.getTime() - d.getTime()
const diffMinutes = Math.floor(diffMs / 60000)
const diffHours = Math.floor(diffMs / 3600000)
const diffDays = Math.floor(diffMs / 86400000)
if (diffMinutes < 1) {
return '剛剛'
}
if (diffMinutes < 60) {
return `${diffMinutes}分鐘前`
}
if (diffHours < 24) {
return `${diffHours}小時前`
}
if (diffDays === 1) {
return `昨天 ${formatTimeGMT8(date)}`
}
if (diffDays < 7) {
return `${diffDays}天前`
}
return formatDateTimeGMT8(date)
}

View File

@@ -0,0 +1,78 @@
# Change: Add LOT Field and Remove Room Templates
## Why
1. **模板功能未被使用**:前端 UI 未實作模板選擇功能,後端雖有完整實作但實際上無人使用。預設模板中的 email 地址(如 `maintenance_team@panjit.com.tw`)也不存在於實際 AD 系統中。
2. **需要追蹤 LOT 批號**:生產線異常事件需要記錄相關的 LOT批號資訊以便追蹤受影響的產品批次。一個事件可能涉及多個 LOT且 LOT 資訊需要在事件處理過程中可以新增或修改。
## What Changes
### 1. 移除 Room Templates 功能
**後端移除:**
- 刪除 `RoomTemplate` 模型 (`app/modules/chat_room/models.py`)
- 刪除 `template_service.py` (`app/modules/chat_room/services/template_service.py`)
- 移除 `app/main.py` 中的模板初始化代碼
- 移除 `app/modules/chat_room/router.py` 中的模板相關端點和邏輯
- 刪除 `tr_room_templates` 資料表Alembic migration
**前端移除:**
- 移除 `useRoomTemplates` hook
- 移除 `RoomTemplate` type 定義
- 移除 `roomsService.getTemplates()` 函數
### 2. 新增 LOT 欄位
**後端新增:**
-`IncidentRoom` 模型新增 `lots` 欄位JSON 陣列格式)
- 更新 `CreateRoomRequest``UpdateRoomRequest` schema 支援 lots
- 新增 `PUT /api/rooms/{room_id}/lots` 端點用於更新 LOT 清單
- Alembic migration 新增欄位
**前端新增:**
- 建立房間 modal 新增 LOT 輸入區(動態新增/刪除行)
- 房間詳情頁顯示和編輯 LOT 清單
- 更新相關 types 和 API 呼叫
### 3. UI 設計
LOT 輸入區:
```
LOT 批號(可選)
┌──────────────────────┐
│ LOT-2024-001 │ [x]
└──────────────────────┘
┌──────────────────────┐
│ LOT-2024-002 │ [x]
└──────────────────────┘
[+ 新增 LOT]
```
- 每行一個 LOT 輸入框
- 右側 [x] 按鈕可刪除該行
- 底部 [+ 新增 LOT] 按鈕新增空白行
- LOT 欄位為選填
## Impact
- **Affected specs**: `chat-room` (移除 Room Templates 需求,新增 LOT 需求)
- **Affected code**:
- `app/modules/chat_room/models.py` - 移除 RoomTemplate新增 lots 欄位
- `app/modules/chat_room/services/template_service.py` - 刪除
- `app/modules/chat_room/services/__init__.py` - 移除 template_service 匯出
- `app/modules/chat_room/router.py` - 移除模板端點,新增 LOT 端點
- `app/modules/chat_room/schemas.py` - 新增 lots 欄位
- `app/main.py` - 移除模板初始化
- `frontend/src/types/index.ts` - 移除 RoomTemplate新增 lots
- `frontend/src/hooks/useRooms.ts` - 移除 useRoomTemplates
- `frontend/src/services/rooms.ts` - 移除 getTemplates新增 updateLots
- `frontend/src/pages/RoomList.tsx` - 移除模板引用,新增 LOT 輸入
- `frontend/src/pages/RoomDetail.tsx` - 新增 LOT 顯示和編輯
- **Breaking changes**:
- 移除 `GET /api/rooms/templates` 端點
- 移除 `POST /api/rooms``template` 參數支援
- 刪除 `tr_room_templates` 資料表
- **Database migration**:
- 刪除 `tr_room_templates` 資料表
- 新增 `lots` 欄位到 `tr_incident_rooms` 資料表

View File

@@ -0,0 +1,118 @@
# chat-room Spec Delta
## REMOVED Requirements
### Requirement: Room Templates
~~The system SHALL support predefined room templates for common incident types to streamline room creation with preset metadata and initial members.~~
**Reason for removal**: 功能未被使用,前端 UI 未實作模板選擇功能,預設模板中的 email 地址不存在於實際 AD 系統。
#### Scenario: Create room from template (REMOVED)
- This scenario is removed along with the requirement
---
## ADDED Requirements
### Requirement: LOT Batch Number Tracking
The system SHALL support tracking multiple LOT (batch numbers) associated with each incident room. LOT information helps identify affected product batches for quality traceability and recall purposes.
#### Scenario: Create room with LOT numbers
- **WHEN** an authenticated user sends `POST /api/rooms` with body:
```json
{
"title": "Quality Issue on Line 3",
"incident_type": "quality_issue",
"severity": "high",
"location": "Building A",
"lots": ["LOT-2024-001", "LOT-2024-002"]
}
```
- **THEN** the system SHALL create the room with the provided LOT numbers
- **AND** store lots as a JSON array in the database
- **AND** return the room details including the lots array
#### Scenario: Create room without LOT numbers
- **WHEN** an authenticated user creates a room without specifying lots
- **THEN** the system SHALL create the room with an empty lots array
- **AND** allow lots to be added later via update
#### Scenario: Update room LOT numbers
- **WHEN** an authenticated user with editor or owner role sends `PATCH /api/rooms/{room_id}` with:
```json
{
"lots": ["LOT-2024-001", "LOT-2024-002", "LOT-2024-003"]
}
```
- **THEN** the system SHALL replace the existing lots with the new array
- **AND** update last_updated_at timestamp
- **AND** return the updated room details
#### Scenario: Add single LOT to existing room
- **WHEN** an authenticated user with editor or owner role sends `POST /api/rooms/{room_id}/lots` with:
```json
{
"lot": "LOT-2024-004"
}
```
- **THEN** the system SHALL append the LOT to the existing lots array
- **AND** prevent duplicate LOT entries
- **AND** return the updated lots array
#### Scenario: Remove single LOT from room
- **WHEN** an authenticated user with editor or owner role sends `DELETE /api/rooms/{room_id}/lots/{lot_id}`
- **THEN** the system SHALL remove the specified LOT from the array
- **AND** return the updated lots array
#### Scenario: Viewer cannot modify LOT numbers
- **WHEN** a user with viewer role attempts to update LOT numbers
- **THEN** the system SHALL return status 403 with "Insufficient permissions"
#### Scenario: LOT numbers displayed in room details
- **WHEN** a user sends `GET /api/rooms/{room_id}`
- **THEN** the response SHALL include the lots array
- **AND** lots SHALL be returned in the order they were added
---
## MODIFIED Requirements
### Requirement: Create Incident Room
The system SHALL allow authenticated users to create a new incident room with metadata including title, incident type, severity level, location, description, and optional LOT batch numbers. Each room SHALL be assigned a unique identifier and timestamp upon creation.
#### Scenario: Create room with LOT numbers
- **WHEN** an authenticated user sends `POST /api/rooms` with body:
```json
{
"title": "Line 3 Conveyor Belt Stopped",
"incident_type": "equipment_failure",
"severity": "high",
"location": "Building A, Line 3",
"description": "Conveyor belt motor overheating, production halted",
"lots": ["LOT-2024-001"]
}
```
- **THEN** the system SHALL create a new incident_rooms record with:
- Unique room_id (UUID)
- Provided metadata fields including lots
- Status set to "active"
- created_by set to current user's ID
- created_at timestamp
- **AND** automatically add the creator as a room member with "owner" role
- **AND** return status 201 with the room details including room_id and lots
### Requirement: Update Room Status and Metadata
The system SHALL allow room owners and editors to update room metadata including LOT batch numbers, and allow owners to transition room status through its lifecycle (active -> resolved -> archived).
#### Scenario: Update room metadata with LOT
- **WHEN** a room owner sends `PATCH /api/rooms/{room_id}` with:
```json
{
"severity": "critical",
"description": "Updated: Fire hazard detected",
"lots": ["LOT-2024-001", "LOT-2024-002"]
}
```
- **THEN** the system SHALL update only the provided fields including lots
- **AND** record the update in room_activity log
- **AND** set last_updated_at timestamp

View File

@@ -0,0 +1,77 @@
# Tasks: Add LOT Field and Remove Room Templates
## Phase 1: Remove Room Templates (Backend) ✅
- [x] **T-1.1**: 刪除 `app/modules/chat_room/services/template_service.py`
- [x] **T-1.2**: 更新 `app/modules/chat_room/services/__init__.py` 移除 template_service 匯出
- [x] **T-1.3**: 更新 `app/main.py` 移除 `template_service.initialize_default_templates()` 呼叫
- [x] **T-1.4**: 更新 `app/modules/chat_room/router.py` 移除:
- `GET /rooms/templates` 端點
- 建立房間時的 template 參數處理
- [x] **T-1.5**: 刪除 `RoomTemplate` 模型 (`app/modules/chat_room/models.py`)
- [x] **T-1.6**: 建立 Alembic migration 刪除 `tr_room_templates` 資料表
## Phase 2: Add LOT Field (Backend) ✅
- [x] **T-2.1**: 在 `IncidentRoom` 模型新增 `lots` 欄位 (JSON/Text 類型)
- [x] **T-2.2**: 建立 Alembic migration 新增 `lots` 欄位到 `tr_incident_rooms`
- [x] **T-2.3**: 更新 `app/modules/chat_room/schemas.py`
- `CreateRoomRequest` 新增 `lots: Optional[List[str]]`
- `UpdateRoomRequest` 新增 `lots: Optional[List[str]]`
- `RoomResponse` 新增 `lots: List[str]`
- 新增 `AddLotRequest` schema
- [x] **T-2.4**: 更新 `room_service.py`
- `create_room()` 支援 lots 參數
- `update_room()` 支援 lots 更新
- [x] **T-2.5**: 更新 `app/modules/chat_room/router.py` 新增:
- `POST /api/rooms/{room_id}/lots` - 新增單一 LOT
- `DELETE /api/rooms/{room_id}/lots/{lot}` - 刪除單一 LOT
- [x] **T-2.6**: 執行 migration 並測試 API
## Phase 3: Remove Room Templates (Frontend) ✅
- [x] **T-3.1**: 更新 `frontend/src/types/index.ts` 移除 `RoomTemplate` interface
- [x] **T-3.2**: 更新 `frontend/src/services/rooms.ts` 移除 `getTemplates()` 方法
- [x] **T-3.3**: 更新 `frontend/src/hooks/useRooms.ts` 移除:
- `roomKeys.templates()`
- `useRoomTemplates()` hook
- [x] **T-3.4**: 更新 `frontend/src/hooks/index.ts` 移除 `useRoomTemplates` 匯出
- [x] **T-3.5**: 更新 `frontend/src/pages/RoomList.tsx` 移除 `useRoomTemplates()` 呼叫
## Phase 4: Add LOT Field (Frontend) ✅
- [x] **T-4.1**: 更新 `frontend/src/types/index.ts`
- `Room` interface 新增 `lots: string[]`
- `CreateRoomRequest` 新增 `lots?: string[]`
- [x] **T-4.2**: 更新 `frontend/src/services/rooms.ts` 新增:
- `addLot(roomId: string, lot: string)` 方法
- `removeLot(roomId: string, lot: string)` 方法
- [x] **T-4.3**: 更新 `CreateRoomModal` 元件 (`RoomList.tsx`)
- 新增 LOT 輸入區塊(動態新增/刪除行)
- 整合到表單提交邏輯
- [x] **T-4.4**: 更新 `RoomDetail.tsx`
- 顯示 LOT 清單
- 新增 LOT 編輯功能(新增/刪除按鈕)
- [x] **T-4.5**: 新增 `useAddLot``useRemoveLot` hooks
## Phase 5: Testing & Cleanup ✅
- [x] **T-5.1**: 更新 `hooks/useRooms.test.ts` 移除模板相關 mock新增 LOT mock
- [x] **T-5.2**: Frontend build passes (npm run build)
- [x] **T-5.3**: Frontend tests pass (63 tests)
- [x] **T-5.4**: 清理未使用的 import 和程式碼
## Dependencies
- T-1.* 可以並行處理
- T-2.1, T-2.2 必須在 T-2.3-T-2.6 之前完成
- T-3.* 可以在 T-1.* 完成後並行處理
- T-4.* 需要 T-2.* 和 T-3.* 完成後才能開始
- T-5.* 在所有實作完成後進行
## Summary
All phases completed successfully:
- Backend templates removed and LOT field added
- Frontend templates removed and LOT UI implemented
- Build and tests pass

View File

@@ -0,0 +1,130 @@
# Design: Optimize WebSocket Database Sessions
## Context
Task Reporter 使用 WebSocket 進行即時通訊,目前實作在 WebSocket 連線期間持有單一資料庫 Session。這在少量用戶時可行但隨著用戶增加會造成連線池耗盡。
**現況分析:**
- 連線池: 5 + 10 = 15 個連線
- WebSocket 持有 Session 直到斷線
- 50 用戶同時在線 = 需要 50 個 Session
- 結果: 連線池耗盡,後續請求阻塞
## Goals / Non-Goals
**Goals:**
- 支援 100+ 並發 WebSocket 連線
- 資料庫操作即時寫入(不使用佇列)
- 修復 sequence_number 競爭條件
- 可配置的連線池參數
**Non-Goals:**
- 不改用 async SQLAlchemy保持簡單
- 不實作訊息佇列(維持即時寫入)
- 不改變 API 介面
## Decisions
### Decision 1: 短期 Session 模式
**選擇:** 每次 DB 操作使用獨立 Session操作完成立即釋放。
**實作:**
```python
# database.py
@contextmanager
def get_db_context():
db = SessionLocal()
try:
yield db
finally:
db.close()
# router.py (改前)
db = next(get_db())
while True:
message = create_message(db, ...) # 共用 Session
# router.py (改後)
while True:
with get_db_context() as db:
message = create_message(db, ...) # 每次獨立
```
**替代方案考慮:**
1. **連線池擴大**: 只增加 pool_size 到 100+
- 優點: 最少改動
- 缺點: 浪費資源MySQL 連線數有限
2. **Async SQLAlchemy**: 使用 aiomysql
- 優點: 真正非阻塞
- 缺點: 需要大幅重構,增加複雜度
3. **訊息佇列**: Redis/內存佇列 + 批次寫入
- 優點: 最高效能
- 缺點: 複雜度高,可能丟失資料
### Decision 2: Sequence Number 鎖定策略
**選擇:** 使用 `SELECT ... FOR UPDATE` 鎖定 + 重試機制
```python
def create_message(db, room_id, ...):
max_retries = 3
for attempt in range(max_retries):
try:
# 使用 FOR UPDATE 鎖定該房間的最大 sequence
max_seq = db.execute(
text("SELECT MAX(sequence_number) FROM tr_messages WHERE room_id = :room_id FOR UPDATE"),
{"room_id": room_id}
).scalar()
next_seq = (max_seq or 0) + 1
# ... create message ...
db.commit()
return message
except IntegrityError:
db.rollback()
if attempt == max_retries - 1:
raise
```
**替代方案:**
1. **AUTO_INCREMENT 子欄位**: 每個房間獨立計數器表
- 需要額外表,增加 JOIN 成本
2. **樂觀鎖**: 使用版本號重試
- 高並發時重試次數可能很高
### Decision 3: 連線池配置
**選擇:** 環境變數可配置
```python
# 生產環境建議值
DB_POOL_SIZE=20 # 常駐連線
DB_MAX_OVERFLOW=30 # 額外連線 (總共最多 50)
DB_POOL_TIMEOUT=10 # 等待連線秒數
DB_POOL_RECYCLE=1800 # 30 分鐘回收
```
## Risks / Trade-offs
| 風險 | 影響 | 緩解措施 |
|------|------|----------|
| 頻繁取得/釋放 Session 增加開銷 | 輕微效能下降 | 連線池的 `pool_pre_ping` 減少無效連線 |
| FOR UPDATE 可能造成鎖等待 | 高並發時延遲 | 設定合理的鎖等待超時 |
| 每次操作獨立事務 | 無法跨操作 rollback | 本系統每個操作獨立,無此需求 |
## Migration Plan
1. **Phase 1**: 部署新連線池配置(無風險)
2. **Phase 2**: 新增 context manager無風險
3. **Phase 3**: 修改 WebSocket router需測試
4. **Phase 4**: 修復 sequence 鎖定(需測試)
**Rollback:** 每個 Phase 獨立,可單獨回滾。
## Open Questions
1. 是否需要連線池監控指標(如 Prometheus metrics
2. 是否需要實作連線健康檢查端點?

View File

@@ -0,0 +1,26 @@
# Change: Optimize WebSocket Database Sessions for Production
**Status**: ✅ COMPLETED - Ready for archive
## Why
目前 WebSocket 連線在整個生命週期中持有單一資料庫 Session造成連線池快速耗盡。當 50+ 用戶同時在線時15 個連線池容量無法支撐導致資料庫操作阻塞或失敗。此外sequence_number 的計算存在競爭條件,可能導致訊息順序錯誤。
## What Changes
- **BREAKING**: 移除 WebSocket 連線的長期 Session 持有模式
- 改用短期 Session 模式:每次 DB 操作獨立取得連線
- 增加連線池容量配置(可透過環境變數調整)
- 修復 sequence_number 競爭條件(使用資料庫層級鎖定)
- 新增環境變數支援動態調整連線池參數
## Impact
- Affected specs: realtime-messaging
- Affected code:
- `app/core/database.py` - 連線池配置
- `app/core/config.py` - 新增環境變數
- `app/modules/realtime/router.py` - WebSocket Session 管理
- `app/modules/realtime/services/message_service.py` - Sequence 鎖定
- `.env.example` - 新增配置說明
- `.env` - 同步更新目前使用的環境變數檔案

View File

@@ -0,0 +1,52 @@
## ADDED Requirements
### Requirement: Short-lived Database Sessions for WebSocket
The system SHALL process WebSocket messages using short-lived database sessions that are acquired and released for each individual operation, rather than holding a session for the entire WebSocket connection lifetime.
#### Scenario: Message creation with short session
- **WHEN** a user sends a message via WebSocket
- **THEN** the system acquires a database session
- **AND** creates the message with proper sequence number
- **AND** commits the transaction
- **AND** releases the session immediately
- **AND** broadcasts the message to room members
#### Scenario: Concurrent message handling
- **WHEN** multiple users send messages simultaneously
- **THEN** each message operation uses an independent database session
- **AND** sequence numbers are correctly assigned without duplicates
- **AND** no connection pool exhaustion occurs
### Requirement: Message Sequence Number Integrity
The system SHALL guarantee unique, monotonically increasing sequence numbers per room using database-level locking to prevent race conditions during concurrent message creation.
#### Scenario: Concurrent sequence assignment
- **WHEN** two users send messages to the same room at the exact same time
- **THEN** each message receives a unique sequence number
- **AND** the sequence numbers are consecutive without gaps or duplicates
#### Scenario: High concurrency sequence safety
- **WHEN** 50+ users send messages to the same room simultaneously
- **THEN** all messages receive correct unique sequence numbers
- **AND** the operation does not cause deadlocks
### Requirement: Configurable Database Connection Pool
The system SHALL support environment variable configuration for database connection pool parameters to optimize for different deployment scales.
#### Scenario: Custom pool size configuration
- **WHEN** the application starts with `DB_POOL_SIZE=20` environment variable
- **THEN** the connection pool maintains 20 persistent connections
#### Scenario: Pool overflow configuration
- **WHEN** the application starts with `DB_MAX_OVERFLOW=30` environment variable
- **THEN** the connection pool can expand up to 30 additional connections beyond the pool size
#### Scenario: Pool timeout configuration
- **WHEN** all connections are in use and a new request arrives
- **AND** `DB_POOL_TIMEOUT=10` is configured
- **THEN** the request waits up to 10 seconds for an available connection
- **AND** raises an error if no connection becomes available
#### Scenario: Default configuration
- **WHEN** no database pool environment variables are set
- **THEN** the system uses production-ready defaults (pool_size=20, max_overflow=30, timeout=10, recycle=1800)

View File

@@ -0,0 +1,51 @@
# Tasks: Optimize WebSocket Database Sessions
## Phase 1: Database Configuration
- [x] **T-1.1**: 在 `app/core/config.py` 新增連線池環境變數
- `DB_POOL_SIZE` (預設: 20)
- `DB_MAX_OVERFLOW` (預設: 30)
- `DB_POOL_TIMEOUT` (預設: 10)
- `DB_POOL_RECYCLE` (預設: 1800)
- [x] **T-1.2**: 更新 `app/core/database.py` 使用新的環境變數配置
- [x] **T-1.3**: 更新 `.env.example` 加入新配置說明
- [x] **T-1.4**: 同步更新 `.env` 加入新的環境變數(使用生產環境建議值)
## Phase 2: Context Manager for Short Sessions
- [x] **T-2.1**: 在 `app/core/database.py` 新增 `get_db_context()` context manager
- [ ] **T-2.2**: 新增 async 版本 `get_async_db_context()` (可選,若未來需要)
## Phase 3: WebSocket Router Refactoring
- [x] **T-3.1**: 修改 `app/modules/realtime/router.py` 移除長期 Session 持有
- [x] **T-3.2**: 每個訊息處理改用 `with get_db_context() as db:` 模式
- [x] **T-3.3**: 確保連線認證和房間成員檢查也使用短期 Session
## Phase 4: Sequence Number Race Condition Fix
- [x] **T-4.1**: 修改 `MessageService.create_message()` 使用 `SELECT ... FOR UPDATE`
- [ ] ~~**T-4.2**: 或改用資料庫 AUTO_INCREMENT + 觸發器方案~~ (不需要,已採用 FOR UPDATE)
- [x] **T-4.3**: 測試並發訊息場景確認無重複 sequence
- 測試腳本: `tests/test_concurrent_messages.py`
- 測試結果: 100 條訊息從 20 個用戶並發發送,全部成功無重複
## Phase 5: Testing & Documentation
- [x] **T-5.1**: 壓力測試 50+ 並發連線
- 測試: 100 threads × 10 queries = 1000 次連線
- 結果: 100% 成功263.7 QPS
- [x] **T-5.2**: 驗證連線池不會耗盡
- Pool size: 20, 0 overflow during test
- [x] **T-5.3**: 驗證 sequence_number 無重複
- 100 條並發訊息100 個唯一 sequence numbers
- [x] **T-5.4**: 更新部署文件
- 更新 `.env.example` 加入連線池配置說明
## Dependencies
- T-1.* 必須先完成
- T-2.* 在 T-1.* 之後
- T-3.* 依賴 T-2.*
- T-4.* 可與 T-3.* 並行
- T-5.* 在所有實作完成後

View File

@@ -0,0 +1,179 @@
# Design: Fix Chat UX Issues
## Technical Design
### 1. 發文者顯示名稱 (Sender Display Name)
#### 後端改動
**Schema 變更 (`app/modules/realtime/schemas.py`):**
```python
class MessageResponse(BaseModel):
message_id: str
room_id: str
sender_id: str
sender_display_name: Optional[str] = None # 新增欄位
content: str
# ... 其他欄位
```
**訊息查詢修改 (`app/modules/realtime/services/message_service.py`):**
```python
from app.modules.auth.models import User
# 在 get_messages() 中 JOIN users 表
messages = (
db.query(Message, User.display_name)
.outerjoin(User, Message.sender_id == User.user_id)
.filter(Message.room_id == room_id)
.order_by(desc(Message.created_at))
.limit(limit)
.all()
)
# 轉換為 response
for msg, display_name in messages:
msg_response = MessageResponse.from_orm(msg)
msg_response.sender_display_name = display_name or msg.sender_id
```
**WebSocket 廣播修改 (`app/modules/realtime/schemas.py`):**
```python
class MessageBroadcast(BaseModel):
type: str = "message"
message_id: str
sender_id: str
sender_display_name: Optional[str] = None # 新增
# ... 其他欄位
```
#### 前端改動
**RoomDetail.tsx:**
```tsx
// 將 message.sender_id 改為
{message.sender_display_name || message.sender_id}
```
### 2. 統一時區為 GMT+8
#### 前端工具函數 (`frontend/src/utils/datetime.ts`)
```typescript
/**
* 格式化日期時間為 GMT+8 (台灣時間)
*/
export function formatDateTimeGMT8(date: Date | string): string {
const d = typeof date === 'string' ? new Date(date) : date
return d.toLocaleString('zh-TW', {
timeZone: 'Asia/Taipei',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
}
/**
* 格式化時間為 GMT+8 (僅時:分)
*/
export function formatTimeGMT8(date: Date | string): string {
const d = typeof date === 'string' ? new Date(date) : date
return d.toLocaleString('zh-TW', {
timeZone: 'Asia/Taipei',
hour: '2-digit',
minute: '2-digit',
})
}
```
#### 使用位置
- `RoomDetail.tsx` - 訊息時間
- `RoomList.tsx` - 房間最後更新時間
- `Reports.tsx` - 報告生成時間
### 3. AI 報告生成問題修復
#### 新增健康檢查端點 (`app/modules/report_generation/router.py`)
```python
@router.get("/health", response_model=schemas.HealthCheckResponse)
async def check_dify_health():
"""檢查 DIFY AI 服務連線狀態"""
if not settings.DIFY_API_KEY:
return {"status": "error", "message": "DIFY_API_KEY 未設定"}
try:
# 測試 API 連線
result = await dify_service.test_connection()
return {"status": "ok", "message": "AI 服務正常"}
except Exception as e:
return {"status": "error", "message": str(e)}
```
#### 啟動時檢查 (`app/main.py`)
```python
@app.on_event("startup")
async def startup_event():
# 檢查 DIFY API Key
if not settings.DIFY_API_KEY:
logger.warning("DIFY_API_KEY not configured - AI report generation will be unavailable")
```
#### 前端改善 (`frontend/src/hooks/useReports.ts`)
```typescript
// 新增輪詢直到報告完成或失敗
const pollReportStatus = async (reportId: string) => {
const maxAttempts = 60 // 最多輪詢 2 分鐘
let attempts = 0
while (attempts < maxAttempts) {
const status = await api.get(`/rooms/${roomId}/reports/${reportId}`)
if (status.data.status === 'completed' || status.data.status === 'failed') {
return status.data
}
await new Promise(resolve => setTimeout(resolve, 2000))
attempts++
}
throw new Error('報告生成超時')
}
```
## Data Flow
### Message Flow with Display Name
```
1. User sends message via WebSocket
2. Backend creates message in DB
3. Backend queries User table for sender's display_name
4. Backend broadcasts MessageBroadcast with sender_display_name
5. Frontend displays sender_display_name in chat bubble
```
### Report Generation Flow (Fixed)
```
1. User clicks "Generate Report"
2. Frontend: POST /api/rooms/{id}/reports/generate
3. Backend: Creates report record (status=pending)
4. Backend: Returns report_id immediately
5. Frontend: Starts polling GET /api/rooms/{id}/reports/{report_id}
6. Backend: Background task updates status (collecting_data → generating_content → assembling_document → completed)
7. Backend: Broadcasts WebSocket updates for each status change
8. Frontend: Updates UI based on poll response OR WebSocket message
9. If completed: Enable download button
10. If failed: Show error message
```
## Database Changes
無資料庫結構變更。僅新增 JOIN 查詢。
## Configuration Changes
無新增設定項目。僅改善現有 DIFY_API_KEY 的檢查機制。

View File

@@ -0,0 +1,37 @@
# Proposal: Fix Chat UX Issues
## Status: DRAFT
## Why
使用者回報三個影響使用體驗的問題:
1. **聊天室無法辨識發文者**:訊息只顯示 email (sender_id),無法快速識別是誰發的,不像 LINE 等通訊軟體會顯示使用者名稱。
2. **時間顯示錯誤**:系統使用 UTC 時間,但使用者期望看到 GMT+8 (台灣時間)。目前前端依賴瀏覽器 locale可能導致不同使用者看到不同時區的時間。
3. **AI 報告生成卡住**:點擊生成報告後一直停在「準備中」狀態,沒有進一步的回應或錯誤訊息。
## What Changes
### 1. 發文者顯示名稱
- 後端 API 在回傳訊息時加入 `sender_display_name` 欄位
-`tr_users` 表格 JOIN 取得 display_name
- 前端顯示 display_name 而非 sender_id
### 2. 統一時區為 GMT+8
- 前端建立時間格式化工具函數,統一轉換為 GMT+8
- 所有時間顯示使用該工具函數
- 後端維持 UTC 儲存(國際標準做法)
### 3. AI 報告生成問題修復
- 新增 DIFY API 連線測試端點
- 啟動時檢查 DIFY_API_KEY 是否設定
- 改善錯誤訊息顯示
- 前端新增輪詢機制確保狀態更新
## Impact
- **低風險**:不影響現有資料結構,僅新增欄位和修改顯示邏輯
- **向後相容**sender_display_name 為選填欄位,舊訊息會 fallback 顯示 sender_id
- **效能影響極小**:只增加一個 LEFT JOIN 查詢

View File

@@ -0,0 +1,59 @@
# ai-report-generation Specification
## ADDED Requirements
### Requirement: DIFY Service Health Check
The system SHALL provide a health check mechanism to verify DIFY AI service connectivity and configuration.
#### Scenario: Check DIFY configuration on startup
- **WHEN** the application starts
- **AND** `DIFY_API_KEY` is not configured
- **THEN** the system SHALL log a warning message: "DIFY_API_KEY not configured - AI report generation will be unavailable"
#### Scenario: DIFY health check endpoint
- **WHEN** a user sends `GET /api/reports/health`
- **AND** `DIFY_API_KEY` is not configured
- **THEN** the system SHALL return:
```json
{
"status": "error",
"message": "DIFY_API_KEY 未設定,請聯繫系統管理員"
}
```
#### Scenario: DIFY service unreachable
- **WHEN** a user sends `GET /api/reports/health`
- **AND** `DIFY_API_KEY` is configured
- **BUT** the DIFY service cannot be reached
- **THEN** the system SHALL return:
```json
{
"status": "error",
"message": "無法連接 AI 服務,請稍後再試"
}
```
### Requirement: Report Generation Status Polling
The frontend SHALL implement polling mechanism to ensure report status updates are received even if WebSocket connection is unstable.
#### Scenario: Poll report status after generation trigger
- **WHEN** a user triggers report generation
- **AND** receives the initial `report_id`
- **THEN** the frontend SHALL poll `GET /api/rooms/{room_id}/reports/{report_id}` every 2 seconds
- **AND** continue polling until status is "completed" or "failed"
- **AND** timeout after 120 seconds with user-friendly error message
#### Scenario: Display generation progress
- **WHEN** polling returns status "collecting_data"
- **THEN** the UI SHALL display "正在收集聊天室資料..."
- **WHEN** polling returns status "generating_content"
- **THEN** the UI SHALL display "AI 正在分析並生成報告內容..."
- **WHEN** polling returns status "assembling_document"
- **THEN** the UI SHALL display "正在組裝報告文件..."
#### Scenario: Display generation error
- **WHEN** polling returns status "failed"
- **THEN** the UI SHALL display the `error_message` from the response
- **AND** provide option to retry generation

View File

@@ -0,0 +1,37 @@
# realtime-messaging Specification
## ADDED Requirements
### Requirement: Message Sender Display Name
The system SHALL include the sender's display name in message responses and broadcasts, enabling the UI to show user-friendly names instead of email addresses.
#### Scenario: Message response includes display name
- **WHEN** a message is retrieved via REST API or WebSocket
- **THEN** the response SHALL include `sender_display_name` field
- **AND** the display name SHALL be obtained by joining with the `tr_users` table
- **AND** if the sender does not exist in `tr_users`, the field SHALL fallback to `sender_id`
#### Scenario: WebSocket broadcast includes display name
- **WHEN** a new message is broadcast via WebSocket
- **THEN** the broadcast SHALL include `sender_display_name` field
- **AND** the value SHALL be the sender's display name from `tr_users` table
#### Scenario: Historical messages include display name
- **WHEN** a client requests message history via `GET /api/rooms/{room_id}/messages`
- **THEN** each message in the response SHALL include `sender_display_name`
- **AND** messages from unknown users SHALL show their `sender_id` as fallback
### Requirement: GMT+8 Timezone Display
The frontend SHALL display all timestamps in GMT+8 (Asia/Taipei) timezone for consistent user experience across all browsers.
#### Scenario: Message timestamp in GMT+8
- **WHEN** a message is displayed in the chat room
- **THEN** the timestamp SHALL be formatted in GMT+8 timezone
- **AND** use format "HH:mm" for today's messages
- **AND** use format "MM/DD HH:mm" for older messages
#### Scenario: Room list timestamps in GMT+8
- **WHEN** the room list is displayed
- **THEN** the "last updated" time SHALL be formatted in GMT+8 timezone

View File

@@ -0,0 +1,78 @@
# Tasks: Fix Chat UX Issues
## Phase 1: 發文者顯示名稱
### T-1.1: 後端 Schema 新增 sender_display_name
- [x]`MessageResponse` schema 新增 `sender_display_name: Optional[str]` 欄位
- [x]`MessageBroadcast` schema 新增 `sender_display_name: Optional[str]` 欄位
### T-1.2: 後端 Service 查詢加入 User JOIN
- [x] 修改 `MessageService.get_messages()` 使用 LEFT JOIN 取得 display_name
- [x] 新增 `MessageService.get_display_name()` helper 方法
- [x] 修改 `MessageService.search_messages()` 使用 LEFT JOIN 取得 display_name
### T-1.3: WebSocket 廣播包含 display_name
- [x] 修改 WebSocket MESSAGE handler 查詢並包含 sender_display_name
- [x] 修改 WebSocket EDIT_MESSAGE handler 包含 sender_display_name
- [x] 修改 REST API create_message 端點包含 sender_display_name
### T-1.4: 前端顯示 display_name
- [x] 更新 `Message` 型別定義包含 `sender_display_name` 欄位
- [x] 更新 `MessageBroadcast` 型別定義包含 `sender_display_name` 欄位
- [x] 修改 `RoomDetail.tsx` 顯示 `sender_display_name || sender_id`
- [x] 修改 `useWebSocket.ts` 傳遞 `sender_display_name`
## Phase 2: 統一時區為 GMT+8
### T-2.1: 建立時間格式化工具
- [x] 建立 `frontend/src/utils/datetime.ts`
- [x] 實作 `formatDateTimeGMT8(date)` 函數
- [x] 實作 `formatTimeGMT8(date)` 函數
- [x] 實作 `formatMessageTime(date)` 函數 (智慧顯示)
- [x] 實作 `formatRelativeTimeGMT8(date)` 函數 (相對時間)
### T-2.2: 套用到所有時間顯示
- [x] `RoomDetail.tsx` - 訊息時間改用 `formatMessageTime`
- [x] `RoomList.tsx` - 最後活動時間改用 `formatRelativeTimeGMT8`
## Phase 3: AI 報告生成問題修復
### T-3.1: 後端 DIFY 健康檢查
- [x]`dify_client.py` 新增 `test_connection()` 方法
- [x] 新增 `HealthCheckResponse` schema
- [x] 新增 `GET /api/reports/health` 端點檢查 DIFY 狀態
- [x] 啟動時檢查 DIFY_API_KEY 並記錄警告
- [x] 在 main.py 註冊 health_router
### T-3.2: 改善錯誤處理與顯示
- [x] 確保 background task 錯誤正確寫入 report.error_message (已存在)
- [x] WebSocket 廣播失敗狀態時包含具體錯誤 (已存在)
### T-3.3: 前端輪詢機制
- [x] 修改 `useReports.ts` 新增 `useReportPolling()` hook
- [x] 新增 `useGenerateReportWithPolling()` hook
- [x] 修改 `RoomDetail.tsx` 實作輪詢邏輯
- [x] 顯示各階段進度訊息 (準備中 → 收集資料 → AI 生成 → 組裝文件)
- [x] 失敗時顯示錯誤訊息給使用者
### T-3.4: 報告下載中文檔名修復
- [x] 修改 `router.py` download_report 使用 RFC 5987 編碼處理中文檔名
- [x] 使用 `urllib.parse.quote()` 對檔名進行 URL 編碼
- [x] 提供 ASCII fallback 檔名相容舊版客戶端
## Phase 4: 測試與驗證
### T-4.1: 測試發文者顯示
- [ ] 測試新訊息 WebSocket 廣播包含 display_name
- [ ] 測試歷史訊息 API 回傳包含 display_name
- [ ] 測試未知使用者 (不在 users 表) fallback 顯示 sender_id
### T-4.2: 測試時區顯示
- [ ] 測試訊息時間顯示為台灣時間
- [ ] 測試跨日訊息時間正確
### T-4.3: 測試 AI 報告
- [ ] 測試 DIFY_API_KEY 未設定時的錯誤訊息
- [ ] 測試報告生成完整流程
- [ ] 測試生成失敗時的錯誤顯示
- [ ] 測試報告下載中文檔名正確顯示

View File

@@ -0,0 +1,33 @@
# Change: Improve Chat UX - Action Bar and Features
## Why
目前聊天室的主要操作按鈕(生成報告、上傳檔案、添加人員)位於頁首,當訊息增多時需要向上捲動才能操作,影響使用效率。此外,報告僅提供 Word 下載,缺少即時預覽功能;聊天介面也缺少複製訊息、@提及、通知等現代化功能。
## What Changes
### 1. Action Bar Redesign (類似 LINE 佈局)
- 將常用操作按鈕從頁首移至輸入框區域附近
- 新增可展開的工具列,包含:上傳檔案、生成報告、添加人員
- 保持頁首僅顯示房間資訊和設定
### 2. Report Preview Enhancement
- 報告生成後以 Markdown 格式在頁面內預覽
- 提供「複製 Markdown」按鈕方便轉貼發布
- 保留原有 Word 下載功能
### 3. Message Actions
- 新增訊息複製按鈕hover 顯示)
- 新增 @mention 功能(輸入 @ 觸發成員選單)
### 4. Notification System
- 瀏覽器 Push Notification需用戶授權
- 新訊息音效提示
- 移動端震動提示(支援 Vibration API
## Impact
- Affected specs: `chat-room`, `realtime-messaging`, `ai-report-generation`
- Affected code:
- `frontend/src/pages/RoomDetail.tsx` - 主要介面重構
- `frontend/src/components/` - 新增 ActionBar, ReportPreview, MentionInput 組件
- `app/modules/realtime/` - @mention 解析和通知
- `app/modules/report_generation/` - Markdown 輸出端點

View File

@@ -0,0 +1,33 @@
## ADDED Requirements
### Requirement: Markdown Report Output
The report generation system SHALL provide reports in Markdown format for in-page preview.
#### Scenario: Get report as Markdown
- **WHEN** user requests `GET /api/rooms/{room_id}/reports/{report_id}/markdown`
- **AND** the report status is `completed`
- **THEN** the system returns the report content in Markdown format
- **AND** the Markdown includes all report sections (summary, timeline, participants, etc.)
#### Scenario: Markdown includes metadata
- **WHEN** generating Markdown output
- **THEN** the output includes a metadata header with room info, LOT numbers, dates
- **AND** the format is suitable for copy-paste to other platforms
### Requirement: In-Page Report Preview
The frontend SHALL display a preview of the generated report within the chat room interface.
#### Scenario: Display report preview
- **WHEN** user clicks on a completed report
- **THEN** a modal or drawer opens showing the Markdown-rendered report
- **AND** the preview includes proper formatting (headers, tables, lists)
#### Scenario: Copy Markdown content
- **WHEN** user clicks "Copy Markdown" in the preview
- **THEN** the raw Markdown text is copied to clipboard
- **AND** a success toast notification is shown
#### Scenario: Download Word from preview
- **WHEN** user clicks "Download Word" in the preview
- **THEN** the .docx file is downloaded
- **AND** the filename uses the report title

View File

@@ -0,0 +1,32 @@
## ADDED Requirements
### Requirement: Action Bar Layout
The chat room interface SHALL provide an action bar near the message input area for quick access to common operations.
#### Scenario: Action bar displays common actions
- **WHEN** user views a chat room
- **THEN** an action bar is displayed above or beside the message input
- **AND** the action bar includes: upload file, generate report, add member buttons
#### Scenario: Action bar toggle on mobile
- **WHEN** user is on a mobile device
- **THEN** the action bar buttons can be collapsed/expanded
- **AND** a single toggle button reveals the full action options
### Requirement: Message Copy Action
Users SHALL be able to copy individual message content to clipboard.
#### Scenario: Copy message content
- **WHEN** user hovers over a message
- **THEN** a copy button appears
- **AND** clicking the button copies the message content to clipboard
- **AND** a success toast notification is shown
### Requirement: Notification Settings
Users SHALL be able to configure notification preferences for the chat room.
#### Scenario: Configure notification settings
- **WHEN** user accesses notification settings
- **THEN** user can enable/disable sound notifications
- **AND** user can enable/disable browser push notifications
- **AND** user can enable/disable vibration (on supported devices)

View File

@@ -0,0 +1,61 @@
## ADDED Requirements
### Requirement: @Mention Support
The messaging system SHALL support @mention functionality to tag specific users in messages.
#### Scenario: Trigger mention autocomplete
- **WHEN** user types `@` in the message input
- **THEN** a dropdown menu appears showing room members
- **AND** the list filters as user continues typing
- **AND** user can select a member using keyboard or mouse
#### Scenario: Insert mention into message
- **WHEN** user selects a member from the mention dropdown
- **THEN** the mention is inserted as `@display_name`
- **AND** the mention is stored with the user_id reference
- **AND** the mention is visually highlighted in the message
#### Scenario: Mention notification
- **WHEN** a message containing @mention is sent
- **THEN** the mentioned user receives a highlighted notification
- **AND** the notification indicates they were mentioned
### Requirement: Browser Push Notifications
The system SHALL support browser push notifications for new messages.
#### Scenario: Request notification permission
- **WHEN** user first visits the chat room
- **THEN** the system prompts for notification permission
- **AND** the permission state is stored locally
#### Scenario: Send push notification
- **WHEN** a new message arrives while the tab is not focused
- **AND** user has granted notification permission
- **THEN** a browser push notification is displayed
- **AND** clicking the notification focuses the chat room
### Requirement: Sound and Vibration Alerts
The system SHALL support audio and haptic feedback for new messages.
#### Scenario: Play notification sound
- **WHEN** a new message arrives
- **AND** sound notifications are enabled
- **THEN** a notification sound is played
#### Scenario: Vibrate on mobile
- **WHEN** a new message arrives on a mobile device
- **AND** vibration is enabled
- **AND** the device supports Vibration API
- **THEN** the device vibrates briefly
### Requirement: Mention Data Storage
Messages with @mentions SHALL store the mention metadata for querying.
#### Scenario: Store mention references
- **WHEN** a message with @mentions is created
- **THEN** the `mentions` field stores an array of mentioned user_ids
- **AND** the message content preserves the @display_name format
#### Scenario: Query messages mentioning user
- **WHEN** fetching messages that mention a specific user
- **THEN** messages with that user_id in `mentions` array are returned

View File

@@ -0,0 +1,80 @@
# Tasks: Improve Chat UX v2
## Phase 1: Action Bar Redesign
### T-1.1: 前端 Action Bar 組件
- [x] 建立 `ActionBar.tsx` 組件(輸入框上方工具列)
- [x] 實作展開/收合按鈕群組
- [x] 整合上傳檔案按鈕
- [x] 整合生成報告按鈕
- [x] 整合添加人員按鈕
### T-1.2: RoomDetail 介面重構
- [x] 移除頁首的操作按鈕
- [x] 整合 ActionBar 到輸入區域
- [x] 調整 header 僅顯示房間資訊
- [x] 響應式佈局調整mobile/desktop
## Phase 2: Report Markdown Preview
### T-2.1: 後端 Markdown 輸出
- [x] 新增 `GET /api/rooms/{room_id}/reports/{report_id}/markdown` 端點
- [x] 實作 `DocxService.to_markdown()` 方法
- [x] 從 AI JSON 內容生成結構化 Markdown
### T-2.2: 前端預覽組件
- [x] 建立 `ReportPreview.tsx` 組件
- [x] 整合 Markdown 渲染react-markdown
- [x] 實作「複製 Markdown」按鈕
- [x] 實作「下載 Word」按鈕
- [x] 新增預覽 Modal/Drawer
## Phase 3: Message Actions
### T-3.1: 訊息複製功能
- [x] 在訊息 hover 時顯示操作按鈕
- [x] 實作複製訊息內容功能
- [x] 複製成功提示 (icon change feedback)
### T-3.2: @Mention 功能 - 前端
- [x] 建立 `MentionInput.tsx` 組件
- [x] 輸入 `@` 時觸發成員選單
- [x] 支援鍵盤選擇上下鍵、Enter
- [x] 顯示成員 display_name
- [x] 訊息中 @mention 高亮顯示
### T-3.3: @Mention 功能 - 後端
- [x] 修改 Message model 新增 `mentions` JSON 欄位
- [x] 訊息建立時解析 @mention (parse_mentions function)
- [x] 建立 Alembic migration
## Phase 4: Notification System
### T-4.1: 瀏覽器通知
- [x] 建立 `NotificationService.ts`
- [x] 請求通知權限 UI
- [x] 新訊息時發送 Push Notification
- [x] 點擊通知跳轉到對應房間
### T-4.2: 音效與震動
- [x] 新增訊息提示音效檔案 (base64 embedded)
- [x] 實作音效播放(可設定開關)
- [x] 實作移動端震動提示Vibration API
- [x] 新增通知設定介面
### T-4.3: @Mention 通知
- [x]@mention 時強調通知 (special sound + notification)
- [x] 在房間列表顯示 mention 徽章 (client-side tracking with Zustand/localStorage)
## Phase 5: Testing & Polish
### T-5.1: 測試
- [x] Action Bar 功能測試 (build verified)
- [x] Report Preview 測試 (build verified)
- [x] @Mention 完整流程測試 (build verified)
- [x] 通知系統測試(權限、音效、震動)(build verified)
### T-5.2: UI 微調
- [x] 動畫過渡效果 (ActionBar expand/collapse, NotificationSettings modal)
- [ ] 深色模式相容性 (deferred - requires theme system)
- [x] 無障礙支援 (ARIA labels, roles, keyboard support)

View File

@@ -262,3 +262,91 @@ The system SHALL track report generation status and notify users of completion v
``` ```
- **AND** the error message SHALL be user-friendly (no technical details) - **AND** the error message SHALL be user-friendly (no technical details)
### Requirement: DIFY Service Health Check
The system SHALL provide a health check mechanism to verify DIFY AI service connectivity and configuration.
#### Scenario: Check DIFY configuration on startup
- **WHEN** the application starts
- **AND** `DIFY_API_KEY` is not configured
- **THEN** the system SHALL log a warning message: "DIFY_API_KEY not configured - AI report generation will be unavailable"
#### Scenario: DIFY health check endpoint
- **WHEN** a user sends `GET /api/reports/health`
- **AND** `DIFY_API_KEY` is not configured
- **THEN** the system SHALL return:
```json
{
"status": "error",
"message": "DIFY_API_KEY 未設定,請聯繫系統管理員"
}
```
#### Scenario: DIFY service unreachable
- **WHEN** a user sends `GET /api/reports/health`
- **AND** `DIFY_API_KEY` is configured
- **BUT** the DIFY service cannot be reached
- **THEN** the system SHALL return:
```json
{
"status": "error",
"message": "無法連接 AI 服務,請稍後再試"
}
```
### Requirement: Report Generation Status Polling
The frontend SHALL implement polling mechanism to ensure report status updates are received even if WebSocket connection is unstable.
#### Scenario: Poll report status after generation trigger
- **WHEN** a user triggers report generation
- **AND** receives the initial `report_id`
- **THEN** the frontend SHALL poll `GET /api/rooms/{room_id}/reports/{report_id}` every 2 seconds
- **AND** continue polling until status is "completed" or "failed"
- **AND** timeout after 120 seconds with user-friendly error message
#### Scenario: Display generation progress
- **WHEN** polling returns status "collecting_data"
- **THEN** the UI SHALL display "正在收集聊天室資料..."
- **WHEN** polling returns status "generating_content"
- **THEN** the UI SHALL display "AI 正在分析並生成報告內容..."
- **WHEN** polling returns status "assembling_document"
- **THEN** the UI SHALL display "正在組裝報告文件..."
#### Scenario: Display generation error
- **WHEN** polling returns status "failed"
- **THEN** the UI SHALL display the `error_message` from the response
- **AND** provide option to retry generation
### Requirement: Markdown Report Output
The report generation system SHALL provide reports in Markdown format for in-page preview.
#### Scenario: Get report as Markdown
- **WHEN** user requests `GET /api/rooms/{room_id}/reports/{report_id}/markdown`
- **AND** the report status is `completed`
- **THEN** the system returns the report content in Markdown format
- **AND** the Markdown includes all report sections (summary, timeline, participants, etc.)
#### Scenario: Markdown includes metadata
- **WHEN** generating Markdown output
- **THEN** the output includes a metadata header with room info, LOT numbers, dates
- **AND** the format is suitable for copy-paste to other platforms
### Requirement: In-Page Report Preview
The frontend SHALL display a preview of the generated report within the chat room interface.
#### Scenario: Display report preview
- **WHEN** user clicks on a completed report
- **THEN** a modal or drawer opens showing the Markdown-rendered report
- **AND** the preview includes proper formatting (headers, tables, lists)
#### Scenario: Copy Markdown content
- **WHEN** user clicks "Copy Markdown" in the preview
- **THEN** the raw Markdown text is copied to clipboard
- **AND** a success toast notification is shown
#### Scenario: Download Word from preview
- **WHEN** user clicks "Download Word" in the preview
- **THEN** the .docx file is downloaded
- **AND** the filename uses the report title

View File

@@ -21,9 +21,9 @@ The system SHALL recognize ymirliu@panjit.com.tw as a system administrator with
- **AND** record the admin override in audit log - **AND** record the admin override in audit log
### Requirement: Create Incident Room ### Requirement: Create Incident Room
The system SHALL allow authenticated users to create a new incident room with metadata including title, incident type, severity level, location, and description. Each room SHALL be assigned a unique identifier and timestamp upon creation. The system SHALL allow authenticated users to create a new incident room with metadata including title, incident type, severity level, location, description, and optional LOT batch numbers. Each room SHALL be assigned a unique identifier and timestamp upon creation.
#### Scenario: Create room for equipment failure incident #### Scenario: Create room with LOT numbers
- **WHEN** an authenticated user sends `POST /api/rooms` with body: - **WHEN** an authenticated user sends `POST /api/rooms` with body:
```json ```json
{ {
@@ -31,25 +31,18 @@ The system SHALL allow authenticated users to create a new incident room with me
"incident_type": "equipment_failure", "incident_type": "equipment_failure",
"severity": "high", "severity": "high",
"location": "Building A, Line 3", "location": "Building A, Line 3",
"description": "Conveyor belt motor overheating, production halted" "description": "Conveyor belt motor overheating, production halted",
"lots": ["LOT-2024-001"]
} }
``` ```
- **THEN** the system SHALL create a new incident_rooms record with: - **THEN** the system SHALL create a new incident_rooms record with:
- Unique room_id (UUID) - Unique room_id (UUID)
- Provided metadata fields - Provided metadata fields including lots
- Status set to "active" - Status set to "active"
- created_by set to current user's ID - created_by set to current user's ID
- created_at timestamp - created_at timestamp
- **AND** automatically add the creator as a room member with "owner" role - **AND** automatically add the creator as a room member with "owner" role
- **AND** return status 201 with the room details including room_id - **AND** return status 201 with the room details including room_id and lots
#### Scenario: Create room with missing required fields
- **WHEN** a user attempts to create a room without required fields (title, incident_type)
- **THEN** the system SHALL return status 400 with validation error details
#### Scenario: Create room without authentication
- **WHEN** an unauthenticated request is sent to `POST /api/rooms`
- **THEN** the system SHALL return status 401 with "Authentication required"
### Requirement: List and Filter Incident Rooms ### Requirement: List and Filter Incident Rooms
The system SHALL provide endpoints to list incident rooms with filtering capabilities by status, incident type, severity, date range, and user membership. The system SHALL automatically exclude rooms with ARCHIVED status from listing results for non-admin users, ensuring archived rooms are only visible to system administrators. The system SHALL provide endpoints to list incident rooms with filtering capabilities by status, incident type, severity, date range, and user membership. The system SHALL automatically exclude rooms with ARCHIVED status from listing results for non-admin users, ensuring archived rooms are only visible to system administrators.
@@ -121,44 +114,21 @@ The system SHALL allow room owners and members with appropriate permissions to a
- **AND** record the admin action in audit log - **AND** record the admin action in audit log
### Requirement: Update Room Status and Metadata ### Requirement: Update Room Status and Metadata
The system SHALL allow room owners to update room metadata and transition room status through its lifecycle (active resolved archived). The system SHALL allow room owners and editors to update room metadata including LOT batch numbers, and allow owners to transition room status through its lifecycle (active -> resolved -> archived).
#### Scenario: Mark room as resolved #### Scenario: Update room metadata with LOT
- **WHEN** a room owner sends `PATCH /api/rooms/{room_id}` with:
```json
{
"status": "resolved",
"resolution_notes": "Replaced motor, production resumed"
}
```
- **THEN** the system SHALL update status to "resolved"
- **AND** set resolved_at timestamp
- **AND** store resolution_notes
- **AND** keep room accessible in read-only mode for members
#### Scenario: Update room metadata
- **WHEN** a room owner sends `PATCH /api/rooms/{room_id}` with: - **WHEN** a room owner sends `PATCH /api/rooms/{room_id}` with:
```json ```json
{ {
"severity": "critical", "severity": "critical",
"description": "Updated: Fire hazard detected" "description": "Updated: Fire hazard detected",
"lots": ["LOT-2024-001", "LOT-2024-002"]
} }
``` ```
- **THEN** the system SHALL update only the provided fields - **THEN** the system SHALL update only the provided fields including lots
- **AND** record the update in room_activity log - **AND** record the update in room_activity log
- **AND** set last_updated_at timestamp - **AND** set last_updated_at timestamp
#### Scenario: Archive resolved room
- **WHEN** a room owner sends `PATCH /api/rooms/{room_id}` with status "archived"
- **AND** the room is already in "resolved" status
- **THEN** the system SHALL update status to "archived"
- **AND** set archived_at timestamp
- **AND** make room read-only for all members
#### Scenario: Invalid status transition
- **WHEN** attempting to change status from "active" directly to "archived"
- **THEN** the system SHALL return status 400 with "Invalid status transition"
### Requirement: Room Access Control ### Requirement: Room Access Control
The system SHALL enforce role-based access control for all room operations based on user membership and assigned roles. System administrators SHALL have full access to all rooms regardless of membership status. The system SHALL enforce role-based access control for all room operations based on user membership and assigned roles. System administrators SHALL have full access to all rooms regardless of membership status.
@@ -188,31 +158,6 @@ The system SHALL enforce role-based access control for all room operations based
- **THEN** the system SHALL return ALL rooms in the system, not just rooms where admin is a member - **THEN** the system SHALL return ALL rooms in the system, not just rooms where admin is a member
- **AND** include an "is_admin_view" flag in the response - **AND** include an "is_admin_view" flag in the response
### Requirement: Room Templates
The system SHALL support predefined room templates for common incident types to streamline room creation with preset metadata and initial members.
#### Scenario: Create room from template
- **WHEN** a user sends `POST /api/rooms` with:
```json
{
"template": "equipment_failure",
"title": "Molding Machine #5 Down",
"location": "Building B"
}
```
- **THEN** the system SHALL create room with:
- Preset incident_type from template
- Default severity level from template
- Auto-assigned team members based on template configuration
- Template-specific metadata fields
#### Scenario: List available templates
- **WHEN** a user sends `GET /api/room-templates`
- **THEN** the system SHALL return available templates with:
- Template name and description
- Default values for each template
- Required additional fields
### Requirement: Admin Permanent Room Deletion ### Requirement: Admin Permanent Room Deletion
The system SHALL provide system administrators with the ability to permanently delete rooms, including all associated data (members, messages, files, reports). This operation is irreversible and restricted to system administrators only. The system SHALL provide system administrators with the ability to permanently delete rooms, including all associated data (members, messages, files, reports). This operation is irreversible and restricted to system administrators only.
@@ -260,3 +205,95 @@ The system SHALL hide rooms with ARCHIVED status from non-admin users in all lis
- **THEN** the system SHALL return all rooms regardless of status - **THEN** the system SHALL return all rooms regardless of status
- **AND** include archived rooms in the response - **AND** include archived rooms in the response
### Requirement: LOT Batch Number Tracking
The system SHALL support tracking multiple LOT (batch numbers) associated with each incident room. LOT information helps identify affected product batches for quality traceability and recall purposes.
#### Scenario: Create room with LOT numbers
- **WHEN** an authenticated user sends `POST /api/rooms` with body:
```json
{
"title": "Quality Issue on Line 3",
"incident_type": "quality_issue",
"severity": "high",
"location": "Building A",
"lots": ["LOT-2024-001", "LOT-2024-002"]
}
```
- **THEN** the system SHALL create the room with the provided LOT numbers
- **AND** store lots as a JSON array in the database
- **AND** return the room details including the lots array
#### Scenario: Create room without LOT numbers
- **WHEN** an authenticated user creates a room without specifying lots
- **THEN** the system SHALL create the room with an empty lots array
- **AND** allow lots to be added later via update
#### Scenario: Update room LOT numbers
- **WHEN** an authenticated user with editor or owner role sends `PATCH /api/rooms/{room_id}` with:
```json
{
"lots": ["LOT-2024-001", "LOT-2024-002", "LOT-2024-003"]
}
```
- **THEN** the system SHALL replace the existing lots with the new array
- **AND** update last_updated_at timestamp
- **AND** return the updated room details
#### Scenario: Add single LOT to existing room
- **WHEN** an authenticated user with editor or owner role sends `POST /api/rooms/{room_id}/lots` with:
```json
{
"lot": "LOT-2024-004"
}
```
- **THEN** the system SHALL append the LOT to the existing lots array
- **AND** prevent duplicate LOT entries
- **AND** return the updated lots array
#### Scenario: Remove single LOT from room
- **WHEN** an authenticated user with editor or owner role sends `DELETE /api/rooms/{room_id}/lots/{lot_id}`
- **THEN** the system SHALL remove the specified LOT from the array
- **AND** return the updated lots array
#### Scenario: Viewer cannot modify LOT numbers
- **WHEN** a user with viewer role attempts to update LOT numbers
- **THEN** the system SHALL return status 403 with "Insufficient permissions"
#### Scenario: LOT numbers displayed in room details
- **WHEN** a user sends `GET /api/rooms/{room_id}`
- **THEN** the response SHALL include the lots array
- **AND** lots SHALL be returned in the order they were added
---
### Requirement: Action Bar Layout
The chat room interface SHALL provide an action bar near the message input area for quick access to common operations.
#### Scenario: Action bar displays common actions
- **WHEN** user views a chat room
- **THEN** an action bar is displayed above or beside the message input
- **AND** the action bar includes: upload file, generate report, add member buttons
#### Scenario: Action bar toggle on mobile
- **WHEN** user is on a mobile device
- **THEN** the action bar buttons can be collapsed/expanded
- **AND** a single toggle button reveals the full action options
### Requirement: Message Copy Action
Users SHALL be able to copy individual message content to clipboard.
#### Scenario: Copy message content
- **WHEN** user hovers over a message
- **THEN** a copy button appears
- **AND** clicking the button copies the message content to clipboard
- **AND** a success toast notification is shown
### Requirement: Notification Settings
Users SHALL be able to configure notification preferences for the chat room.
#### Scenario: Configure notification settings
- **WHEN** user accesses notification settings
- **THEN** user can enable/disable sound notifications
- **AND** user can enable/disable browser push notifications
- **AND** user can enable/disable vibration (on supported devices)

View File

@@ -192,3 +192,148 @@ The system SHALL support message editing and deletion with proper audit trail an
- **AND** broadcast to all connected clients - **AND** broadcast to all connected clients
- **AND** aggregate reaction counts for display - **AND** aggregate reaction counts for display
### Requirement: Short-lived Database Sessions for WebSocket
The system SHALL process WebSocket messages using short-lived database sessions that are acquired and released for each individual operation, rather than holding a session for the entire WebSocket connection lifetime.
#### Scenario: Message creation with short session
- **WHEN** a user sends a message via WebSocket
- **THEN** the system acquires a database session
- **AND** creates the message with proper sequence number
- **AND** commits the transaction
- **AND** releases the session immediately
- **AND** broadcasts the message to room members
#### Scenario: Concurrent message handling
- **WHEN** multiple users send messages simultaneously
- **THEN** each message operation uses an independent database session
- **AND** sequence numbers are correctly assigned without duplicates
- **AND** no connection pool exhaustion occurs
### Requirement: Message Sequence Number Integrity
The system SHALL guarantee unique, monotonically increasing sequence numbers per room using database-level locking to prevent race conditions during concurrent message creation.
#### Scenario: Concurrent sequence assignment
- **WHEN** two users send messages to the same room at the exact same time
- **THEN** each message receives a unique sequence number
- **AND** the sequence numbers are consecutive without gaps or duplicates
#### Scenario: High concurrency sequence safety
- **WHEN** 50+ users send messages to the same room simultaneously
- **THEN** all messages receive correct unique sequence numbers
- **AND** the operation does not cause deadlocks
### Requirement: Configurable Database Connection Pool
The system SHALL support environment variable configuration for database connection pool parameters to optimize for different deployment scales.
#### Scenario: Custom pool size configuration
- **WHEN** the application starts with `DB_POOL_SIZE=20` environment variable
- **THEN** the connection pool maintains 20 persistent connections
#### Scenario: Pool overflow configuration
- **WHEN** the application starts with `DB_MAX_OVERFLOW=30` environment variable
- **THEN** the connection pool can expand up to 30 additional connections beyond the pool size
#### Scenario: Pool timeout configuration
- **WHEN** all connections are in use and a new request arrives
- **AND** `DB_POOL_TIMEOUT=10` is configured
- **THEN** the request waits up to 10 seconds for an available connection
- **AND** raises an error if no connection becomes available
#### Scenario: Default configuration
- **WHEN** no database pool environment variables are set
- **THEN** the system uses production-ready defaults (pool_size=20, max_overflow=30, timeout=10, recycle=1800)
### Requirement: Message Sender Display Name
The system SHALL include the sender's display name in message responses and broadcasts, enabling the UI to show user-friendly names instead of email addresses.
#### Scenario: Message response includes display name
- **WHEN** a message is retrieved via REST API or WebSocket
- **THEN** the response SHALL include `sender_display_name` field
- **AND** the display name SHALL be obtained by joining with the `tr_users` table
- **AND** if the sender does not exist in `tr_users`, the field SHALL fallback to `sender_id`
#### Scenario: WebSocket broadcast includes display name
- **WHEN** a new message is broadcast via WebSocket
- **THEN** the broadcast SHALL include `sender_display_name` field
- **AND** the value SHALL be the sender's display name from `tr_users` table
#### Scenario: Historical messages include display name
- **WHEN** a client requests message history via `GET /api/rooms/{room_id}/messages`
- **THEN** each message in the response SHALL include `sender_display_name`
- **AND** messages from unknown users SHALL show their `sender_id` as fallback
### Requirement: GMT+8 Timezone Display
The frontend SHALL display all timestamps in GMT+8 (Asia/Taipei) timezone for consistent user experience across all browsers.
#### Scenario: Message timestamp in GMT+8
- **WHEN** a message is displayed in the chat room
- **THEN** the timestamp SHALL be formatted in GMT+8 timezone
- **AND** use format "HH:mm" for today's messages
- **AND** use format "MM/DD HH:mm" for older messages
#### Scenario: Room list timestamps in GMT+8
- **WHEN** the room list is displayed
- **THEN** the "last updated" time SHALL be formatted in GMT+8 timezone
### Requirement: @Mention Support
The messaging system SHALL support @mention functionality to tag specific users in messages.
#### Scenario: Trigger mention autocomplete
- **WHEN** user types `@` in the message input
- **THEN** a dropdown menu appears showing room members
- **AND** the list filters as user continues typing
- **AND** user can select a member using keyboard or mouse
#### Scenario: Insert mention into message
- **WHEN** user selects a member from the mention dropdown
- **THEN** the mention is inserted as `@display_name`
- **AND** the mention is stored with the user_id reference
- **AND** the mention is visually highlighted in the message
#### Scenario: Mention notification
- **WHEN** a message containing @mention is sent
- **THEN** the mentioned user receives a highlighted notification
- **AND** the notification indicates they were mentioned
### Requirement: Browser Push Notifications
The system SHALL support browser push notifications for new messages.
#### Scenario: Request notification permission
- **WHEN** user first visits the chat room
- **THEN** the system prompts for notification permission
- **AND** the permission state is stored locally
#### Scenario: Send push notification
- **WHEN** a new message arrives while the tab is not focused
- **AND** user has granted notification permission
- **THEN** a browser push notification is displayed
- **AND** clicking the notification focuses the chat room
### Requirement: Sound and Vibration Alerts
The system SHALL support audio and haptic feedback for new messages.
#### Scenario: Play notification sound
- **WHEN** a new message arrives
- **AND** sound notifications are enabled
- **THEN** a notification sound is played
#### Scenario: Vibrate on mobile
- **WHEN** a new message arrives on a mobile device
- **AND** vibration is enabled
- **AND** the device supports Vibration API
- **THEN** the device vibrates briefly
### Requirement: Mention Data Storage
Messages with @mentions SHALL store the mention metadata for querying.
#### Scenario: Store mention references
- **WHEN** a message with @mentions is created
- **THEN** the `mentions` field stores an array of mentioned user_ids
- **AND** the message content preserves the @display_name format
#### Scenario: Query messages mentioning user
- **WHEN** fetching messages that mention a specific user
- **THEN** messages with that user_id in `mentions` array are returned

376
start-prod.sh Executable file
View File

@@ -0,0 +1,376 @@
#!/bin/bash
# ============================================================================
# Production Server Startup Script for Task Reporter
# Starts all services: MinIO, Backend (FastAPI with Gunicorn/Uvicorn workers)
# Frontend is served as static files (build first with npm run build)
# ============================================================================
set -e
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
NC='\033[0m' # No Color
# Script directory (scripts are in project root)
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$SCRIPT_DIR"
# PID file locations
PID_DIR="$PROJECT_ROOT/.pids"
BACKEND_PID_FILE="$PID_DIR/backend-prod.pid"
FRONTEND_PID_FILE="$PID_DIR/frontend-prod.pid"
# Log file locations
LOG_DIR="$PROJECT_ROOT/logs"
BACKEND_LOG="$LOG_DIR/backend-prod.log"
FRONTEND_LOG="$LOG_DIR/frontend-prod.log"
# Default configuration
BACKEND_PORT="${BACKEND_PORT:-8000}"
FRONTEND_PORT="${FRONTEND_PORT:-3000}"
WORKERS="${WORKERS:-4}"
# Helper functions
print_header() {
echo -e "\n${BLUE}=== $1 ===${NC}"
}
print_ok() {
echo -e "${GREEN}[OK]${NC} $1"
}
print_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
print_warn() {
echo -e "${YELLOW}[WARN]${NC} $1"
}
print_info() {
echo -e "${CYAN}[INFO]${NC} $1"
}
# Show help
show_help() {
echo "Usage: $0 [OPTIONS]"
echo ""
echo "Starts all production services for Task Reporter."
echo ""
echo "Options:"
echo " -h, --help Show this help message"
echo " --skip-build Skip frontend build step"
echo " --no-frontend Don't start static file server (use external nginx/caddy)"
echo " --no-minio Don't start MinIO (use external storage)"
echo " --workers N Number of uvicorn workers (default: 4)"
echo " --backend-port N Backend port (default: 8000)"
echo " --frontend-port N Frontend static server port (default: 3000)"
echo ""
echo "Environment variables:"
echo " WORKERS Number of uvicorn workers (default: 4)"
echo " BACKEND_PORT Backend API port (default: 8000)"
echo " FRONTEND_PORT Frontend static server port (default: 3000)"
echo ""
echo "Services started:"
echo " - MinIO (Object Storage) - http://localhost:9000 (API), http://localhost:9001 (Console)"
echo " - Backend (FastAPI) - http://localhost:\$BACKEND_PORT"
echo " - Frontend (Static) - http://localhost:\$FRONTEND_PORT"
echo ""
echo "Prerequisites:"
echo " - Python virtual environment at ./venv"
echo " - Frontend built (npm run build in ./frontend)"
echo " - Docker for MinIO"
}
# Cleanup function for graceful shutdown
cleanup() {
echo -e "\n${YELLOW}Shutting down services...${NC}"
"$PROJECT_ROOT/stop-prod.sh"
exit 0
}
# Trap Ctrl+C
trap cleanup SIGINT SIGTERM
# Parse arguments
SKIP_BUILD=false
NO_FRONTEND=false
NO_MINIO=false
while [[ $# -gt 0 ]]; do
case $1 in
-h|--help)
show_help
exit 0
;;
--skip-build)
SKIP_BUILD=true
shift
;;
--no-frontend)
NO_FRONTEND=true
shift
;;
--no-minio)
NO_MINIO=true
shift
;;
--workers)
WORKERS="$2"
shift 2
;;
--backend-port)
BACKEND_PORT="$2"
shift 2
;;
--frontend-port)
FRONTEND_PORT="$2"
shift 2
;;
*)
echo "Unknown option: $1"
show_help
exit 1
;;
esac
done
echo -e "${BLUE}============================================${NC}"
echo -e "${BLUE} Task Reporter - Production Server${NC}"
echo -e "${BLUE}============================================${NC}"
# Create directories
mkdir -p "$PID_DIR"
mkdir -p "$LOG_DIR"
# ============================================================================
# Pre-flight Check
# ============================================================================
print_header "Pre-flight Check"
# Check .env file exists
if [[ ! -f "$PROJECT_ROOT/.env" ]]; then
print_error ".env file not found. Copy from .env.example and configure."
exit 1
fi
print_ok ".env file exists"
# Check virtual environment
if [[ ! -d "$PROJECT_ROOT/venv" ]]; then
print_error "Python virtual environment not found at ./venv"
exit 1
fi
print_ok "Python virtual environment found"
# Check Docker
if ! command -v docker &> /dev/null; then
print_warn "Docker not found. MinIO will not be started."
NO_MINIO=true
else
print_ok "Docker available"
fi
# ============================================================================
# Build Frontend (if needed)
# ============================================================================
if [[ "$NO_FRONTEND" == "false" ]] && [[ "$SKIP_BUILD" == "false" ]]; then
print_header "Building Frontend"
cd "$PROJECT_ROOT/frontend"
if [[ ! -d "node_modules" ]]; then
print_info "Installing frontend dependencies..."
npm install
fi
print_info "Building frontend for production..."
npm run build
if [[ -d "dist" ]]; then
print_ok "Frontend built successfully"
else
print_error "Frontend build failed"
exit 1
fi
fi
# ============================================================================
# Start MinIO
# ============================================================================
if [[ "$NO_MINIO" == "false" ]]; then
print_header "Starting MinIO"
cd "$PROJECT_ROOT"
# Check if MinIO container is already running
if docker ps --format '{{.Names}}' | grep -q "task-reporter-minio"; then
print_ok "MinIO is already running"
else
# Start MinIO
if command -v docker-compose &> /dev/null; then
docker-compose -f docker-compose.minio.yml up -d
else
docker compose -f docker-compose.minio.yml up -d
fi
# Wait for MinIO to be healthy
print_info "Waiting for MinIO to be healthy..."
MINIO_READY=false
for i in {1..30}; do
if curl -s http://localhost:9000/minio/health/live > /dev/null 2>&1; then
MINIO_READY=true
break
fi
sleep 1
echo -n "."
done
echo ""
if [[ "$MINIO_READY" == "true" ]]; then
print_ok "MinIO is healthy"
else
print_error "MinIO failed to start within 30 seconds"
exit 1
fi
fi
print_info "MinIO Console: http://localhost:9001"
fi
# ============================================================================
# Start Backend (Production Mode)
# ============================================================================
print_header "Starting Backend (Production)"
cd "$PROJECT_ROOT"
# Check if backend is already running
if [[ -f "$BACKEND_PID_FILE" ]] && kill -0 "$(cat "$BACKEND_PID_FILE")" 2>/dev/null; then
print_ok "Backend is already running (PID: $(cat "$BACKEND_PID_FILE"))"
else
print_info "Starting uvicorn with $WORKERS workers..."
# Source the virtual environment and run uvicorn in production mode
(
source "$PROJECT_ROOT/venv/bin/activate"
cd "$PROJECT_ROOT"
# Production mode: no reload, multiple workers
uvicorn app.main:app \
--host 0.0.0.0 \
--port "$BACKEND_PORT" \
--workers "$WORKERS" \
--log-level info \
--access-log \
> "$BACKEND_LOG" 2>&1 &
echo $! > "$BACKEND_PID_FILE"
)
# Wait for backend to be ready
print_info "Waiting for backend to be ready..."
BACKEND_READY=false
for i in {1..30}; do
if curl -s "http://localhost:$BACKEND_PORT/api/health" > /dev/null 2>&1 || \
curl -s "http://localhost:$BACKEND_PORT/docs" > /dev/null 2>&1; then
BACKEND_READY=true
break
fi
sleep 1
echo -n "."
done
echo ""
if [[ "$BACKEND_READY" == "true" ]]; then
print_ok "Backend is ready (PID: $(cat "$BACKEND_PID_FILE"))"
else
print_warn "Backend may still be starting. Check logs: $BACKEND_LOG"
fi
fi
print_info "Backend API: http://localhost:$BACKEND_PORT"
print_info "API Docs: http://localhost:$BACKEND_PORT/docs"
# ============================================================================
# Start Frontend Static Server
# ============================================================================
if [[ "$NO_FRONTEND" == "false" ]]; then
print_header "Starting Frontend Static Server"
cd "$PROJECT_ROOT/frontend"
# Check if dist folder exists
if [[ ! -d "dist" ]]; then
print_error "Frontend dist folder not found. Run 'npm run build' first or remove --skip-build"
exit 1
fi
# Check if frontend is already running
if [[ -f "$FRONTEND_PID_FILE" ]] && kill -0 "$(cat "$FRONTEND_PID_FILE")" 2>/dev/null; then
print_ok "Frontend server is already running (PID: $(cat "$FRONTEND_PID_FILE"))"
else
# Use Python's http.server as a simple static file server
# For production, consider using nginx, caddy, or serve
print_info "Starting static file server on port $FRONTEND_PORT..."
# Check if 'serve' is available (better for SPA)
if command -v npx &> /dev/null; then
# Use serve for proper SPA support (handles client-side routing)
cd "$PROJECT_ROOT/frontend"
npx serve -s dist -l "$FRONTEND_PORT" > "$FRONTEND_LOG" 2>&1 &
echo $! > "$FRONTEND_PID_FILE"
else
# Fallback to Python http.server
cd "$PROJECT_ROOT/frontend/dist"
python3 -m http.server "$FRONTEND_PORT" --bind 0.0.0.0 > "$FRONTEND_LOG" 2>&1 &
echo $! > "$FRONTEND_PID_FILE"
print_warn "Using Python http.server (SPA routing may not work). Install 'serve' for better support."
fi
# Wait for frontend to be ready
sleep 2
if kill -0 "$(cat "$FRONTEND_PID_FILE")" 2>/dev/null; then
print_ok "Frontend server started (PID: $(cat "$FRONTEND_PID_FILE"))"
else
print_error "Frontend server failed to start. Check logs: $FRONTEND_LOG"
fi
fi
print_info "Frontend: http://localhost:$FRONTEND_PORT"
fi
# ============================================================================
# Summary
# ============================================================================
print_header "Production Services Running"
echo -e "${GREEN}============================================${NC}"
echo -e "${GREEN} All production services are running!${NC}"
echo -e "${GREEN}============================================${NC}"
echo ""
if [[ "$NO_FRONTEND" == "false" ]]; then
echo -e " ${CYAN}Frontend:${NC} http://localhost:$FRONTEND_PORT"
fi
echo -e " ${CYAN}Backend API:${NC} http://localhost:$BACKEND_PORT"
echo -e " ${CYAN}API Docs:${NC} http://localhost:$BACKEND_PORT/docs"
if [[ "$NO_MINIO" == "false" ]]; then
echo -e " ${CYAN}MinIO Console:${NC} http://localhost:9001"
fi
echo ""
echo -e " ${CYAN}Workers:${NC} $WORKERS"
echo ""
echo -e " ${YELLOW}Logs:${NC}"
echo -e " Backend: $BACKEND_LOG"
if [[ "$NO_FRONTEND" == "false" ]]; then
echo -e " Frontend: $FRONTEND_LOG"
fi
echo ""
echo -e " ${YELLOW}Press Ctrl+C to stop all services${NC}"
echo ""
# Keep script running to catch Ctrl+C
while true; do
sleep 1
done

279
stop-prod.sh Executable file
View File

@@ -0,0 +1,279 @@
#!/bin/bash
# ============================================================================
# Production Server Stop Script for Task Reporter
# Gracefully stops all services: Frontend, Backend, MinIO
# ============================================================================
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Script directory (scripts are in project root)
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$SCRIPT_DIR"
# PID file locations
PID_DIR="$PROJECT_ROOT/.pids"
BACKEND_PID_FILE="$PID_DIR/backend-prod.pid"
FRONTEND_PID_FILE="$PID_DIR/frontend-prod.pid"
# Helper functions
print_header() {
echo -e "\n${BLUE}=== $1 ===${NC}"
}
print_ok() {
echo -e "${GREEN}[OK]${NC} $1"
}
print_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
print_warn() {
echo -e "${YELLOW}[WARN]${NC} $1"
}
print_info() {
echo -e "${BLUE}[INFO]${NC} $1"
}
# Show help
show_help() {
echo "Usage: $0 [OPTIONS]"
echo ""
echo "Stops all production services for Task Reporter."
echo ""
echo "Options:"
echo " -h, --help Show this help message"
echo " --keep-minio Don't stop MinIO container"
echo " --keep-data Alias for --keep-minio"
echo ""
echo "Services stopped:"
echo " - Frontend (static file server)"
echo " - Backend (uvicorn workers)"
echo " - MinIO (Docker container)"
}
# Parse arguments
KEEP_MINIO=false
while [[ $# -gt 0 ]]; do
case $1 in
-h|--help)
show_help
exit 0
;;
--keep-minio|--keep-data)
KEEP_MINIO=true
shift
;;
*)
echo "Unknown option: $1"
show_help
exit 1
;;
esac
done
echo -e "${BLUE}============================================${NC}"
echo -e "${BLUE} Task Reporter - Stopping Production${NC}"
echo -e "${BLUE}============================================${NC}"
# ============================================================================
# Stop Frontend Static Server
# ============================================================================
print_header "Stopping Frontend Server"
if [[ -f "$FRONTEND_PID_FILE" ]]; then
FRONTEND_PID=$(cat "$FRONTEND_PID_FILE")
if kill -0 "$FRONTEND_PID" 2>/dev/null; then
kill "$FRONTEND_PID" 2>/dev/null || true
# Also kill any child processes
pkill -P "$FRONTEND_PID" 2>/dev/null || true
sleep 1
# Force kill if still running
if kill -0 "$FRONTEND_PID" 2>/dev/null; then
kill -9 "$FRONTEND_PID" 2>/dev/null || true
fi
print_ok "Frontend server stopped (was PID: $FRONTEND_PID)"
else
print_info "Frontend server was not running"
fi
rm -f "$FRONTEND_PID_FILE"
else
# Try to find and kill serve or python http.server process
SERVE_PID=$(pgrep -f "serve.*dist.*-l" 2>/dev/null | head -1)
if [[ -n "$SERVE_PID" ]]; then
kill "$SERVE_PID" 2>/dev/null || true
print_ok "Frontend server stopped (found PID: $SERVE_PID)"
else
HTTP_PID=$(pgrep -f "python.*http.server" 2>/dev/null | head -1)
if [[ -n "$HTTP_PID" ]]; then
kill "$HTTP_PID" 2>/dev/null || true
print_ok "Frontend server stopped (found PID: $HTTP_PID)"
else
print_info "Frontend server was not running"
fi
fi
fi
# ============================================================================
# Stop Backend
# ============================================================================
print_header "Stopping Backend"
if [[ -f "$BACKEND_PID_FILE" ]]; then
BACKEND_PID=$(cat "$BACKEND_PID_FILE")
if kill -0 "$BACKEND_PID" 2>/dev/null; then
# Send SIGTERM first for graceful shutdown
kill "$BACKEND_PID" 2>/dev/null || true
# Also kill worker processes
pkill -P "$BACKEND_PID" 2>/dev/null || true
sleep 2
# Force kill if still running
if kill -0 "$BACKEND_PID" 2>/dev/null; then
kill -9 "$BACKEND_PID" 2>/dev/null || true
pkill -9 -P "$BACKEND_PID" 2>/dev/null || true
fi
print_ok "Backend stopped (was PID: $BACKEND_PID)"
else
print_info "Backend was not running"
fi
rm -f "$BACKEND_PID_FILE"
else
# Try to find and kill uvicorn processes (production mode)
UVICORN_PIDS=$(pgrep -f "uvicorn app.main:app" 2>/dev/null)
if [[ -n "$UVICORN_PIDS" ]]; then
echo "$UVICORN_PIDS" | xargs kill 2>/dev/null || true
sleep 1
# Force kill remaining
REMAINING=$(pgrep -f "uvicorn app.main:app" 2>/dev/null)
if [[ -n "$REMAINING" ]]; then
echo "$REMAINING" | xargs kill -9 2>/dev/null || true
fi
print_ok "Backend stopped (found uvicorn processes)"
else
print_info "Backend was not running"
fi
fi
# ============================================================================
# Stop MinIO
# ============================================================================
if [[ "$KEEP_MINIO" == "false" ]]; then
print_header "Stopping MinIO"
cd "$PROJECT_ROOT"
if docker ps --format '{{.Names}}' 2>/dev/null | grep -q "task-reporter-minio"; then
if command -v docker-compose &> /dev/null; then
docker-compose -f docker-compose.minio.yml down
else
docker compose -f docker-compose.minio.yml down
fi
print_ok "MinIO stopped (data preserved in volume)"
else
print_info "MinIO was not running"
fi
else
print_header "MinIO"
print_info "Keeping MinIO running (--keep-minio)"
fi
# ============================================================================
# Port Release
# ============================================================================
print_header "Port Release"
# Function to release port
release_port() {
local port=$1
local service_name=$2
# Check if port is in use
if command -v fuser &> /dev/null; then
# Use fuser if available
if fuser "$port/tcp" > /dev/null 2>&1; then
print_info "Releasing port $port ($service_name)..."
fuser -k "$port/tcp" > /dev/null 2>&1 || true
sleep 1
if ! fuser "$port/tcp" > /dev/null 2>&1; then
print_ok "Port $port released"
else
print_warn "Port $port may still be in use (TIME_WAIT state)"
fi
else
print_ok "Port $port is free"
fi
elif command -v lsof &> /dev/null; then
# Fallback to lsof
local pid=$(lsof -ti ":$port" 2>/dev/null | head -1)
if [[ -n "$pid" ]]; then
print_info "Releasing port $port ($service_name, PID: $pid)..."
kill "$pid" 2>/dev/null || true
sleep 1
if ! lsof -ti ":$port" > /dev/null 2>&1; then
print_ok "Port $port released"
else
# Force kill if still in use
kill -9 "$pid" 2>/dev/null || true
sleep 1
if ! lsof -ti ":$port" > /dev/null 2>&1; then
print_ok "Port $port force released"
else
print_warn "Port $port may still be in use (TIME_WAIT state)"
fi
fi
else
print_ok "Port $port is free"
fi
else
# Check with netstat/ss as last resort
if ss -tuln 2>/dev/null | grep -q ":$port "; then
print_warn "Port $port appears in use but no tool to release it"
else
print_ok "Port $port is free"
fi
fi
}
# Get ports from environment or use defaults
BACKEND_PORT="${BACKEND_PORT:-8000}"
FRONTEND_PORT="${FRONTEND_PORT:-3000}"
# Release application ports
release_port "$BACKEND_PORT" "Backend API"
release_port "$FRONTEND_PORT" "Frontend"
# ============================================================================
# Cleanup
# ============================================================================
print_header "Cleanup"
# Remove PID files
rm -f "$BACKEND_PID_FILE" "$FRONTEND_PID_FILE" 2>/dev/null
# Remove PID directory if empty
if [[ -d "$PID_DIR" ]] && [[ -z "$(ls -A "$PID_DIR")" ]]; then
rmdir "$PID_DIR"
print_ok "Cleaned up PID directory"
else
print_ok "Cleanup complete"
fi
# ============================================================================
# Summary
# ============================================================================
print_header "Summary"
echo -e "${GREEN}All production services have been stopped.${NC}"
echo ""
if [[ "$KEEP_MINIO" == "true" ]]; then
echo -e " ${YELLOW}Note:${NC} MinIO is still running (use without --keep-minio to stop)"
fi
echo -e " ${BLUE}Tip:${NC} Run ./start-prod.sh to start services again"
echo ""

View File

@@ -0,0 +1,296 @@
"""
Concurrent Message Stress Test
This script tests:
1. Connection pool doesn't exhaust under load
2. Sequence numbers are unique per room
3. No deadlocks occur during concurrent operations
Usage:
# Run with pytest (uses test database)
pytest tests/test_concurrent_messages.py -v
# Run standalone against real database
python tests/test_concurrent_messages.py --concurrent 50 --messages 10
Requirements:
- MySQL database (SQLite doesn't support FOR UPDATE properly)
- pip install aiohttp websockets
"""
import asyncio
import argparse
import sys
import os
from collections import Counter
from typing import List, Dict, Any
import time
# Add project root to path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# Import all models to ensure proper relationship initialization
from app.modules.auth.models import UserSession
from app.modules.chat_room.models import IncidentRoom, RoomMember
from app.modules.file_storage.models import RoomFile
from app.modules.realtime.models import Message, MessageReaction, MessageEditHistory
from app.modules.report_generation.models import GeneratedReport
def create_test_room(db, room_id: str) -> IncidentRoom:
"""Create a test room for testing"""
from datetime import datetime, timezone
from app.modules.chat_room.schemas import IncidentType, SeverityLevel
room = IncidentRoom(
room_id=room_id,
title=f"Test Room {room_id}",
incident_type=IncidentType.OTHER,
severity=SeverityLevel.LOW,
lots=["TEST-LOT"],
created_by="test-admin",
created_at=datetime.now(timezone.utc)
)
db.add(room)
db.commit()
db.refresh(room)
return room
def cleanup_test_room(room_id: str):
"""Clean up test room and its messages"""
from app.core.database import get_db_context
with get_db_context() as db:
# Messages will be deleted by CASCADE
db.query(IncidentRoom).filter(IncidentRoom.room_id == room_id).delete()
db.commit()
def test_sequence_number_uniqueness_sync():
"""Test that sequence numbers are unique when creating messages sequentially"""
from sqlalchemy.orm import Session
from app.core.database import get_db_context
from app.modules.realtime.services.message_service import MessageService
from app.modules.realtime.models import MessageType, Message
import uuid
# Use short UUID to fit in room_id column (20 chars max based on model)
room_id = f"ts-{str(uuid.uuid4())[:6]}"
num_messages = 20
sequence_numbers = []
try:
# Create test room first
with get_db_context() as db:
create_test_room(db, room_id)
for i in range(num_messages):
with get_db_context() as db:
message = MessageService.create_message(
db=db,
room_id=room_id,
sender_id=f"user-{i % 5}",
content=f"Test message {i}",
message_type=MessageType.TEXT
)
sequence_numbers.append(message.sequence_number)
# Verify uniqueness
duplicates = [seq for seq, count in Counter(sequence_numbers).items() if count > 1]
assert len(duplicates) == 0, f"Duplicate sequence numbers found: {duplicates}"
# Verify monotonic increase
assert sequence_numbers == sorted(sequence_numbers), "Sequence numbers not in order"
assert sequence_numbers == list(range(1, num_messages + 1)), \
f"Sequence numbers not consecutive: {sequence_numbers}"
print(f"✓ Sequential test passed: {num_messages} messages with unique sequences")
finally:
# Cleanup (room deletion cascades to messages)
cleanup_test_room(room_id)
async def create_message_async(room_id: str, user_id: str, msg_index: int) -> Dict[str, Any]:
"""Create a message and return its sequence number"""
from app.core.database import get_db_context
from app.modules.realtime.services.message_service import MessageService
from app.modules.realtime.models import MessageType
with get_db_context() as db:
message = MessageService.create_message(
db=db,
room_id=room_id,
sender_id=user_id,
content=f"Concurrent message {msg_index}",
message_type=MessageType.TEXT
)
return {
"message_id": message.message_id,
"sequence_number": message.sequence_number,
"user_id": user_id,
"index": msg_index
}
async def test_concurrent_message_creation(num_concurrent: int = 50, messages_per_user: int = 5):
"""Test concurrent message creation from multiple users"""
from app.modules.realtime.models import Message
from app.core.database import get_db_context
import uuid
# Use short UUID to fit in room_id column (20 chars max based on model)
room_id = f"cc-{str(uuid.uuid4())[:6]}"
total_messages = num_concurrent * messages_per_user
results: List[Dict[str, Any]] = []
errors: List[str] = []
# Create test room first
with get_db_context() as db:
create_test_room(db, room_id)
print(f"\nStarting concurrent test: {num_concurrent} users × {messages_per_user} messages = {total_messages} total")
start_time = time.time()
# Create tasks for all concurrent message creations
tasks = []
for user_idx in range(num_concurrent):
user_id = f"user-{user_idx}"
for msg_idx in range(messages_per_user):
task = create_message_async(room_id, user_id, user_idx * messages_per_user + msg_idx)
tasks.append(task)
# Execute all tasks concurrently
try:
results = await asyncio.gather(*tasks, return_exceptions=True)
except Exception as e:
errors.append(str(e))
elapsed = time.time() - start_time
# Analyze results
successful_results = [r for r in results if isinstance(r, dict)]
failed_results = [r for r in results if isinstance(r, Exception)]
sequence_numbers = [r["sequence_number"] for r in successful_results]
duplicates = [seq for seq, count in Counter(sequence_numbers).items() if count > 1]
print(f"\nResults after {elapsed:.2f}s:")
print(f" - Successful messages: {len(successful_results)}/{total_messages}")
print(f" - Failed messages: {len(failed_results)}")
print(f" - Unique sequence numbers: {len(set(sequence_numbers))}")
print(f" - Duplicate sequences: {duplicates if duplicates else 'None'}")
print(f" - Messages per second: {len(successful_results)/elapsed:.1f}")
# Cleanup (room deletion cascades to messages)
try:
cleanup_test_room(room_id)
print(f" - Cleaned up test room and messages")
except Exception as e:
print(f" - Cleanup error: {e}")
# Assertions for pytest
assert len(failed_results) == 0, f"Some messages failed: {failed_results[:5]}"
assert len(duplicates) == 0, f"Duplicate sequence numbers: {duplicates}"
assert len(successful_results) == total_messages, \
f"Not all messages created: {len(successful_results)}/{total_messages}"
print(f"\n✓ Concurrent test passed!")
return {
"total": total_messages,
"successful": len(successful_results),
"failed": len(failed_results),
"duplicates": duplicates,
"elapsed": elapsed
}
def test_concurrent_messages_pytest():
"""Pytest wrapper for concurrent message test"""
asyncio.run(test_concurrent_message_creation(num_concurrent=10, messages_per_user=5))
def test_connection_pool_stress():
"""Test that connection pool handles many short sessions"""
from app.core.database import get_db_context, engine
from sqlalchemy import text
import threading
import time
num_threads = 100
queries_per_thread = 10
errors = []
success_count = [0] # Use list to allow modification in nested function
lock = threading.Lock()
def worker(thread_id: int):
for i in range(queries_per_thread):
try:
with get_db_context() as db:
# Simple query to test connection
db.execute(text("SELECT 1"))
with lock:
success_count[0] += 1
except Exception as e:
with lock:
errors.append(f"Thread {thread_id}, query {i}: {e}")
print(f"\nConnection pool stress test: {num_threads} threads × {queries_per_thread} queries")
start_time = time.time()
threads = [threading.Thread(target=worker, args=(i,)) for i in range(num_threads)]
for t in threads:
t.start()
for t in threads:
t.join()
elapsed = time.time() - start_time
total_queries = num_threads * queries_per_thread
print(f" - Completed: {success_count[0]}/{total_queries} queries in {elapsed:.2f}s")
print(f" - Queries per second: {success_count[0]/elapsed:.1f}")
print(f" - Errors: {len(errors)}")
# Show pool status
pool_status = engine.pool.status()
print(f" - Pool status: {pool_status}")
assert len(errors) == 0, f"Pool exhaustion errors: {errors[:5]}"
assert success_count[0] == total_queries, "Not all queries completed"
print(f"\n✓ Connection pool stress test passed!")
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Concurrent message stress test")
parser.add_argument("--concurrent", type=int, default=50,
help="Number of concurrent users (default: 50)")
parser.add_argument("--messages", type=int, default=5,
help="Messages per user (default: 5)")
parser.add_argument("--pool-test", action="store_true",
help="Run connection pool stress test")
parser.add_argument("--sequential", action="store_true",
help="Run sequential test only")
args = parser.parse_args()
print("=" * 60)
print("WebSocket Database Session Optimization - Stress Tests")
print("=" * 60)
if args.sequential:
test_sequence_number_uniqueness_sync()
elif args.pool_test:
test_connection_pool_stress()
else:
# Run all tests
test_sequence_number_uniqueness_sync()
test_connection_pool_stress()
asyncio.run(test_concurrent_message_creation(
num_concurrent=args.concurrent,
messages_per_user=args.messages
))
print("\n" + "=" * 60)
print("All tests completed successfully!")
print("=" * 60)