"""Chat: DM conversations and world/room chat, plus message reports. docs/planning/social.md §4/§5 (US-4). This module is models + schema only -- text filtering, rate limiting, and the send/list/report/moderation endpoints belong to sibling tasks that depend on this one. Delete-behavior choices (mirrors ``app/models/world_content.py``'s established convention of preserving audit/history data over cascading it away): - ``conversations.user_a_id`` / ``user_b_id`` -> CASCADE. A DM conversation is meaningless with one of its two (and only) participants gone, so deleting either user removes the conversation outright, which in turn cascades to its ``conversation_participants`` rows and its ``messages``. - ``conversation_participants.user_id`` -> CASCADE, ``.conversation_id`` -> CASCADE. Pure join-table rows; they only make sense alongside both sides. - ``messages.conversation_id`` -> CASCADE. Deleting a conversation deletes its message history with it. - ``messages.sender_id`` -> SET NULL (nullable). Unlike a DM conversation (which dies with its participants above), world/room chat history and any message under moderation review should survive the sender's account being deleted -- the row just becomes attributed to no one, matching how ``WorldChunk.updated_by_id`` / ``WorldChunkRevision.actor_id`` treat actor references as SET NULL rather than cascading away history. - ``messages.world_id`` / ``room_id`` -> CASCADE. World/room chat has no meaning once the world or room it was said in is gone; consistent with ``WorldChunk``'s own CASCADE from both. - ``message_reports.message_id`` -> SET NULL (nullable). Same audit-trail reasoning as ``WorldChunkRevision`` (world_content.py): a moderation report is a record that something *was* said and reported, which must outlive the underlying message being removed (e.g. as the moderation action taken in response to the report itself). - ``message_reports.reporter_id`` / ``moderator_id`` -> SET NULL (nullable). Actor references for an audit record; the report itself must survive either account being deleted later, matching the actor-reference convention above. """ import enum from sqlalchemy import ( Boolean, CheckConstraint, Column, DateTime, ForeignKey, Index, Integer, Text, UniqueConstraint, ) from sqlalchemy.dialects.postgresql import ENUM from sqlalchemy.orm import relationship from sqlalchemy.sql import func from app.database import Base class ReportStatus(str, enum.Enum): OPEN = "open" RESOLVED = "resolved" DISMISSED = "dismissed" class Conversation(Base): """A DM conversation between exactly two users. ``conversation_participants`` is the real roster (modeled as a join table so a future group-DM schema change can widen it beyond two rows without touching this table's shape), but a 2-party DM also needs a cheap, DB-enforced "only one conversation per pair" guarantee, which a plain join table can't express as a simple unique constraint. So the pair is additionally denormalized here as ``user_a_id``/``user_b_id``, always stored with the lower user id first (enforced by the check constraint below); the API layer is responsible for normalizing the pair before insert/lookup. """ __tablename__ = "conversations" id = Column(Integer, primary_key=True, index=True) user_a_id = Column( Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True ) user_b_id = Column( Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True ) created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) updated_at = Column( DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False ) participants = relationship( "ConversationParticipant", back_populates="conversation", cascade="all, delete-orphan" ) messages = relationship( "Message", back_populates="conversation", cascade="all, delete-orphan" ) __table_args__ = ( UniqueConstraint("user_a_id", "user_b_id", name="uq_conversations_pair"), CheckConstraint("user_a_id < user_b_id", name="ck_conversations_ordered_pair"), ) class ConversationParticipant(Base): """Join table of a conversation's members. DMs are strictly 2-party today (enforced by ``Conversation``'s pair columns above), but this is modeled as a real join table -- not folded into ``Conversation`` -- so a later group-conversation feature can add rows here without a schema change to this table. """ __tablename__ = "conversation_participants" id = Column(Integer, primary_key=True, index=True) conversation_id = Column( Integer, ForeignKey("conversations.id", ondelete="CASCADE"), nullable=False, index=True ) user_id = Column( Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True ) created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) conversation = relationship("Conversation", back_populates="participants") user = relationship("User") __table_args__ = ( UniqueConstraint( "conversation_id", "user_id", name="uq_conversation_participants_pair" ), ) class Message(Base): """A chat message: either a DM (``conversation_id`` set) or a world/room chat message (``conversation_id`` null, ``world_id`` set and optionally ``room_id``). ``world_id`` is denormalized onto every world/room message (mirroring ``WorldChunk.world_id``) so world-scoped history queries don't need a join through rooms; ``room_id`` narrows further to a specific room when the world subdivides chat by room, and is null for world-wide chat. """ __tablename__ = "messages" id = Column(Integer, primary_key=True, index=True) sender_id = Column( Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True ) 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( Integer, ForeignKey("world_rooms.id", ondelete="CASCADE"), nullable=True ) body = Column(Text, nullable=False) filtered = Column(Boolean, nullable=False, default=False, server_default="false") created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) sender = relationship("User") conversation = relationship("Conversation", back_populates="messages") world = relationship("World") room = relationship("WorldRoom") reports = relationship( "MessageReport", back_populates="message", cascade="all, delete-orphan" ) __table_args__ = ( Index("ix_messages_conversation_created", "conversation_id", "created_at"), Index("ix_messages_world_created", "world_id", "created_at"), ) class MessageReport(Base): """A moderation report filed against a message. ``message_id`` is nullable with ``ON DELETE SET NULL`` (rather than CASCADE) so the report -- the audit record that something was said and flagged -- outlives the message row, including when the message's own removal is the moderation action taken in response to this report. """ __tablename__ = "message_reports" id = Column(Integer, primary_key=True, index=True) message_id = Column( Integer, ForeignKey("messages.id", ondelete="SET NULL"), nullable=True, index=True ) reporter_id = Column( Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True ) reason = Column(Text, nullable=False) status = Column( ENUM( ReportStatus, name="message_report_status", create_type=False, values_callable=lambda enum_cls: [e.value for e in enum_cls], ), nullable=False, default=ReportStatus.OPEN, server_default=ReportStatus.OPEN.value, ) moderator_id = Column( Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True ) resolved_at = Column(DateTime(timezone=True), nullable=True) created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) message = relationship("Message", back_populates="reports") reporter = relationship("User", foreign_keys=[reporter_id]) moderator = relationship("User", foreign_keys=[moderator_id])