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:
@@ -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"),
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user