Files
modbison/backend/alembic/versions/0008_add_chat_tables.py
T
daddy 546678a30d Add chat data models + migration (conversations, messages, reports)
docs/planning/social.md US-4, task 6: conversations,
conversation_participants, messages (DM + world/room chat), and
message_reports, following app/models/social.py's conventions (native
enums, explicit index/constraint names, CASCADE FKs where appropriate).

Delete behavior, chosen per-relationship:
- conversations.user_a_id/user_b_id -> CASCADE: a DM is meaningless with
  one of its two participants gone, so it cascades away entirely
  (participants + messages with it).
- messages.sender_id -> SET NULL: world/room chat history and reported
  messages must survive the sender's account being deleted, matching the
  existing actor-reference convention (WorldChunk.updated_by_id,
  WorldChunkRevision.actor_id).
- messages.world_id/room_id -> CASCADE: chat scoped to a world/room has no
  meaning once that world/room is gone (mirrors WorldChunk).
- message_reports.message_id -> SET NULL (not CASCADE): a moderation
  report must outlive the message it's about, mirroring
  WorldChunkRevision's audit-trail-survives-deletion precedent.
- message_reports.reporter_id/moderator_id -> SET NULL: audit-actor
  references, same reasoning.

Models + migration only, no endpoints, no text filtering (sibling tasks).

Verified: migration upgrade/downgrade/upgrade round-trip against a real
Postgres with schema inspected via \d against spec; new model tests cover
DM creation, pair uniqueness + ordering check constraint, world/room
messages, report defaults, and cascade behavior for both
conversation-deletion and sender-deletion; full backend suite (160 tests)
passes with no regressions.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y2FESCaMXeJpC9gSctiEeD
2026-09-15 00:19:53 +00:00

179 lines
6.6 KiB
Python

"""add chat tables (conversations, messages, message_reports)
Revision ID: 0008_add_chat_tables
Revises: 0007_add_social_graph_tables
Create Date: 2026-09-15 00:00:00.000000
docs/planning/social.md US-4 / app/models/chat.py. Models + migration only
-- text filtering, rate limiting, and send/report/moderation endpoints live
in sibling tasks that depend on this one.
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = "0008_add_chat_tables"
down_revision: Union[str, None] = "0007_add_social_graph_tables"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
message_report_status = postgresql.ENUM(
"open", "resolved", "dismissed", name="message_report_status"
)
message_report_status.create(op.get_bind(), checkfirst=True)
op.create_table(
"conversations",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"user_a_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"user_b_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.UniqueConstraint("user_a_id", "user_b_id", name="uq_conversations_pair"),
sa.CheckConstraint("user_a_id < user_b_id", name="ck_conversations_ordered_pair"),
)
op.create_index("ix_conversations_user_a_id", "conversations", ["user_a_id"])
op.create_index("ix_conversations_user_b_id", "conversations", ["user_b_id"])
op.create_table(
"conversation_participants",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"conversation_id",
sa.Integer(),
sa.ForeignKey("conversations.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"user_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.UniqueConstraint(
"conversation_id", "user_id", name="uq_conversation_participants_pair"
),
)
op.create_index(
"ix_conversation_participants_conversation_id",
"conversation_participants",
["conversation_id"],
)
op.create_index(
"ix_conversation_participants_user_id", "conversation_participants", ["user_id"]
)
op.create_table(
"messages",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"sender_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column(
"conversation_id",
sa.Integer(),
sa.ForeignKey("conversations.id", ondelete="CASCADE"),
nullable=True,
),
sa.Column(
"world_id",
sa.Integer(),
sa.ForeignKey("worlds.id", ondelete="CASCADE"),
nullable=True,
),
sa.Column(
"room_id",
sa.Integer(),
sa.ForeignKey("world_rooms.id", ondelete="CASCADE"),
nullable=True,
),
sa.Column("body", sa.Text(), nullable=False),
sa.Column("filtered", sa.Boolean(), nullable=False, server_default="false"),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
op.create_index("ix_messages_sender_id", "messages", ["sender_id"])
op.create_index(
"ix_messages_conversation_created", "messages", ["conversation_id", "created_at"]
)
op.create_index("ix_messages_world_created", "messages", ["world_id", "created_at"])
op.create_table(
"message_reports",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"message_id",
sa.Integer(),
sa.ForeignKey("messages.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column(
"reporter_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column("reason", sa.Text(), nullable=False),
sa.Column(
"status",
postgresql.ENUM(
"open", "resolved", "dismissed", name="message_report_status", create_type=False
),
nullable=False,
server_default="open",
),
sa.Column(
"moderator_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column("resolved_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
op.create_index("ix_message_reports_message_id", "message_reports", ["message_id"])
op.create_index("ix_message_reports_reporter_id", "message_reports", ["reporter_id"])
op.create_index("ix_message_reports_moderator_id", "message_reports", ["moderator_id"])
def downgrade() -> None:
op.drop_index("ix_message_reports_moderator_id", table_name="message_reports")
op.drop_index("ix_message_reports_reporter_id", table_name="message_reports")
op.drop_index("ix_message_reports_message_id", table_name="message_reports")
op.drop_table("message_reports")
op.drop_index("ix_messages_world_created", table_name="messages")
op.drop_index("ix_messages_conversation_created", table_name="messages")
op.drop_index("ix_messages_sender_id", table_name="messages")
op.drop_table("messages")
op.drop_index("ix_conversation_participants_user_id", table_name="conversation_participants")
op.drop_index(
"ix_conversation_participants_conversation_id", table_name="conversation_participants"
)
op.drop_table("conversation_participants")
op.drop_index("ix_conversations_user_b_id", table_name="conversations")
op.drop_index("ix_conversations_user_a_id", table_name="conversations")
op.drop_table("conversations")
postgresql.ENUM(name="message_report_status").drop(op.get_bind(), checkfirst=True)