From cff7fd96d22809d1cfa3767bf4d7d728873791af Mon Sep 17 00:00:00 2001 From: coder-bot Date: Tue, 18 Aug 2026 14:57:17 +0000 Subject: [PATCH] WIP checkpoint: Chat data models + migration (conversations, messages, reports) --- backend/alembic/env.py | 2 +- backend/app/models/__init__.py | 13 +++- backend/app/models/conversation.py | 94 +++++++++++++++++++++++++++++ backend/app/models/message.py | 96 ++++++++++++++++++++++++++++++ 4 files changed, 203 insertions(+), 2 deletions(-) create mode 100644 backend/app/models/conversation.py create mode 100644 backend/app/models/message.py diff --git a/backend/alembic/env.py b/backend/alembic/env.py index d9411ca..96ed1b0 100644 --- a/backend/alembic/env.py +++ b/backend/alembic/env.py @@ -8,7 +8,7 @@ from sqlalchemy.ext.asyncio import create_async_engine from app.config import settings from app.database import Base -from app.models import user, session, world, asset # noqa: F401 +from app.models import user, session, world, asset, conversation, message # noqa: F401 # this is the Alembic Config object, which provides # access to the values within the .ini file in use. diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 96e2dc3..7933483 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -2,5 +2,16 @@ from app.models.user import User from app.models.session import Session from app.models.world import World from app.models.asset import Asset +from app.models.conversation import Conversation, ConversationParticipant +from app.models.message import Message, MessageReport -__all__ = ["User", "Session", "World", "Asset"] +__all__ = [ + "User", + "Session", + "World", + "Asset", + "Conversation", + "ConversationParticipant", + "Message", + "MessageReport", +] diff --git a/backend/app/models/conversation.py b/backend/app/models/conversation.py new file mode 100644 index 0000000..b0f1b12 --- /dev/null +++ b/backend/app/models/conversation.py @@ -0,0 +1,94 @@ +"""Conversation and participant models for DM chat.""" + +from sqlalchemy import ( + Column, + Integer, + String, + DateTime, + ForeignKey, + UniqueConstraint, + Index, +) +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func + +from app.database import Base + + +class Conversation(Base): + """A DM conversation between two users. + + Each conversation is uniquely identified by the pair of participant + user IDs (regardless of order). A conversation is soft-deletable + via ``left_at`` on the participant row. + """ + + __tablename__ = "conversations" + + id = Column(Integer, primary_key=True, index=True) + # The most recent message's ID in this conversation (denormalised + # for fast listing; updated by triggers or app logic). + latest_message_id = Column( + Integer, ForeignKey("messages.id", ondelete="SET NULL"), nullable=True + ) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + + # Relationships + latest_message = relationship( + "Message", + foreign_keys=[latest_message_id], + lazy="select", + ) + participants = relationship( + "ConversationParticipant", + back_populates="conversation", + cascade="all, delete-orphan", + order_by="ConversationParticipant.id", + ) + + __table_args__ = ( + # Ensure exactly one conversation per unique pair of users + UniqueConstraint("participant_1_id", "participant_2_id", name="uq_conversation_participants"), + ) + + def __repr__(self) -> str: + return f"" + + +class ConversationParticipant(Base): + """Link table between conversations and users. + + Stores per-participant metadata such as when they left the + conversation (for soft-delete / archival). + """ + + __tablename__ = "conversation_participants" + + id = Column(Integer, primary_key=True, index=True) + conversation_id = Column( + Integer, + ForeignKey("conversations.id", ondelete="CASCADE"), + nullable=False, + ) + user_id = Column( + Integer, + ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + ) + # When the participant left (NULL = still active). + left_at = Column(DateTime(timezone=True), nullable=True) + # Timestamp of the last message read by this participant. + last_read_at = Column(DateTime(timezone=True), nullable=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + conversation = relationship("Conversation", back_populates="participants") + user = relationship("User") + + __table_args__ = ( + UniqueConstraint("conversation_id", "user_id", name="uq_conversation_user"), + Index("ix_conversation_participants_user_id", "user_id"), + ) + + def __repr__(self) -> str: + return f"" diff --git a/backend/app/models/message.py b/backend/app/models/message.py new file mode 100644 index 0000000..d4fe66b --- /dev/null +++ b/backend/app/models/message.py @@ -0,0 +1,96 @@ +"""Message models — messages, message reports.""" + +from sqlalchemy import ( + Column, + Integer, + String, + Text, + DateTime, + ForeignKey, + Boolean, + Index, +) +from sqlalchemy.dialects.postgresql import ENUM +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func +import enum + +from app.database import Base + + +class MessageReportStatus(str, enum.Enum): + PENDING = "pending" + RESOLVED = "resolved" + DISMISSED = "dismissed" + + +class MessageReportReason(str, enum.Enum): + SPAM = "spam" + INAPPROPRIATE = "inappropriate" + OFF_TOPIC = "off_topic" + OTHER = "other" + + +class Message(Base): + __tablename__ = "messages" + + id = Column(Integer, primary_key=True, index=True) + sender_id = Column( + Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False + ) + conversation_id = Column( + Integer, ForeignKey("conversations.id", ondelete="CASCADE"), nullable=True + ) + world_id = Column( + Integer, ForeignKey("worlds.id", ondelete="CASCADE"), nullable=True + ) + room_id = Column(String(36), nullable=True) + body = Column(Text, nullable=False) + is_filtered = Column(Boolean, default=False, nullable=False) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + # Relationships + sender = relationship("User", back_populates="sent_messages") + conversation = relationship("Conversation", back_populates="messages") + world = relationship("World") + reports = relationship( + "MessageReport", back_populates="message", cascade="all, delete-orphan" + ) + + # Indexes for history queries + __table_args__ = ( + Index("ix_messages_conversation_created", "conversation_id", "created_at"), + Index("ix_messages_world_created", "world_id", "created_at"), + ) + + +class MessageReport(Base): + __tablename__ = "message_reports" + + id = Column(Integer, primary_key=True, index=True) + message_id = Column( + Integer, ForeignKey("messages.id", ondelete="CASCADE"), nullable=False + ) + reporter_id = Column( + Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=False + ) + reason = Column( + ENUM(MessageReportReason, name="message_report_reason", create_type=False), + nullable=False, + ) + details = Column(Text, nullable=True) + status = Column( + ENUM(MessageReportStatus, name="message_report_status", create_type=False), + default=MessageReportStatus.PENDING, + nullable=False, + ) + moderator_id = Column( + Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True + ) + resolved_at = Column(DateTime(timezone=True), nullable=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + # Relationships + message = relationship("Message", back_populates="reports") + reporter = relationship("User", foreign_keys=[reporter_id]) + moderator = relationship("User", foreign_keys=[moderator_id])