feat: Migrate to MySQL and add unified environment configuration

## Database Migration (SQLite → MySQL)
- Add Alembic migration framework
- Add 'tr_' prefix to all tables to avoid conflicts in shared database
- Remove SQLite support, use MySQL exclusively
- Add pymysql driver dependency
- Change ad_token column to Text type for long JWT tokens

## Unified Environment Configuration
- Centralize all hardcoded settings to environment variables
- Backend: Extend Settings class in app/core/config.py
- Frontend: Use Vite environment variables (import.meta.env)
- Docker: Move credentials to environment variables
- Update .env.example files with comprehensive documentation

## Test Organization
- Move root-level test files to tests/ directory:
  - test_chat_room.py → tests/test_chat_room.py
  - test_websocket.py → tests/test_websocket.py
  - test_realtime_implementation.py → tests/test_realtime_implementation.py
- Fix path references in test_realtime_implementation.py

Breaking Changes:
- CORS now requires explicit origins (no more wildcard)
- All database tables renamed with 'tr_' prefix
- SQLite no longer supported

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
egg
2025-12-07 14:15:11 +08:00
parent 1d5d4d447d
commit 92834dbe0e
39 changed files with 1558 additions and 136 deletions

View File

@@ -1,9 +1,11 @@
"""SQLAlchemy models for realtime messaging
Tables:
- messages: Stores all messages sent in incident rooms
- message_reactions: User reactions to messages (emoji)
- message_edit_history: Audit trail for message edits
- tr_messages: Stores all messages sent in incident rooms
- tr_message_reactions: User reactions to messages (emoji)
- tr_message_edit_history: Audit trail for message edits
Note: All tables use 'tr_' prefix to avoid conflicts in shared database.
"""
from sqlalchemy import Column, Integer, String, Text, DateTime, Enum, ForeignKey, UniqueConstraint, Index, BigInteger, JSON
from sqlalchemy.orm import relationship
@@ -25,10 +27,10 @@ class MessageType(str, enum.Enum):
class Message(Base):
"""Message model for incident room communications"""
__tablename__ = "messages"
__tablename__ = "tr_messages"
message_id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
room_id = Column(String(36), ForeignKey("incident_rooms.room_id", ondelete="CASCADE"), nullable=False)
room_id = Column(String(36), ForeignKey("tr_incident_rooms.room_id", ondelete="CASCADE"), nullable=False)
sender_id = Column(String(255), nullable=False) # User email/ID
content = Column(Text, nullable=False)
message_type = Column(Enum(MessageType), default=MessageType.TEXT, nullable=False)
@@ -42,7 +44,6 @@ class Message(Base):
deleted_at = Column(DateTime) # Soft delete timestamp
# Sequence number for FIFO ordering within a room
# Note: Autoincrement doesn't work for non-PK in SQLite, will be set in service layer
sequence_number = Column(BigInteger, nullable=False)
# Relationships
@@ -51,22 +52,19 @@ class Message(Base):
# Indexes for common queries
__table_args__ = (
Index("ix_messages_room_created", "room_id", "created_at"),
Index("ix_messages_room_sequence", "room_id", "sequence_number"),
Index("ix_messages_sender", "sender_id"),
# PostgreSQL full-text search index on content (commented for SQLite compatibility)
# Note: Uncomment when using PostgreSQL with pg_trgm extension enabled
# Index("ix_messages_content_search", "content", postgresql_using='gin', postgresql_ops={'content': 'gin_trgm_ops'}),
Index("ix_tr_messages_room_created", "room_id", "created_at"),
Index("ix_tr_messages_room_sequence", "room_id", "sequence_number"),
Index("ix_tr_messages_sender", "sender_id"),
)
class MessageReaction(Base):
"""Message reaction model for emoji reactions"""
__tablename__ = "message_reactions"
__tablename__ = "tr_message_reactions"
reaction_id = Column(Integer, primary_key=True, autoincrement=True)
message_id = Column(String(36), ForeignKey("messages.message_id", ondelete="CASCADE"), nullable=False)
message_id = Column(String(36), ForeignKey("tr_messages.message_id", ondelete="CASCADE"), nullable=False)
user_id = Column(String(255), nullable=False) # User email/ID who reacted
emoji = Column(String(10), nullable=False) # Emoji character or code
@@ -79,18 +77,18 @@ class MessageReaction(Base):
# Constraints and indexes
__table_args__ = (
# Ensure unique reaction per user per message
UniqueConstraint("message_id", "user_id", "emoji", name="uq_message_reaction"),
Index("ix_message_reactions_message", "message_id"),
UniqueConstraint("message_id", "user_id", "emoji", name="uq_tr_message_reaction"),
Index("ix_tr_message_reactions_message", "message_id"),
)
class MessageEditHistory(Base):
"""Message edit history model for audit trail"""
__tablename__ = "message_edit_history"
__tablename__ = "tr_message_edit_history"
edit_id = Column(Integer, primary_key=True, autoincrement=True)
message_id = Column(String(36), ForeignKey("messages.message_id", ondelete="CASCADE"), nullable=False)
message_id = Column(String(36), ForeignKey("tr_messages.message_id", ondelete="CASCADE"), nullable=False)
original_content = Column(Text, nullable=False) # Content before edit
edited_by = Column(String(255), nullable=False) # User who made the edit
@@ -102,5 +100,5 @@ class MessageEditHistory(Base):
# Indexes
__table_args__ = (
Index("ix_message_edit_history_message", "message_id", "edited_at"),
Index("ix_tr_message_edit_history_message", "message_id", "edited_at"),
)

View File

@@ -7,6 +7,7 @@ from datetime import datetime
import json
from app.core.database import get_db
from app.core.config import get_settings
from app.modules.auth.dependencies import get_current_user
from app.modules.auth.services.session_service import session_service
from app.modules.chat_room.models import RoomMember, MemberRole
@@ -32,7 +33,7 @@ from sqlalchemy import and_
router = APIRouter(prefix="/api", tags=["realtime"])
SYSTEM_ADMIN_EMAIL = "ymirliu@panjit.com.tw"
settings = get_settings()
async def ws_send_json(websocket: WebSocket, data: dict):
@@ -51,9 +52,14 @@ def get_user_room_membership(db: Session, room_id: str, user_id: str) -> Optiona
).first()
def is_system_admin(user_id: str) -> bool:
"""Check if user is the system administrator"""
return bool(settings.SYSTEM_ADMIN_EMAIL and user_id == settings.SYSTEM_ADMIN_EMAIL)
def can_write_message(membership: Optional[RoomMember], user_id: str) -> bool:
"""Check if user has write permission (OWNER or EDITOR)"""
if user_id == SYSTEM_ADMIN_EMAIL:
if is_system_admin(user_id):
return True
if not membership:
@@ -99,7 +105,7 @@ async def websocket_endpoint(
# Check room membership
membership = get_user_room_membership(db, room_id, user_id)
if not membership and user_id != SYSTEM_ADMIN_EMAIL:
if not membership and not is_system_admin(user_id):
await websocket.close(code=4001, reason="Not a member of this room")
return
@@ -225,12 +231,11 @@ async def websocket_endpoint(
continue
# Delete message
is_admin = user_id == SYSTEM_ADMIN_EMAIL
deleted_message = MessageService.delete_message(
db=db,
message_id=ws_message.message_id,
user_id=user_id,
is_admin=is_admin
is_admin=is_system_admin(user_id)
)
if not deleted_message:
@@ -345,7 +350,7 @@ async def get_messages(
# Check room membership
membership = get_user_room_membership(db, room_id, user_id)
if not membership and user_id != SYSTEM_ADMIN_EMAIL:
if not membership and not is_system_admin(user_id):
raise HTTPException(status_code=403, detail="Not a member of this room")
return MessageService.get_messages(
@@ -414,7 +419,7 @@ async def search_messages(
# Check room membership
membership = get_user_room_membership(db, room_id, user_id)
if not membership and user_id != SYSTEM_ADMIN_EMAIL:
if not membership and not is_system_admin(user_id):
raise HTTPException(status_code=403, detail="Not a member of this room")
return MessageService.search_messages(
@@ -437,7 +442,7 @@ async def get_online_users(
# Check room membership
membership = get_user_room_membership(db, room_id, user_id)
if not membership and user_id != SYSTEM_ADMIN_EMAIL:
if not membership and not is_system_admin(user_id):
raise HTTPException(status_code=403, detail="Not a member of this room")
online_users = manager.get_online_users(room_id)
@@ -455,7 +460,7 @@ async def get_typing_users(
# Check room membership
membership = get_user_room_membership(db, room_id, user_id)
if not membership and user_id != SYSTEM_ADMIN_EMAIL:
if not membership and not is_system_admin(user_id):
raise HTTPException(status_code=403, detail="Not a member of this room")
typing_users = manager.get_typing_users(room_id)

View File

@@ -5,6 +5,7 @@ from typing import List, Optional, Dict, Any
from datetime import datetime, timedelta
import uuid
from app.core.config import get_settings
from app.modules.realtime.models import Message, MessageType, MessageReaction, MessageEditHistory
from app.modules.realtime.schemas import (
MessageCreate,
@@ -13,6 +14,8 @@ from app.modules.realtime.schemas import (
ReactionSummary
)
settings = get_settings()
class MessageService:
"""Service for message operations"""
@@ -161,9 +164,9 @@ class MessageService:
if message.sender_id != user_id:
return None
# Check time limit (15 minutes)
# Check time limit (configurable via MESSAGE_EDIT_TIME_LIMIT_MINUTES)
time_diff = datetime.utcnow() - message.created_at
if time_diff > timedelta(minutes=15):
if time_diff > timedelta(minutes=settings.MESSAGE_EDIT_TIME_LIMIT_MINUTES):
return None
# Store original content in edit history

View File

@@ -6,6 +6,10 @@ import asyncio
import json
from collections import defaultdict
from app.core.config import get_settings
settings = get_settings()
def json_serializer(obj: Any) -> str:
"""Custom JSON serializer for objects not serializable by default json code"""
@@ -193,9 +197,11 @@ class WebSocketManager:
if user_id in self._typing_tasks:
self._typing_tasks[user_id].cancel()
# Set new timeout (3 seconds)
# Set new timeout (configurable via TYPING_TIMEOUT_SECONDS)
typing_timeout = settings.TYPING_TIMEOUT_SECONDS
async def clear_typing():
await asyncio.sleep(3)
await asyncio.sleep(typing_timeout)
self._typing_users[room_id].discard(user_id)
if user_id in self._typing_tasks:
del self._typing_tasks[user_id]