546678a30d
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
179 lines
6.6 KiB
Python
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)
|