8b17c17137
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.
222 lines
8.8 KiB
Python
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])
|