"""User profile: bio, avatar reference, privacy settings, favorite worlds. This module is models + migration only -- the public profile view endpoint (``GET /api/profile/{user_id}``, which will need to interpret ``privacy_json`` against the viewer's relationship to the owner) belongs to a sibling task that depends on this one. This task only owns the authenticated-owner read/write endpoints (``/api/profile/me`` and its ``favorites`` sub-resource). Design choices: - ``profiles.user_id`` is both the primary key and the FK to ``users.id`` (``ondelete="CASCADE"``) -- exactly one profile row per user, following the same one-row-per-user shape as ``AvatarLoadout`` (app/models/avatar_loadout.py). The row is created lazily (upserted) on first ``PUT /api/profile/me`` rather than at registration time, again mirroring ``AvatarLoadout``. - ``avatar_asset_id`` -> ``assets.id`` with ``ondelete="SET NULL"``. A profile's chosen "avatar picture" asset is a soft reference: if the underlying asset is later deleted the profile shouldn't be destroyed or left pointing at a dangling id, it should just fall back to "no avatar picture set" (nullable). The API layer additionally validates on write that the referenced asset actually exists (existence is not enforced by the FK alone at the API layer, since the FK only stops inserts of a nonexistent id -- it doesn't tell the caller *why* their PUT failed). - ``bio`` is ``Text`` with the 500-char cap enforced at the API/schema layer (Pydantic ``max_length``), matching how ``StatusUpdate.status_message`` in presence.py enforces its length cap in the router rather than the schema. - ``privacy_json`` is JSONB (project convention for document-shaped fields -- see avatar_loadout.py, world_content.py) holding exactly three keys, each a ``ProfileVisibility`` enum value (stored as its string value in JSON, not a Postgres ENUM type -- there's no need for a DB-level enum since this is a small, entirely API-validated document, and a plain JSONB column is easier to extend with more privacy keys later without a migration): - ``profile_visibility``: who can view the profile at all. - ``friends_list_visibility``: who can see the user's friends list. - ``favorites_visibility``: who can see the user's favorited worlds. Each value is one of "public", "friends", "private". Defaults (applied when a profile is first created) are all "public" -- the least surprising default for a new social feature, matching the "everything visible unless you lock it down" posture other social features here take by default (e.g. presence/status has no privacy gate at all yet). - Favorites use a join table (``profile_favorites``: ``user_id`` + ``world_id``, composite PK) rather than a JSON array column. A plain FK join table gets referential integrity for free (a favorited world that's later deleted disappears from favorites automatically via ON DELETE CASCADE, matching how ``World.assets``/``World.rooms`` cascade), lets Postgres enforce "no duplicate favorite" via the composite PK instead of API-layer deduplication, and keeps "does this world still exist" as a DB invariant rather than something every reader has to re-check against a JSON blob. The tradeoff (an extra table/join vs. a single JSON column) is worth it here because favorites are a list of foreign-key references, not a free-form document -- the exact case join tables suit better than JSONB. """ import enum from sqlalchemy import Column, Integer, ForeignKey, Text, DateTime from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.orm import relationship from sqlalchemy.sql import func from app.database import Base class ProfileVisibility(str, enum.Enum): PUBLIC = "public" FRIENDS = "friends" PRIVATE = "private" # Applied when a profile is created for the first time (lazy upsert on the # first PUT /api/profile/me). Keys match the three privacy_json fields # documented above. DEFAULT_PRIVACY: dict = { "profile_visibility": ProfileVisibility.PUBLIC.value, "friends_list_visibility": ProfileVisibility.PUBLIC.value, "favorites_visibility": ProfileVisibility.PUBLIC.value, } BIO_MAX_LEN = 500 class Profile(Base): """One row per user: bio, avatar picture reference, privacy settings.""" __tablename__ = "profiles" user_id = Column( Integer, ForeignKey("users.id", ondelete="CASCADE"), primary_key=True, ) avatar_asset_id = Column( Integer, ForeignKey("assets.id", ondelete="SET NULL"), nullable=True, ) bio = Column(Text, nullable=True) privacy_json = Column(JSONB, nullable=False, server_default="{}") created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False) user = relationship("User", back_populates="profile") avatar_asset = relationship("Asset") # Deliberately no ORM relationship to ProfileFavorite here: profiles and # profile_favorites both independently FK users.id (no direct FK between # the two tables), and the router only ever needs to query/replace a # user's favorites by user_id directly -- not traverse it off a loaded # Profile object. Keeping it a plain queryable table avoids a # hand-spelled, easy-to-get-wrong primaryjoin for no real benefit. class ProfileFavorite(Base): """A user's favorited world. Composite PK -- see module docstring for why this is a join table rather than a JSON array column.""" __tablename__ = "profile_favorites" user_id = Column( Integer, ForeignKey("users.id", ondelete="CASCADE"), primary_key=True, ) world_id = Column( Integer, ForeignKey("worlds.id", ondelete="CASCADE"), primary_key=True, ) created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) world = relationship("World")