Compare commits

..

5 Commits

8 changed files with 528 additions and 2 deletions
+1 -1
View File
@@ -8,7 +8,7 @@ from sqlalchemy.ext.asyncio import create_async_engine
from app.config import settings
from app.database import Base
from app.models import user, session, world, asset # noqa: F401
from app.models import user, session, world, asset, friendship, follow, block # noqa: F401
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
@@ -0,0 +1,136 @@
"""social graph: friendships, follows, blocks
Revision ID: 0002_social_graph
Revises: 0001_initial
Create Date: 2026-08-18 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "0002_social_graph"
down_revision: Union[str, None] = "0001_initial"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Create friendships, follows, and blocks tables."""
op.create_table(
"friendships",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("requester_id", sa.Integer(), nullable=False),
sa.Column("addressee_id", sa.Integer(), nullable=False),
sa.Column("status", sa.String(length=20), server_default="pending", 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), nullable=True),
sa.CheckConstraint(
"status IN ('pending', 'accepted')",
name="ck_friendships_status",
),
sa.CheckConstraint(
"requester_id <> addressee_id",
name="ck_friendships_no_self",
),
sa.ForeignKeyConstraint(
["addressee_id"],
["users.id"],
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["requester_id"],
["users.id"],
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"requester_id",
"addressee_id",
name="uq_friendships_requester_addressee",
),
)
op.create_index(op.f("ix_friendships_id"), "friendships", ["id"], unique=False)
op.create_index(op.f("ix_friendships_addressee_id"), "friendships", ["addressee_id"], unique=False)
op.create_index(op.f("ix_friendships_requester_id"), "friendships", ["requester_id"], unique=False)
op.create_table(
"follows",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("follower_id", sa.Integer(), nullable=False),
sa.Column("followed_id", sa.Integer(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.CheckConstraint(
"follower_id <> followed_id",
name="ck_follows_no_self",
),
sa.ForeignKeyConstraint(
["followed_id"],
["users.id"],
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["follower_id"],
["users.id"],
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"follower_id",
"followed_id",
name="uq_follows_follower_followed",
),
)
op.create_index(op.f("ix_follows_id"), "follows", ["id"], unique=False)
op.create_index(op.f("ix_follows_followed_id"), "follows", ["followed_id"], unique=False)
op.create_index(op.f("ix_follows_follower_id"), "follows", ["follower_id"], unique=False)
op.create_table(
"blocks",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("blocker_id", sa.Integer(), nullable=False),
sa.Column("blocked_id", sa.Integer(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.CheckConstraint(
"blocker_id <> blocked_id",
name="ck_blocks_no_self",
),
sa.ForeignKeyConstraint(
["blocked_id"],
["users.id"],
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["blocker_id"],
["users.id"],
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"blocker_id",
"blocked_id",
name="uq_blocks_blocker_blocked",
),
)
op.create_index(op.f("ix_blocks_id"), "blocks", ["id"], unique=False)
op.create_index(op.f("ix_blocks_blocked_id"), "blocks", ["blocked_id"], unique=False)
op.create_index(op.f("ix_blocks_blocker_id"), "blocks", ["blocker_id"], unique=False)
def downgrade() -> None:
"""Drop friendships, follows, and blocks tables."""
op.drop_index(op.f("ix_blocks_blocker_id"), table_name="blocks")
op.drop_index(op.f("ix_blocks_blocked_id"), table_name="blocks")
op.drop_index(op.f("ix_blocks_id"), table_name="blocks")
op.drop_table("blocks")
op.drop_index(op.f("ix_follows_follower_id"), table_name="follows")
op.drop_index(op.f("ix_follows_followed_id"), table_name="follows")
op.drop_index(op.f("ix_follows_id"), table_name="follows")
op.drop_table("follows")
op.drop_index(op.f("ix_friendships_requester_id"), table_name="friendships")
op.drop_index(op.f("ix_friendships_addressee_id"), table_name="friendships")
op.drop_index(op.f("ix_friendships_id"), table_name="friendships")
op.drop_table("friendships")
+13 -1
View File
@@ -2,5 +2,17 @@ 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.friendship import Friendship, FriendshipStatus
from app.models.follow import Follow
from app.models.block import Block
__all__ = ["User", "Session", "World", "Asset"]
__all__ = [
"User",
"Session",
"World",
"Asset",
"Friendship",
"FriendshipStatus",
"Follow",
"Block",
]
+48
View File
@@ -0,0 +1,48 @@
"""Block model: a hard deny that also hides content and prevents DMs."""
from sqlalchemy import CheckConstraint, Column, DateTime, ForeignKey, Integer, UniqueConstraint
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.database import Base
class Block(Base):
__tablename__ = "blocks"
__table_args__ = (
UniqueConstraint(
"blocker_id",
"blocked_id",
name="uq_blocks_pair",
),
CheckConstraint(
"blocker_id <> blocked_id",
name="ck_blocks_no_self",
),
)
id = Column(Integer, primary_key=True, index=True)
blocker_id = Column(
Integer,
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
blocked_id = Column(
Integer,
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
created_at = Column(DateTime(timezone=True), server_default=func.now())
blocker = relationship(
"User",
foreign_keys=[blocker_id],
back_populates="blocks_issued",
)
blocked = relationship(
"User",
foreign_keys=[blocked_id],
back_populates="blocks_received",
)
+48
View File
@@ -0,0 +1,48 @@
"""Follow model: a one-way relationship requiring no approval."""
from sqlalchemy import CheckConstraint, Column, DateTime, ForeignKey, Integer, UniqueConstraint
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.database import Base
class Follow(Base):
__tablename__ = "follows"
__table_args__ = (
UniqueConstraint(
"follower_id",
"followed_id",
name="uq_follows_pair",
),
CheckConstraint(
"follower_id <> followed_id",
name="ck_follows_no_self",
),
)
id = Column(Integer, primary_key=True, index=True)
follower_id = Column(
Integer,
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
followed_id = Column(
Integer,
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
created_at = Column(DateTime(timezone=True), server_default=func.now())
follower = relationship(
"User",
foreign_keys=[follower_id],
back_populates="following",
)
followed = relationship(
"User",
foreign_keys=[followed_id],
back_populates="followers",
)
+74
View File
@@ -0,0 +1,74 @@
"""Friendship model: a one-directional request that becomes mutual on accept."""
from sqlalchemy import (
CheckConstraint,
Column,
DateTime,
ForeignKey,
Integer,
String,
UniqueConstraint,
)
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.database import Base
class FriendshipStatus:
"""String values for the friendship.status column."""
PENDING = "pending"
ACCEPTED = "accepted"
class Friendship(Base):
__tablename__ = "friendships"
__table_args__ = (
UniqueConstraint(
"requester_id",
"addressee_id",
name="uq_friendships_pair",
),
CheckConstraint(
"requester_id <> addressee_id",
name="ck_friendships_no_self",
),
CheckConstraint(
"status IN ('pending', 'accepted')",
name="ck_friendships_status",
),
)
id = Column(Integer, primary_key=True, index=True)
requester_id = Column(
Integer,
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
addressee_id = Column(
Integer,
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
status = Column(
String(20),
nullable=False,
default=FriendshipStatus.PENDING,
server_default=FriendshipStatus.PENDING,
)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
requester = relationship(
"User",
foreign_keys=[requester_id],
back_populates="sent_friend_requests",
)
addressee = relationship(
"User",
foreign_keys=[addressee_id],
back_populates="received_friend_requests",
)
+38
View File
@@ -19,3 +19,41 @@ class User(Base):
sessions = relationship("Session", back_populates="user", cascade="all, delete-orphan")
worlds = relationship("World", back_populates="owner")
assets = relationship("Asset", back_populates="owner")
# Social graph
sent_friend_requests = relationship(
"Friendship",
foreign_keys="Friendship.requester_id",
back_populates="requester",
cascade="all, delete-orphan",
)
received_friend_requests = relationship(
"Friendship",
foreign_keys="Friendship.addressee_id",
back_populates="addressee",
cascade="all, delete-orphan",
)
following = relationship(
"Follow",
foreign_keys="Follow.follower_id",
back_populates="follower",
cascade="all, delete-orphan",
)
followers = relationship(
"Follow",
foreign_keys="Follow.followed_id",
back_populates="followed",
cascade="all, delete-orphan",
)
blocks_issued = relationship(
"Block",
foreign_keys="Block.blocker_id",
back_populates="blocker",
cascade="all, delete-orphan",
)
blocks_received = relationship(
"Block",
foreign_keys="Block.blocked_id",
back_populates="blocked",
cascade="all, delete-orphan",
)
+170
View File
@@ -0,0 +1,170 @@
# Social — Friends, Chat & Groups — Plan
> **Status:** Planned
> **Owner:** Platform team
> **Feature ID:** `social`
> **Godot version:** 4.6.2 stable
> **Backend:** FastAPI + SQLAlchemy (async) + PostgreSQL + Redis
## 1. Overview
This document breaks the **Social** feature into user stories and right-sized
implementation tasks. The feature delivers:
- **Friends & following** — a friend request/accept model plus a one-way follow
relationship, with block lists.
- **Presence & status** — online/offline, custom status message, last-seen.
- **Chat** — direct messages (DM) and in-experience (world/room) chat with
**first-class safety**: server-side text filtering, report-message, and rate
limiting.
- **Groups** — named groups with roles (owner/admin/member) and a simple group
page.
- **Public profiles** — avatar, bio, favorite experiences, and a friends list
that respects privacy settings.
## 2. Why chat safety is the differentiator
The platform is explicitly meant to pull adults away from a youth-oriented
competitor. Chat is the highest-risk surface: it is real-time, user-generated,
and where abuse happens fastest. Safety is therefore treated as a **blocking
requirement**, not an add-on:
- Every message passes a **server-side text filter** before it is stored or
delivered. The client filter is cosmetic only; the server is authoritative.
- Every message can be **reported**; reports flow into a moderation queue with
actor + reason + audit trail (mirroring `content_moderation.md`).
- Sending is **rate limited** per user (Redis token bucket) to stop flooding and
harassment-by-volume.
- Report and block are always reachable from a message context in the UI.
## 3. Existing constraints (do not re-build)
- **Auth:** JWT bearer tokens; `get_current_user` / `CurrentUser` dependency in
`app/dependencies.py`. All social endpoints reuse this.
- **DB:** async SQLAlchemy + Alembic; migration `0001_initial` is the head.
- **Redis:** configured (`REDIS_URL`) but unused so far; adopted here for
presence and rate limiting.
- **Models:** `User`, `World` (owner_id), `Asset` (owner_id, is_public). Avatars
and favorite-experience references point at existing rows.
- **Client:** Godot 4.6.2, minimal `main_menu.gd`; no social UI yet.
## 4. Key design decisions
| Concern | Decision |
|---|---|
| Message transport | REST for send/history; **WebSocket** gateway for real-time delivery + presence (matches `networking.md`'s "WebSocket as secondary transport" for chat) |
| Presence store | Redis (TTL heartbeat) + persisted `last_seen_at` on the user record |
| Text filtering | Pluggable `TextFilter` interface with a deterministic default (profanity list + normalization) so it runs without external services; third-party hook later |
| Rate limiting | Redis token-bucket dependency, reusable across all social endpoints |
| Reports | `message_reports` table + moderation endpoints (list/review/resolve) with audit fields |
| Friends vs. follow | Friends = mutual (request→accept). Follow = one-way (no approval). Block = hard deny that also hides content and prevents DMs |
| Privacy | Per-user settings JSON (`profile_visibility`, `friends_list_visibility`, `favorites_visibility`) enforced server-side on every read |
| Groups | `groups` + `group_members` (role: owner/admin/member) + group page aggregate endpoint |
| Real-time | One WebSocket endpoint multiplexing presence, DM, and world-chat messages via Redis pub/sub |
## 5. User stories
### US-1 Friends & following
As a player, I can send a friend request, accept/decline incoming requests,
unfriend, follow/unfollow other players, and block users so they can't interact
with me.
**Acceptance criteria**
- Friend requests are one-directional until accepted; accepted friendships are mutual.
- Follow is one-way and requires no approval.
- Block prevents DMs, hides the blocked user's profile/friends interactions, and
removes any existing friendship/follow between the two.
- Duplicate/self requests are rejected idempotently.
### US-2 Presence & status
As a player, I can see whether my friends are online, their custom status, and
when they were last seen.
**Acceptance criteria**
- Online/offline is derived from a server-side heartbeat (Redis TTL), not client claims.
- `last_seen_at` persists and is visible (subject to privacy settings).
- Custom status text is optional and length-limited.
### US-3 Public profiles
As a player, I can view a public profile page showing avatar, bio, favorite
experiences, and a friends list that respects the owner's privacy settings.
**Acceptance criteria**
- Avatar and bio are editable by the owner only.
- Favorite experiences reference existing `World` rows.
- A viewer sees only the sections the owner's privacy settings allow; blocked
users see a restricted view.
### US-4 Chat (DM + in-experience) with safety
As a player, I can send direct messages and chat inside a world/room. Messages
are filtered, rate limited, and reportable.
**Acceptance criteria**
- Server filters message text before storing/delivering; filtered content is either
rejected (blocking) or redacted (configurable per filter policy).
- Send rate is limited per user (token bucket); excess sends get `429`.
- Every message has a stable id, author, timestamp, and conversation/world scope.
- Any message can be reported with a reason; reports are stored and reviewable.
- Real-time delivery over WebSocket with REST fallback for history.
### US-5 Groups
As a player, I can create a group, join/leave groups, and (as owner/admin)
manage roles and view a simple group page.
**Acceptance criteria**
- Each group has exactly one owner and any number of admins/members.
- Role transitions are authorized (only owner can transfer ownership or promote/demote admins).
- Group page lists name, description, member list with roles, and member count.
### US-6 Godot client
As a player, I can access friends, presence, chat, profiles, and groups from the
game UI.
**Acceptance criteria**
- An API client handles JWT auth, HTTP, and WebSocket for all social calls.
- Dedicated screens/panels: friends+presence, DM & world chat (with report),
profile page, group page.
- Client-side filtering is cosmetic; server responses remain authoritative.
## 6. Task breakdown
Right-sized to 1–3 files / one coherent piece of functionality each. Task IDs
are sequential for dependency chaining.
| # | Task | Story | Depends on |
|---|------|-------|-----------|
| 1 | Friends/follow/block models + Alembic migration | US-1 | — |
| 2 | Friends & follow API router | US-1 | 1 |
| 3 | Presence & status model + endpoints (Redis heartbeat) | US-2 | — |
| 4 | Profile model + migration + own-profile endpoints | US-3 | 1 |
| 5 | Public profile view + favorites + privacy enforcement | US-3 | 2, 4 |
| 6 | Chat data models + migration (conversations, messages, reports) | US-4 | — |
| 7 | Text filtering service + profanity config | US-4 | — |
| 8 | Redis rate-limiting dependency | US-4 | — |
| 9 | DM chat send/list endpoints (filter + rate limit) | US-4 | 6, 7, 8 |
| 10 | Report-message endpoint + moderation queue | US-4 | 6, 9 |
| 11 | In-experience (world/room) chat endpoints | US-4 | 9 |
| 12 | Real-time WebSocket gateway (presence + DM + world chat) | US-4 | 3, 9, 11 |
| 13 | Group + membership models + migration | US-5 | — |
| 14 | Groups API (CRUD, join/leave, roles, group page) | US-5 | 13 |
| 15 | Godot API client (auth + HTTP + WebSocket) | US-6 | 2 |
| 16 | Godot friends & presence UI | US-6 | 3, 15 |
| 17 | Godot chat UI (DM + world chat + report) | US-6 | 12, 15 |
| 18 | Godot profile page UI | US-6 | 5, 15 |
| 19 | Godot groups UI + group page | US-6 | 14, 15 |
## 7. Phasing
- **Phase 1 (data + core APIs):** tasks 1–5 (friends, presence, profiles).
- **Phase 2 (chat safety — the differentiator):** tasks 6–12 (chat + safety +
realtime). This is the critical path and highest priority.
- **Phase 3 (groups):** tasks 13–14.
- **Phase 4 (client):** tasks 15–19, each landing after its backend dependency.
## 8. Out of scope for this feature
- Age verification / maturity gating (covered by `content_moderation.md`).
- Voice chat, media/image messages, message editing/deletion history.
- Third-party profanity/toxicity vendors (the `TextFilter` interface leaves a
hook, but the default is deterministic and dependency-free).
- Mobile push notifications.