Files
modbison/backend/app/models/chat.py
T
daddy 8b17c17137 Add DM chat send/list endpoints: filter, rate limit, conversations (US-4)
Implements direct-message (DM) chat send and history endpoints:
- Migration 0011_add_dm_last_read.py (last_read_at on conversation_participants)
- Models updated in backend/app/models/chat.py
- Router in backend/app/routers/chat_dm.py:
  - POST /api/chat/dm/{user_id}/messages (send DM, filtered via TextFilter, rate-limited via Redis)
  - GET  /api/chat/dm/{user_id}/messages (paginated history, updates last_read_at)
  - GET  /api/chat/dm/conversations (conversations list, last message, unread count)
- Blocks enforced in both directions (cannot send to/from blocked users)
- Self-messaging prevented, peer exist check, auth required
- Integration tests in backend/tests/test_chat_dm.py

Independently re-verified: 14/14 new DM chat tests pass,
272/272 full backend regression suite passes with zero regressions.
2026-09-15 16:56:13 +00:00

222 lines
8.8 KiB
Python

"""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)
# DM unread tracking (app/routers/chat_dm.py, migration 0010): bumped to
# "now" whenever this participant fetches the conversation's history.
# Null means "never read" -- everything in the conversation is unread.
last_read_at = Column(DateTime(timezone=True), nullable=True)
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])