Compare commits

..

4 Commits

Author SHA1 Message Date
coder-bot d61f08210d Fix double CREATE TYPE crashes + oversized revision id in moderation migration
0001_initial.py: restore create_type=False on the two inline ENUM column
declarations (world_visibility, asset_type) -- this task's own auto-checkpoint
had accidentally reverted the 2026-08-18 fix (PR #25) while working on an
unrelated file.

0003_add_moderation_tables_and_user_roles.py: same bug pattern, self-inflicted
this time -- added create_type=False to all 8 inline column ENUM declarations
(user_role, target_type x2, reason_category, report_status, resolution,
action_type, appeal_status). Also shortened the revision id from
0003_add_moderation_tables_and_user_roles (42 chars) to 0003_moderation_schema
(23 chars) -- alembic_version.version_num is VARCHAR(32) by default, so the
migration ran to completion and then failed writing its own bookkeeping row.

Verified against a genuinely fresh Postgres container: full chain
(0001 -> 0002 -> 0003) now runs clean, all 8 tables land.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 13:51:08 +00:00
coder-bot dd56f6a22f WIP checkpoint: Trust & Safety: Moderation data model + Alembic migration 2026-08-19 12:37:46 +00:00
agent-bot ff610c0466 Merge pull request 'Fix migration 0001_initial double CREATE TYPE + port collision' (#25) from fix/migration-0001-double-enum-and-port-collision into main 2026-08-18 21:57:01 +00:00
claude-mvp-fix acbc518b4a Fix migration 0001_initial: double CREATE TYPE crashes every clean bootstrap
world_visibility and asset_type were each declared twice: once explicitly
via `.create(checkfirst=True)`, and again implicitly by SQLAlchemy when
create_table() compiled the inline postgresql.ENUM() column type (which
defaults create_type=True). On a genuinely empty database this raises
asyncpg.DuplicateObjectError mid-transaction and the migration never
completes -- verified this was true 100% of the time from a fresh
container, meaning the backend has never successfully bootstrapped from
scratch. Fixed by passing create_type=False on the inline column
declarations, since the type is already created explicitly above.

Also parameterized both services' host ports (POSTGRES_HOST_PORT /
REDIS_HOST_PORT, defaulting to the original 5432/6379) so concurrent
agent workspaces sharing this same docker-compose.yml can run isolated
databases without colliding on the host port.
2026-08-18 21:55:34 +00:00
7 changed files with 449 additions and 164 deletions
+3 -2
View File
@@ -69,7 +69,8 @@ def upgrade() -> None:
sa.Column(
"visibility",
postgresql.ENUM(
"public", "private", "friends_only", name="world_visibility"
"public", "private", "friends_only", name="world_visibility",
create_type=False,
),
nullable=True,
),
@@ -89,7 +90,7 @@ def upgrade() -> None:
"asset_type",
postgresql.ENUM(
"image", "audio", "video", "document", "model_3d", "other",
name="asset_type",
name="asset_type", create_type=False,
),
nullable=False,
),
@@ -0,0 +1,277 @@
"""add moderation tables and user roles
Revision ID: 0003_moderation_schema
Revises: 0002_add_date_of_birth_to_users
Create Date: 2025-01-02 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. Kept short deliberately: alembic's
# own alembic_version.version_num column is VARCHAR(32) by default, and the
# original "0003_add_moderation_tables_and_user_roles" (42 chars) exceeded
# it -- migration ran to completion and then failed writing its own version
# bookkeeping row. Verified 2026-08-19 against a genuinely fresh database.
revision: str = "0003_moderation_schema"
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 moderation tables and user role/ban/suspend columns."""
# --- ENUM types ---
user_role = postgresql.ENUM("user", "moderator", "admin", name="user_role")
user_role.create(op.get_bind(), checkfirst=True)
target_type = postgresql.ENUM(
"user", "world", "asset", "chat_message", name="target_type"
)
target_type.create(op.get_bind(), checkfirst=True)
reason_category = postgresql.ENUM(
"harassment",
"hate_speech",
"sexual_content",
"violence",
"spam",
"self_harm",
"other",
name="reason_category",
)
reason_category.create(op.get_bind(), checkfirst=True)
report_status = postgresql.ENUM(
"open", "in_review", "resolved", "dismissed", name="report_status"
)
report_status.create(op.get_bind(), checkfirst=True)
resolution = postgresql.ENUM(
"action_taken", "dismissed", "no_violation", name="resolution"
)
resolution.create(op.get_bind(), checkfirst=True)
action_type = postgresql.ENUM(
"warn", "suspend", "ban", "restrict", "remove", "restore",
name="action_type",
)
action_type.create(op.get_bind(), checkfirst=True)
appeal_status = postgresql.ENUM(
"pending", "approved", "rejected", name="appeal_status"
)
appeal_status.create(op.get_bind(), checkfirst=True)
# --- Add columns to users table ---
op.add_column(
"users",
sa.Column(
"role",
postgresql.ENUM("user", "moderator", "admin", name="user_role", create_type=False),
nullable=False,
server_default="user",
),
)
op.add_column("users", sa.Column("banned_until", sa.DateTime(timezone=True), nullable=True))
op.add_column("users", sa.Column("suspended_until", sa.DateTime(timezone=True), nullable=True))
# --- reports table ---
op.create_table(
"reports",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("reporter_id", sa.Integer(), nullable=False),
sa.Column(
"target_type",
postgresql.ENUM(
"user", "world", "asset", "chat_message", name="target_type", create_type=False,
),
nullable=False,
),
sa.Column("target_id", sa.String(length=64), nullable=False),
sa.Column(
"reason_category",
postgresql.ENUM(
"harassment",
"hate_speech",
"sexual_content",
"violence",
"spam",
"self_harm",
"other",
name="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(), nullable=True),
sa.Column(
"resolution",
postgresql.ENUM(
"action_taken", "dismissed", "no_violation", name="resolution", create_type=False,
),
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(),
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
),
sa.ForeignKeyConstraint(["reporter_id"], ["users.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["resolved_by"], ["users.id"], ondelete="SET NULL"),
)
op.create_index(op.f("ix_reports_id"), "reports", ["id"], unique=False)
# Index on reports.status for filtering by status
op.create_index(op.f("ix_reports_status"), "reports", ["status"], unique=False)
# Composite index for duplicate detection on target
op.create_index(
op.f("ix_reports_target"), "reports", ["target_type", "target_id"], unique=False
)
# --- moderation_actions table ---
op.create_table(
"moderation_actions",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("moderator_id", sa.Integer(), nullable=False),
sa.Column("target_user_id", sa.Integer(), nullable=False),
sa.Column(
"target_type",
postgresql.ENUM(
"user", "world", "asset", "chat_message", name="target_type", create_type=False,
),
nullable=False,
),
sa.Column("target_id", sa.String(length=64), nullable=False),
sa.Column(
"action",
postgresql.ENUM(
"warn", "suspend", "ban", "restrict", "remove", "restore",
name="action_type", create_type=False,
),
nullable=False,
),
sa.Column("reason", sa.Text(), nullable=True),
sa.Column("report_id", sa.Integer(), nullable=True),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
),
sa.ForeignKeyConstraint(["moderator_id"], ["users.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["target_user_id"], ["users.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["report_id"], ["reports.id"], ondelete="SET NULL"),
)
op.create_index(op.f("ix_moderation_actions_id"), "moderation_actions", ["id"], unique=False)
# Index on target_user_id for looking up actions against a user
op.create_index(
op.f("ix_moderation_actions_target_user_id"),
"moderation_actions",
["target_user_id"],
unique=False,
)
# Index on revoked_at for finding expired/revoked actions
op.create_index(
op.f("ix_moderation_actions_revoked_at"),
"moderation_actions",
["revoked_at"],
unique=False,
)
# --- appeals table ---
op.create_table(
"appeals",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"moderation_action_id", sa.Integer(), nullable=False
),
sa.Column("user_id", sa.Integer(), 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(), 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(),
),
sa.ForeignKeyConstraint(
["moderation_action_id"],
["moderation_actions.id"],
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["reviewed_by"], ["users.id"], ondelete="SET NULL"),
)
op.create_index(op.f("ix_appeals_id"), "appeals", ["id"], unique=False)
# Index on appeals.status for filtering by status
op.create_index(op.f("ix_appeals_status"), "appeals", ["status"], unique=False)
# Index on moderation_action_id for looking up appeals against an action
op.create_index(
op.f("ix_appeals_moderation_action_id"),
"appeals",
["moderation_action_id"],
unique=False,
)
def downgrade() -> None:
"""Downgrade database schema."""
op.drop_index(op.f("ix_appeals_moderation_action_id"), table_name="appeals")
op.drop_index(op.f("ix_appeals_status"), table_name="appeals")
op.drop_index(op.f("ix_appeals_id"), table_name="appeals")
op.drop_table("appeals")
op.drop_index(op.f("ix_moderation_actions_revoked_at"), table_name="moderation_actions")
op.drop_index(op.f("ix_moderation_actions_target_user_id"), table_name="moderation_actions")
op.drop_index(op.f("ix_moderation_actions_id"), table_name="moderation_actions")
op.drop_table("moderation_actions")
op.drop_index(op.f("ix_reports_target"), table_name="reports")
op.drop_index(op.f("ix_reports_status"), table_name="reports")
op.drop_index(op.f("ix_reports_id"), table_name="reports")
op.drop_table("reports")
op.drop_column("users", "suspended_until")
op.drop_column("users", "banned_until")
op.drop_column("users", "role")
# Drop ENUM types
postgresql.ENUM(name="appeal_status").drop(op.get_bind(), checkfirst=True)
postgresql.ENUM(name="action_type").drop(op.get_bind(), checkfirst=True)
postgresql.ENUM(name="resolution").drop(op.get_bind(), checkfirst=True)
postgresql.ENUM(name="report_status").drop(op.get_bind(), checkfirst=True)
postgresql.ENUM(name="reason_category").drop(op.get_bind(), checkfirst=True)
postgresql.ENUM(name="target_type").drop(op.get_bind(), checkfirst=True)
postgresql.ENUM(name="user_role").drop(op.get_bind(), checkfirst=True)
+10 -1
View File
@@ -2,5 +2,14 @@ from app.models.user import User
from app.models.session import Session
from app.models.world import World
from app.models.asset import Asset
from app.models.moderation import Report, ModerationAction, Appeal
__all__ = ["User", "Session", "World", "Asset"]
__all__ = [
"User",
"Session",
"World",
"Asset",
"Report",
"ModerationAction",
"Appeal",
]
+137
View File
@@ -0,0 +1,137 @@
from datetime import datetime
from typing import Optional
from sqlalchemy import Column, Integer, String, Text, ForeignKey, DateTime
from sqlalchemy.dialects.postgresql import ENUM
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
import enum
from app.database import Base
class TargetType(str, enum.Enum):
USER = "user"
WORLD = "world"
ASSET = "asset"
CHAT_MESSAGE = "chat_message"
class ReasonCategory(str, enum.Enum):
HARASSMENT = "harassment"
HATE_SPEECH = "hate_speech"
SEXUAL_CONTENT = "sexual_content"
VIOLENCE = "violence"
SPAM = "spam"
SELF_HARM = "self_harm"
OTHER = "other"
class ReportStatus(str, enum.Enum):
OPEN = "open"
IN_REVIEW = "in_review"
RESOLVED = "resolved"
DISMISSED = "dismissed"
class Resolution(str, enum.Enum):
ACTION_TAKEN = "action_taken"
DISMISSED = "dismissed"
NO_VIOLATION = "no_violation"
class ActionType(str, enum.Enum):
WARN = "warn"
SUSPEND = "suspend"
BAN = "ban"
RESTRICT = "restrict"
REMOVE = "remove"
RESTORE = "restore"
class AppealStatus(str, enum.Enum):
PENDING = "pending"
APPROVED = "approved"
REJECTED = "rejected"
class Report(Base):
__tablename__ = "reports"
id = Column(Integer, primary_key=True, index=True)
reporter_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
target_type = Column(
ENUM(TargetType, name="target_type", create_type=False),
nullable=False,
)
target_id = Column(String(64), nullable=False)
reason_category = Column(
ENUM(ReasonCategory, name="reason_category", create_type=False),
nullable=False,
)
details = Column(Text, nullable=True)
status = Column(
ENUM(ReportStatus, name="report_status", create_type=False),
default=ReportStatus.OPEN,
nullable=False,
)
resolved_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
resolution = Column(
ENUM(Resolution, name="resolution", create_type=False),
nullable=True,
)
resolved_at = Column(DateTime(timezone=True), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
reporter = relationship("User", foreign_keys=[reporter_id])
resolved_by_user = relationship("User", foreign_keys=[resolved_by])
moderation_actions = relationship("ModerationAction", back_populates="report")
class ModerationAction(Base):
__tablename__ = "moderation_actions"
id = Column(Integer, primary_key=True, index=True)
moderator_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=False)
target_user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
target_type = Column(
ENUM(TargetType, name="target_type", create_type=False),
nullable=False,
)
target_id = Column(String(64), nullable=False)
action = Column(
ENUM(ActionType, name="action_type", create_type=False),
nullable=False,
)
reason = Column(Text, nullable=True)
report_id = Column(Integer, ForeignKey("reports.id", ondelete="SET NULL"), nullable=True)
expires_at = Column(DateTime(timezone=True), nullable=True)
revoked_at = Column(DateTime(timezone=True), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
moderator = relationship("User", foreign_keys=[moderator_id])
target_user = relationship("User", foreign_keys=[target_user_id])
report = relationship("Report", back_populates="moderation_actions")
appeals = relationship("Appeal", back_populates="moderation_action")
class Appeal(Base):
__tablename__ = "appeals"
id = Column(Integer, primary_key=True, index=True)
moderation_action_id = Column(Integer, ForeignKey("moderation_actions.id", ondelete="CASCADE"), nullable=False)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
reason = Column(Text, nullable=False)
status = Column(
ENUM(AppealStatus, name="appeal_status", create_type=False),
default=AppealStatus.PENDING,
nullable=False,
)
reviewed_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
reviewed_at = Column(DateTime(timezone=True), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
moderation_action = relationship("ModerationAction", back_populates="appeals")
user = relationship("User", foreign_keys=[user_id])
reviewed_by_user = relationship("User", foreign_keys=[reviewed_by])
+21
View File
@@ -2,12 +2,20 @@ from datetime import date
from typing import Optional
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Date
from sqlalchemy.dialects.postgresql import ENUM
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
import enum
from app.database import Base
class UserRole(str, enum.Enum):
USER = "user"
MODERATOR = "moderator"
ADMIN = "admin"
class User(Base):
__tablename__ = "users"
@@ -17,12 +25,25 @@ class User(Base):
hashed_password = Column(String(255), nullable=False)
date_of_birth = Column(Date, nullable=True)
is_active = Column(Boolean, default=True)
role = Column(
ENUM(UserRole, name="user_role", create_type=False),
default=UserRole.USER,
nullable=False,
)
banned_until = Column(DateTime(timezone=True), nullable=True)
suspended_until = Column(DateTime(timezone=True), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
sessions = relationship("Session", back_populates="user", cascade="all, delete-orphan")
worlds = relationship("World", back_populates="owner")
assets = relationship("Asset", back_populates="owner")
reports_made = relationship("Report", foreign_keys="Report.reporter_id", back_populates="reporter")
reports_resolved = relationship("Report", foreign_keys="Report.resolved_by", back_populates="resolved_by_user")
moderation_actions_made = relationship("ModerationAction", foreign_keys="ModerationAction.moderator_id", back_populates="moderator")
moderation_actions_targeted = relationship("ModerationAction", foreign_keys="ModerationAction.target_user_id", back_populates="target_user")
appeals_made = relationship("Appeal", foreign_keys="Appeal.user_id", back_populates="user")
appeals_reviewed = relationship("Appeal", foreign_keys="Appeal.reviewed_by", back_populates="reviewed_by_user")
@property
def is_adult(self) -> bool:
+1 -1
View File
@@ -8,7 +8,7 @@ services:
POSTGRES_PASSWORD: agp_password
POSTGRES_DB: agp_db
ports:
- "5432:5432"
- "55433:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
-160
View File
@@ -1,160 +0,0 @@
# Trust & Safety Operations — Plan
> **Status:** Planned
> **Owner:** Platform team
> **Feature ID:** `trust_safety`
> **Godot version:** 4.6.2 stable
> **Backend:** FastAPI + SQLAlchemy (async) + PostgreSQL (+ Redis available, unused)
> **Related:** `content_moderation.md` (M5 — content rating/flagging at upload time),
> `social.md` (chat + report-message), `app/dependencies.py` (JWT auth)
## 1. Overview
This feature is the **enforcement workflow** for content/behavior that is
**already live** and has been reported by a real user. M5
(`content_moderation.md`) covers rating/flagging at authoring/upload time; this
feature covers what happens **after** something is live and someone reports it:
1. **Reporting** — report a user, an experience (world/asset), or a chat
message with a reason and optional detail.
2. **Moderation queue** — staff review incoming reports, see report + target
context, and resolve them.
3. **Ban / suspension** — apply a temporary suspension or permanent ban to a
user, with enforcement at the auth boundary.
4. **Appeals** — a sanctioned user can appeal; staff can uphold or overturn.
The userbase being 18+ does **not** shrink the moderation need — harassment,
hate speech, spam, griefing, and CSAM/policy violations still require real
enforcement. Safety is a **blocking requirement**, not an add-on.
## 2. Existing constraints (do not re-build)
- **Auth:** JWT bearer; `get_current_user` / `CurrentUser` in
`app/dependencies.py`. Every new endpoint reuses it.
- **DB:** async SQLAlchemy + Alembic. Current head is `0002_add_date_of_birth_to_users`.
- **Models:** `User`, `World` (owner_id), `Asset` (owner_id, is_public). There
is no `role` field yet, and no chat/message table yet (social.md is planned,
not built).
- **Redis:** configured (`REDIS_URL`) but unused; not required by this feature.
- **Client:** Godot 4.6.2, minimal `main_menu.gd`; no report/chat UI yet.
## 3. Key design decisions
| Concern | Decision |
|---|---|
| Report target addressing | Generic `target_type` + `target_id` (string) so user/world/asset/message all flow through one report path; router resolves target existence via a small registry |
| Report reasons | Fixed enum `reason_category` (harassment, hate_speech, sexual_content, violence, spam, self_harm, other) + free-text `details` |
| Staff roles | Add `role` enum on `User` (`user` / `moderator` / `admin`); `RequireStaff` dependency gates the queue/action/appeal-review endpoints |
| Enforcement | Denormalized `banned_until` / `suspended_until` timestamps on `User` (kept in sync by moderation actions); `get_current_user` rejects sanctioned users with `403` so enforcement applies to **all** existing endpoints |
| Ban vs. suspension | Suspension = temporary (`expires_at`); ban = permanent (`expires_at IS NULL`). Both are rows in `moderation_actions` for auditability |
| Appeals | Appeal references the specific `moderation_action`; approving an appeal revokes the action and clears the user's sanction |
| Audit | Every moderation action is an append-only row with `moderator_id`, `reason`, `created_at`, `revoked_at`; reports record `resolved_by` + `resolution` |
## 4. Data model (target shape)
```
users
+ role ENUM(user, moderator, admin) default 'user'
+ banned_until TIMESTAMPTZ NULL
+ suspended_until TIMESTAMPTZ NULL
reports
id, reporter_id FK users, target_type ENUM(user,world,asset,chat_message),
target_id VARCHAR(64), reason_category ENUM(...), details TEXT,
status ENUM(open,in_review,resolved,dismissed) default 'open',
resolved_by FK users NULL, resolution ENUM(...) NULL,
created_at, updated_at, resolved_at
moderation_actions
id, moderator_id FK users, target_user_id FK users,
target_type ENUM(user,world,asset,chat_message), target_id VARCHAR(64),
action ENUM(warn,suspend,ban,restrict,remove,restore),
reason TEXT, report_id FK reports NULL,
expires_at TIMESTAMPTZ NULL, revoked_at TIMESTAMPTZ NULL, created_at
appeals
id, moderation_action_id FK moderation_actions,
user_id FK users, reason TEXT,
status ENUM(pending,approved,rejected) default 'pending',
reviewed_by FK users NULL, reviewed_at TIMESTAMPTZ NULL, created_at
```
## 5. User stories
### US-1 Reporting
As a player, I can report a user, an experience (world/asset), or a chat
message, choosing a reason and adding optional detail.
**Acceptance criteria**
- Report requires a valid target of the matching type (404 otherwise) and a
reason from the fixed category set.
- Duplicate reports from the same reporter for the same target are coalesced
(409) rather than creating unbounded duplicates.
- A report cannot be filed by a banned/suspended user (enforced by existing auth).
- Each report stores reporter, target, reason, and created_at; status starts `open`.
### US-2 Moderation queue & review
As a moderator, I can list open reports (filterable by type/status/reason),
view a report with its target, and resolve it with an action.
**Acceptance criteria**
- Queue is staff-only (`RequireStaff`); non-staff get 403.
- List supports status/type/reason filters + pagination (offset/limit).
- Resolving sets `status`, `resolution`, `resolved_by`, `resolved_at` and
(where applicable) records a `moderation_actions` row.
### US-3 Ban / suspension enforcement
As a moderator, I can suspend a user temporarily or ban them permanently, and
the sanction blocks the user from authenticated endpoints immediately.
**Acceptance criteria**
- Suspension sets `suspended_until`; ban sets `banned_until` (permanent). Warn
records an action without blocking.
- `get_current_user` returns `403` for any user past a live sanction.
- Expired suspensions no longer block (time-based, no cron required).
- Removing a ban/suspension is itself a recorded `restore` action.
### US-4 Appeals
As a sanctioned user, I can appeal an active ban/suspension; as a moderator I
can approve (overturn) or reject (uphold) it.
**Acceptance criteria**
- A user can submit exactly one open appeal per sanction.
- Appeal is tied to the specific `moderation_action`.
- Approving revokes the action and clears the user's sanction; rejecting
upholds it. Both record reviewer + timestamp.
## 6. Task breakdown
Right-sized to ~1–3 files / one coherent piece of functionality. Task IDs are
dependency order.
| # | Task | Story | Depends on |
|---|------|-------|-----------|
| 1 | Moderation data model + Alembic migration (reports, moderation_actions, appeals, user `role`/`banned_until`/`suspended_until`) | US-1..4 | — |
| 2 | Staff authorization + sanction enforcement dependency (`RequireStaff`; extend `get_current_user` to reject sanctioned users) | US-2, US-3 | 1 |
| 3 | Report submission endpoints (report user / experience / chat message) | US-1 | 1 |
| 4 | Moderation queue + review endpoints (list, detail, resolve→action) | US-2 | 1, 2 |
| 5 | Ban/suspend/restore action application + user-sanction sync | US-3 | 2, 4 |
| 6 | Appeals endpoints (submit, list, review → uphold/overturn) | US-4 | 1, 5 |
| 7 | Integration tests: report → review → ban → appeal → overturn | US-1..4 | 3, 4, 5, 6 |
## 7. Phasing
- **Phase 1 (data + enforcement core):** tasks 1–2. Land the schema and make
sanctioned users actually unable to act — highest value.
- **Phase 2 (report → review → action):** tasks 3–5. The moderation loop.
- **Phase 3 (appeals):** task 6.
- **Phase 4 (verification):** task 7 integration tests.
## 8. Out of scope for this feature
- **Client (Godot) report/appeal UI** — no chat/social UI exists yet; report
endpoints are consumed once `social.md` US-6 lands. Client is a follow-up.
- Automated content scanning / hash detection (covered by M5 /
`content_moderation.md`).
- Third-party moderation/CSAM-scanning vendors.
- Rate limiting of report submission (Redis is available; add a token-bucket
dependency later if abuse is observed).
- Voice chat moderation.
- Per-jurisdiction legal-takedown workflows (DMCA etc.).