diff --git a/backend/alembic/versions/0003_moderation.py b/backend/alembic/versions/0003_moderation.py new file mode 100644 index 0000000..0138640 --- /dev/null +++ b/backend/alembic/versions/0003_moderation.py @@ -0,0 +1,188 @@ +"""add moderation tables and extend users table + +Revision ID: 0003_moderation +Revises: 0002_add_date_of_birth_to_users +Create Date: 2025-01-01 00:00:00.000000 + +""" +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 = "0003_moderation" +down_revision: Union[str, None] = "0002_add_date_of_birth_to_users" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Add role, banned_until, suspended_until to users table + and create reports, moderation_actions, appeals tables.""" + + # --- Extend users table --- + + # Add role column as ENUM + op.execute( + "CREATE TYPE user_role AS ENUM ('user', 'moderator', 'admin')" + ) + op.add_column( + "users", + sa.Column( + "role", + postgresql.ENUM("user", "moderator", "admin", name="user_role", create_type=False), + nullable=False, + server_default="user", + ), + ) + + # Add banned_until column + op.add_column( + "users", + sa.Column("banned_until", sa.DateTime(timezone=True), nullable=True), + ) + + # Add suspended_until column + op.add_column( + "users", + sa.Column("suspended_until", sa.DateTime(timezone=True), nullable=True), + ) + + # --- Create reports table --- + + op.create_table( + "reports", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("reporter_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=False), + sa.Column( + "target_type", + postgresql.ENUM("user", "world", "asset", "chat_message", name="report_target_type", create_type=False), + nullable=False, + ), + sa.Column("target_id", sa.String(64), nullable=False), + sa.Column( + "reason_category", + postgresql.ENUM( + "harassment", "hate_speech", "sexual_content", + "violence", "spam", "self_harm", "other", + name="report_reason_category", create_type=False, + ), + nullable=False, + ), + sa.Column("details", sa.Text(), nullable=True), + sa.Column( + "status", + postgresql.ENUM("open", "in_review", "resolved", "dismissed", name="report_status", create_type=False), + nullable=False, + server_default="open", + ), + sa.Column("resolved_by", sa.Integer(), sa.ForeignKey("users.id"), nullable=True), + sa.Column( + "resolution", + postgresql.ENUM("warn", "suspend", "ban", "restrict", "remove", name="report_resolution", create_type=False), + nullable=True, + ), + 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.Column("resolved_at", sa.DateTime(timezone=True), nullable=True), + ) + + # Indexes for reports + op.create_index("ix_reports_reporter_id", "reports", ["reporter_id"]) + op.create_index("ix_reports_target", "reports", ["target_type", "target_id"]) + op.create_index("ix_reports_status", "reports", ["status"]) + + # --- Create moderation_actions table --- + + op.create_table( + "moderation_actions", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("moderator_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=False), + sa.Column("target_user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=True), + sa.Column( + "target_type", + postgresql.ENUM("user", "world", "asset", "chat_message", name="moderation_target_type", create_type=False), + nullable=False, + ), + sa.Column("target_id", sa.String(64), nullable=False), + sa.Column( + "action", + postgresql.ENUM("warn", "suspend", "ban", "restrict", "remove", "restore", name="moderation_action_type", create_type=False), + nullable=False, + ), + sa.Column("reason", sa.Text(), nullable=True), + sa.Column("report_id", sa.Integer(), sa.ForeignKey("reports.id"), nullable=True), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + ) + + # Indexes for moderation_actions + op.create_index("ix_moderation_actions_moderator_id", "moderation_actions", ["moderator_id"]) + op.create_index("ix_moderation_actions_target_user", "moderation_actions", ["target_user_id"]) + op.create_index("ix_moderation_actions_target", "moderation_actions", ["target_type", "target_id"]) + op.create_index("ix_moderation_actions_report_id", "moderation_actions", ["report_id"]) + + # --- Create appeals table --- + + op.create_table( + "appeals", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("moderation_action_id", sa.Integer(), sa.ForeignKey("moderation_actions.id"), nullable=False), + sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=False), + sa.Column("reason", sa.Text(), nullable=False), + sa.Column( + "status", + postgresql.ENUM("pending", "approved", "rejected", name="appeal_status", create_type=False), + nullable=False, + server_default="pending", + ), + sa.Column("reviewed_by", sa.Integer(), sa.ForeignKey("users.id"), nullable=True), + sa.Column("reviewed_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + ) + + # Indexes for appeals + op.create_index("ix_appeals_moderation_action_id", "appeals", ["moderation_action_id"]) + op.create_index("ix_appeals_user_id", "appeals", ["user_id"]) + op.create_index("ix_appeals_status", "appeals", ["status"]) + + +def downgrade() -> None: + """Remove moderation tables and revert users table changes.""" + + # Drop appeals table + op.drop_index("ix_appeals_status", table_name="appeals") + op.drop_index("ix_appeals_user_id", table_name="appeals") + op.drop_index("ix_appeals_moderation_action_id", table_name="appeals") + op.drop_table("appeals") + + # Drop moderation_actions table + op.drop_index("ix_moderation_actions_report_id", table_name="moderation_actions") + op.drop_index("ix_moderation_actions_target", table_name="moderation_actions") + op.drop_index("ix_moderation_actions_target_user", table_name="moderation_actions") + op.drop_index("ix_moderation_actions_moderator_id", table_name="moderation_actions") + op.drop_table("moderation_actions") + + # Drop reports table + op.drop_index("ix_reports_status", table_name="reports") + op.drop_index("ix_reports_target", table_name="reports") + op.drop_index("ix_reports_reporter_id", table_name="reports") + op.drop_table("reports") + + # Remove columns from users table + op.drop_column("users", "suspended_until") + op.drop_column("users", "banned_until") + op.drop_column("users", "role") + + # Drop the user_role enum type + op.execute("DROP TYPE IF EXISTS user_role") + # Drop other enum types created + op.execute("DROP TYPE IF EXISTS report_target_type") + op.execute("DROP TYPE IF EXISTS report_reason_category") + op.execute("DROP TYPE IF EXISTS report_status") + op.execute("DROP TYPE IF EXISTS report_resolution") + op.execute("DROP TYPE IF EXISTS moderation_target_type") + op.execute("DROP TYPE IF EXISTS moderation_action_type") + op.execute("DROP TYPE IF EXISTS appeal_status")