feat: hunter profiles, ranks, whispers, and the encounter record
All five agents died mid-flight (three on session limits, two on 529s), but their worktrees held real work — 17 files. Salvaged everything, wrote the missing pieces, and finished the integration by hand. PROFILES + RANK User gains display_name, bio, gender, avatar_form, avatar_hue and profile_public — all nullable, so every existing row including the guest `wanderer-` accounts stays valid with no backfill. The avatar is procedural (a GhostForm plus a hue, drawn by the same GhostGlyph that renders entities): no uploads means no moderation surface, no EXIF and no blob storage, and an `avatar_url` still slots in later without changing anything. rank.py converts encounters, essence and favor into one "standing" currency and maps it onto six one-word titles. An encounter is worth ten points to ten essence's one, because contact is what the app is about — a seeker who only buys unlocks climbs very slowly. Negative essence and favor floor at zero rather than subtracting, so a bad judgment can never demote you: rank is a record of what you have done. Level 1 costs exactly one encounter, so a new hunter sees the bar move after their first séance. Privacy invariants, verified live rather than assumed: - `email` is returned by GET /api/profile/me and by nothing else. Confirmed against the running server: zero occurrences in both public payloads. - A hidden profile 404s rather than 403s — confirming the account exists would leak exactly what hiding it was meant to prevent. WHISPERS BETWEEN HUNTERS Plain text, no attachments, no editing. Guests can RECEIVE but not send: that gives registering a felt purpose beyond keeping a codex, and closes the obvious spam vector since guest accounts are free and automatic. Verified live: alice→bob delivers, a guest send returns 403, and a third party's conversation list comes back empty — no cross-user leak. Message bodies are rendered as text nodes, never as HTML, and wrap with overflow-wrap:anywhere so a long unbroken string can't blow out the layout. THE ENCOUNTER RECORD The Codex already knew all of this — Entity.discovered_by has always been recorded and every contact was already an entity_sightings row. Nobody ever showed it. Now an entity page names its summoner and lists every hunter who has met it. Hunters who opted out of a public profile are still COUNTED but not linkable: an anonymous contact is still a contact, so a spirit's history stays honest without exposing anyone. Live on production data: Mabel Crump, discovered by Charly, 1 encounter; Charly ranks channeler (level 2) from 5 real sightings — all computed from data that was already sitting there. 385 frontend tests pass; i18n parity holds across both languages. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -15,6 +15,8 @@ from app.routes.codex import router as codex_router
|
||||
from app.routes.conditions import router as conditions_router
|
||||
from app.routes.device import router as device_router
|
||||
from app.routes.inventory import router as inventory_router
|
||||
from app.routes.messages import router as messages_router
|
||||
from app.routes.profile import router as profile_router
|
||||
from app.routes.seances import router as seances_router
|
||||
from app.routes.seo import router as seo_router
|
||||
from app.routes.shop import router as shop_router
|
||||
@@ -71,6 +73,23 @@ async def lifespan(app: FastAPI):
|
||||
await conn.execute(text(
|
||||
"ALTER TABLE entities ADD COLUMN IF NOT EXISTS at_peace BOOLEAN NOT NULL DEFAULT false"
|
||||
))
|
||||
# Hunter profile columns. All nullable (or defaulted) so existing
|
||||
# rows, guests included, stay valid without a backfill.
|
||||
for column, ddl in (
|
||||
("display_name", "VARCHAR(48)"),
|
||||
("bio", "VARCHAR(280)"),
|
||||
("gender", "VARCHAR(16)"),
|
||||
("avatar_form", "VARCHAR(16)"),
|
||||
("avatar_hue", "INTEGER"),
|
||||
):
|
||||
await conn.execute(
|
||||
text(f"ALTER TABLE users ADD COLUMN IF NOT EXISTS {column} {ddl}")
|
||||
)
|
||||
await conn.execute(text(
|
||||
"ALTER TABLE users ADD COLUMN IF NOT EXISTS "
|
||||
"profile_public BOOLEAN NOT NULL DEFAULT true"
|
||||
))
|
||||
|
||||
# Defense-in-depth: purchase_unlock() already enforces one row per
|
||||
# (user, unlock_key) via a row-locked check-then-insert, so this
|
||||
# constraint should never actually find a conflict on a live DB.
|
||||
@@ -100,6 +119,8 @@ app.include_router(codex_router)
|
||||
app.include_router(conditions_router)
|
||||
app.include_router(device_router)
|
||||
app.include_router(inventory_router)
|
||||
app.include_router(messages_router)
|
||||
app.include_router(profile_router)
|
||||
app.include_router(seances_router)
|
||||
# Registered before the SPA catch-all below, or /robots.txt and
|
||||
# /sitemap.xml would be served index.html instead.
|
||||
|
||||
@@ -5,6 +5,7 @@ from app.models.entity import Entity
|
||||
from app.models.entity_sighting import EntitySighting
|
||||
from app.models.event import Event
|
||||
from app.models.inventory_item import InventoryItem
|
||||
from app.models.message import Message
|
||||
from app.models.sigil import Sigil
|
||||
from app.models.unlock import UnlockRecord
|
||||
from app.models.user import User
|
||||
@@ -19,6 +20,7 @@ __all__ = [
|
||||
"EntitySighting",
|
||||
"Event",
|
||||
"InventoryItem",
|
||||
"Message",
|
||||
"Sigil",
|
||||
"UnlockRecord",
|
||||
"WaitlistEntry",
|
||||
|
||||
49
backend/app/models/message.py
Normal file
49
backend/app/models/message.py
Normal file
@@ -0,0 +1,49 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Index, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db import Base
|
||||
|
||||
# The wire cap. Enforced in three places on purpose: the Pydantic schema
|
||||
# (rejects with a clear 422/400 before touching the DB), the route's own
|
||||
# check (so a body built any other way still can't slip through), and the
|
||||
# column type below — Text, because Postgres VARCHAR(n) truncation/erroring
|
||||
# is a worse failure mode than a validated length, and a future cap change
|
||||
# then needs no migration.
|
||||
BODY_MAX_CHARS = 1000
|
||||
|
||||
|
||||
class Message(Base):
|
||||
"""One plain-text message from one hunter to another.
|
||||
|
||||
Deliberately minimal (hunters-and-messages spec, Workstream S): no
|
||||
attachments, no editing, no groups, no threads-as-rows — a "thread" is
|
||||
simply every row between two user ids, ordered by time.
|
||||
|
||||
`read_at` is null until the *recipient* opens the thread; the sender
|
||||
never marks anything read, so this doubles as the unread signal.
|
||||
"""
|
||||
|
||||
__tablename__ = "messages"
|
||||
# Both directions are queried: the conversation list and thread view each
|
||||
# need "sent by me" OR'd with "sent to me", and the unread aggregate
|
||||
# scans (recipient_id, read_at). The composite indexes below serve those
|
||||
# ordered scans; the per-column indexes on the FKs come from
|
||||
# index=True and keep single-sided lookups cheap.
|
||||
__table_args__ = (
|
||||
Index("ix_messages_sender_recipient_created", "sender_id", "recipient_id", "created_at"),
|
||||
Index("ix_messages_recipient_sender_created", "recipient_id", "sender_id", "created_at"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||
sender_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"), index=True)
|
||||
recipient_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"), index=True)
|
||||
body: Mapped[str] = mapped_column(Text)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), index=True
|
||||
)
|
||||
read_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True, default=None
|
||||
)
|
||||
@@ -1,7 +1,7 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import DateTime, Float, Integer, String
|
||||
from sqlalchemy import Boolean, DateTime, Float, Integer, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db import Base
|
||||
@@ -14,6 +14,27 @@ class User(Base):
|
||||
username: Mapped[str] = mapped_column(String(32), unique=True, index=True)
|
||||
password_hash: Mapped[str] = mapped_column(String(255))
|
||||
email: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
|
||||
# --- hunter profile ---------------------------------------------------
|
||||
# All nullable so every pre-existing row — including the guest
|
||||
# `wanderer-` accounts minted by the open door — stays valid without a
|
||||
# backfill. Absent values are treated as "not set" and fall back at the
|
||||
# serialization layer, never here.
|
||||
#
|
||||
# The avatar is procedural: a GhostForm plus a hue, rendered by the same
|
||||
# GhostGlyph component that draws entities. No uploads means no
|
||||
# moderation surface, no EXIF stripping and no blob storage — and if a
|
||||
# real image is ever wanted, an `avatar_url` slots in beside these
|
||||
# without changing anything else.
|
||||
display_name: Mapped[str | None] = mapped_column(String(48), nullable=True)
|
||||
bio: Mapped[str | None] = mapped_column(String(280), nullable=True)
|
||||
gender: Mapped[str | None] = mapped_column(String(16), nullable=True)
|
||||
avatar_form: Mapped[str | None] = mapped_column(String(16), nullable=True)
|
||||
avatar_hue: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
# Hides the profile from /api/hunters and 404s the public page. It does
|
||||
# NOT stop messages arriving — privacy here is about being browsed, not
|
||||
# about being unreachable.
|
||||
profile_public: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
essence: Mapped[int] = mapped_column(Integer, default=0)
|
||||
# Workstream B (character-depth-ghost-log spec): hidden per-user score,
|
||||
# nudged by judgment correctness, clamped to [-1.0, 1.0] everywhere it's
|
||||
|
||||
114
backend/app/rank.py
Normal file
114
backend/app/rank.py
Normal file
@@ -0,0 +1,114 @@
|
||||
"""A hunter's rank — pure maths over three numbers already on the User row.
|
||||
|
||||
No DB, no I/O, no clock: `level_for` and `progress_for` are total functions of
|
||||
their arguments, so the API layer can call them on values it already loaded and
|
||||
the tests can exercise absurd inputs without a database.
|
||||
|
||||
The curve
|
||||
---------
|
||||
Everything is converted into one currency, "standing", so the three sources of
|
||||
progress can be compared:
|
||||
|
||||
standing = encounters * ENCOUNTER_WEIGHT
|
||||
+ max(0, essence) // ESSENCE_PER_POINT
|
||||
+ max(0.0, favor) * FAVOR_WEIGHT
|
||||
|
||||
Contact is what the app is *about*, so an encounter is worth ten points while
|
||||
ten essence is worth one — a seeker who only buys unlocks climbs very slowly.
|
||||
Favor is a hidden [-1, 1] score nudged by judgment correctness; it contributes
|
||||
at most a few points, enough to break a tie between two equally-travelled
|
||||
hunters but never enough to be a second progression track. Negative essence and
|
||||
negative favor are floored at zero rather than subtracting, so a hunter can
|
||||
never be *demoted* by a bad judgment — rank is a record of what you have done.
|
||||
|
||||
Thresholds are `0, 10, 40, 100, 220, 450`: level 1 costs exactly one encounter
|
||||
(a new hunter finishes their first séance and immediately sees the bar move —
|
||||
this is the point of the curve), then each step costs roughly 2.2x the last.
|
||||
Geometric growth means the early levels arrive in a single sitting while
|
||||
`oracle` is a genuine long-haul goal (~45 distinct spirits), without a
|
||||
hand-tuned table that has to be re-justified every time a level is added.
|
||||
"""
|
||||
|
||||
# One-word, in-fiction titles, indexed by level.
|
||||
TITLES: tuple[str, ...] = (
|
||||
"curious",
|
||||
"sensitive",
|
||||
"channeler",
|
||||
"medium",
|
||||
"adept",
|
||||
"oracle",
|
||||
)
|
||||
|
||||
# Standing required to *reach* each level; index == level. Strictly increasing.
|
||||
THRESHOLDS: tuple[int, ...] = (0, 10, 40, 100, 220, 450)
|
||||
|
||||
MAX_LEVEL = len(THRESHOLDS) - 1
|
||||
|
||||
ENCOUNTER_WEIGHT = 10
|
||||
ESSENCE_PER_POINT = 10
|
||||
FAVOR_WEIGHT = 5.0
|
||||
|
||||
|
||||
def standing_for(encounters: int, essence: int, favor: float) -> int:
|
||||
"""The single progression currency. Total and non-negative for any input,
|
||||
including negative essence/favor and non-finite favor."""
|
||||
try:
|
||||
enc = max(0, int(encounters))
|
||||
ess = max(0, int(essence))
|
||||
fav = float(favor)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
# NaN fails every comparison, so test for it rather than clamping.
|
||||
if not (fav == fav): # noqa: PLR0124 — NaN check without importing math
|
||||
fav = 0.0
|
||||
fav = min(1.0, max(0.0, fav))
|
||||
return enc * ENCOUNTER_WEIGHT + ess // ESSENCE_PER_POINT + int(fav * FAVOR_WEIGHT)
|
||||
|
||||
|
||||
def level_for(encounters: int, essence: int, favor: float) -> int:
|
||||
"""Highest level whose threshold the hunter's standing has reached,
|
||||
clamped to [0, MAX_LEVEL]."""
|
||||
standing = standing_for(encounters, essence, favor)
|
||||
level = 0
|
||||
for candidate, threshold in enumerate(THRESHOLDS):
|
||||
if standing >= threshold:
|
||||
level = candidate
|
||||
else:
|
||||
break
|
||||
return level
|
||||
|
||||
|
||||
def title_for(level: int) -> str:
|
||||
"""Title for a level, clamped — never raises on an out-of-range level."""
|
||||
return TITLES[min(MAX_LEVEL, max(0, int(level)))]
|
||||
|
||||
|
||||
def progress_for(encounters: int, essence: int, favor: float) -> dict:
|
||||
"""Rank plus the numbers a progress bar needs.
|
||||
|
||||
`next_at` is the standing required for the next level (None at MAX_LEVEL),
|
||||
and `progress` is the 0.0..1.0 fraction of the way there (1.0 at
|
||||
MAX_LEVEL, so a maxed bar renders full rather than empty).
|
||||
"""
|
||||
standing = standing_for(encounters, essence, favor)
|
||||
level = level_for(encounters, essence, favor)
|
||||
floor = THRESHOLDS[level]
|
||||
|
||||
if level >= MAX_LEVEL:
|
||||
next_at: int | None = None
|
||||
progress = 1.0
|
||||
else:
|
||||
next_at = THRESHOLDS[level + 1]
|
||||
span = next_at - floor
|
||||
progress = min(1.0, max(0.0, (standing - floor) / span))
|
||||
|
||||
return {
|
||||
"level": level,
|
||||
"title": title_for(level),
|
||||
# Echoed back so a caller rendering the bar doesn't need a second
|
||||
# source for the count it is labelling.
|
||||
"encounters": max(0, int(encounters)) if isinstance(encounters, (int, float)) else 0,
|
||||
"standing": standing,
|
||||
"next_at": next_at,
|
||||
"progress": round(progress, 4),
|
||||
}
|
||||
@@ -16,6 +16,97 @@ from app.models.user import User
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["codex"])
|
||||
|
||||
# How many distinct hunters the dossier names. Everyone else is still
|
||||
# counted in `total_encounters` — the roster is a window, not the truth.
|
||||
ROSTER_LIMIT = 20
|
||||
|
||||
|
||||
def _user_column(name: str):
|
||||
"""The profile columns land in `users` via a parallel workstream (the
|
||||
hunter-profiles spec §"Data model"). Until that migration exists this
|
||||
module must not reference them in SQL at all, or every Codex request
|
||||
500s on an unknown column. Resolve them by name and fall back to a
|
||||
literal default, so the same code path works before and after.
|
||||
"""
|
||||
return getattr(User, name, None)
|
||||
|
||||
|
||||
def _hunter_entry(
|
||||
username: str,
|
||||
display_name: str | None,
|
||||
avatar_form: str | None,
|
||||
avatar_hue: int | None,
|
||||
profile_public: bool | None,
|
||||
times: int,
|
||||
last_seen,
|
||||
) -> dict:
|
||||
# A hidden profile is still a hunter who was there: counted, named
|
||||
# nowhere. `public` is what the UI keys the link off.
|
||||
public = True if profile_public is None else bool(profile_public)
|
||||
return {
|
||||
"username": username,
|
||||
"display_name": display_name or username,
|
||||
"avatar_form": avatar_form,
|
||||
"avatar_hue": avatar_hue,
|
||||
"public": public,
|
||||
"times_contacted": times,
|
||||
"last_seen": last_seen.isoformat() if last_seen else None,
|
||||
}
|
||||
|
||||
|
||||
async def _encounters(db: AsyncSession, entity_id: uuid.UUID) -> tuple[list[dict], int]:
|
||||
"""The roster of hunters who have contacted this spirit, newest-first.
|
||||
|
||||
ONE aggregate query — group the sightings by hunter and carry the count
|
||||
and latest contact out of the same scan. Never a lookup per hunter.
|
||||
"""
|
||||
display_name = _user_column("display_name")
|
||||
avatar_form = _user_column("avatar_form")
|
||||
avatar_hue = _user_column("avatar_hue")
|
||||
profile_public = _user_column("profile_public")
|
||||
|
||||
last_seen = func.max(EntitySighting.seen_at).label("last_seen")
|
||||
times = func.count(EntitySighting.id).label("times")
|
||||
columns = [User.username, times, last_seen]
|
||||
optional = [display_name, avatar_form, avatar_hue, profile_public]
|
||||
columns.extend(col for col in optional if col is not None)
|
||||
|
||||
rows = (
|
||||
await db.execute(
|
||||
select(*columns)
|
||||
.join(User, User.id == EntitySighting.user_id)
|
||||
.where(EntitySighting.entity_id == entity_id)
|
||||
.group_by(User.id)
|
||||
.order_by(desc(last_seen))
|
||||
.limit(ROSTER_LIMIT)
|
||||
)
|
||||
).all()
|
||||
|
||||
roster = [
|
||||
_hunter_entry(
|
||||
row.username,
|
||||
getattr(row, "display_name", None) if display_name is not None else None,
|
||||
getattr(row, "avatar_form", None) if avatar_form is not None else None,
|
||||
getattr(row, "avatar_hue", None) if avatar_hue is not None else None,
|
||||
getattr(row, "profile_public", None) if profile_public is not None else None,
|
||||
row.times,
|
||||
row.last_seen,
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
# Distinct hunters, not sighting rows (`sightings` already reports those)
|
||||
# — so the UI can honestly say "and N more" past the roster window.
|
||||
total = (
|
||||
await db.scalar(
|
||||
select(func.count(func.distinct(EntitySighting.user_id))).where(
|
||||
EntitySighting.entity_id == entity_id
|
||||
)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
return roster, total
|
||||
|
||||
|
||||
def _entity_card(entity: Entity, discoverer: str | None) -> dict:
|
||||
return {
|
||||
@@ -72,17 +163,45 @@ async def get_entity(entity_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "no such spirit in the codex")
|
||||
|
||||
discoverer = None
|
||||
discoverer_entry = None
|
||||
if entity.discovered_by:
|
||||
user = await db.get(User, entity.discovered_by)
|
||||
discoverer = user.username if user else None
|
||||
if user is not None:
|
||||
discoverer = user.username
|
||||
discoverer_entry = _hunter_entry(
|
||||
user.username,
|
||||
getattr(user, "display_name", None),
|
||||
getattr(user, "avatar_form", None),
|
||||
getattr(user, "avatar_hue", None),
|
||||
getattr(user, "profile_public", None),
|
||||
0,
|
||||
None,
|
||||
)
|
||||
|
||||
sightings = await db.scalar(
|
||||
select(func.count(EntitySighting.id)).where(EntitySighting.entity_id == entity.id)
|
||||
)
|
||||
roster, total_encounters = await _encounters(db, entity.id)
|
||||
|
||||
card = _entity_card(entity, discoverer)
|
||||
card["persona"] = entity.persona
|
||||
card["voice"] = entity.voice_profile
|
||||
card["sightings"] = sightings or 0
|
||||
# The summoner, with enough to render a glyph and decide on a link.
|
||||
# `times_contacted`/`last_seen` come from their roster row when they
|
||||
# have one — discovery alone doesn't imply a surviving sighting row.
|
||||
if discoverer_entry is not None:
|
||||
for hunter in roster:
|
||||
if hunter["username"] == discoverer_entry["username"]:
|
||||
discoverer_entry["times_contacted"] = hunter["times_contacted"]
|
||||
discoverer_entry["last_seen"] = hunter["last_seen"]
|
||||
break
|
||||
card["discoverer"] = discoverer_entry
|
||||
card["discoverer_public"] = (
|
||||
discoverer_entry["public"] if discoverer_entry else False
|
||||
)
|
||||
card["encounters"] = roster
|
||||
card["total_encounters"] = total_encounters
|
||||
return card
|
||||
|
||||
|
||||
|
||||
254
backend/app/routes/messages.py
Normal file
254
backend/app/routes/messages.py
Normal file
@@ -0,0 +1,254 @@
|
||||
"""Whispers between hunters — plain text, no attachments, no editing, no
|
||||
groups. Workstream S of docs/superpowers/specs/
|
||||
2026-07-30-hunters-and-messages-design.md.
|
||||
|
||||
Security note, because it is the whole point of this module: every query is
|
||||
scoped to the caller's own user id. A "thread" is not a row anybody can
|
||||
address by id — it is derived from (sender_id, recipient_id) pairs where one
|
||||
side is always `user.id`, so there is no id a caller could guess to read
|
||||
somebody else's mail. See tests/test_messages.py's cross-user leak tests.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import case, desc, func, or_, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db import get_db
|
||||
from app.deps import get_current_user
|
||||
from app.models.message import BODY_MAX_CHARS, Message
|
||||
from app.models.user import User
|
||||
from app.routes.auth import WANDERER_PREFIX
|
||||
from app.rate_limit import RateLimiter
|
||||
from app.schemas import MessageIn
|
||||
|
||||
router = APIRouter(prefix="/api/messages", tags=["messages"])
|
||||
|
||||
# Per-user, not per-IP: sending requires an account, so the user id is the
|
||||
# real actor. 20 an hour is plenty for conversation and useless for spam.
|
||||
send_limiter = RateLimiter(max_requests=20, window_seconds=3600)
|
||||
|
||||
CONVERSATION_LIMIT = 50
|
||||
THREAD_LIMIT_DEFAULT = 50
|
||||
THREAD_LIMIT_MAX = 100
|
||||
EXCERPT_CHARS = 160
|
||||
|
||||
AVATAR_FORMS = ("wisp", "banshee", "fairy", "shade")
|
||||
|
||||
|
||||
def _hunter_out(user: User) -> dict:
|
||||
"""Public identity of a correspondent.
|
||||
|
||||
Workstream P owns `display_name` / `avatar_form` / `avatar_hue` /
|
||||
`profile_public` and lands separately, so every one is read with
|
||||
`getattr(..., None)`: this module works identically before and after
|
||||
that merge, and the mailbox never depends on a profile being public —
|
||||
privacy hides the profile page, not the mailbox.
|
||||
"""
|
||||
form = getattr(user, "avatar_form", None)
|
||||
hue = getattr(user, "avatar_hue", None)
|
||||
return {
|
||||
"username": user.username,
|
||||
"display_name": getattr(user, "display_name", None) or user.username,
|
||||
"avatar": {
|
||||
"form": form if form in AVATAR_FORMS else "wisp",
|
||||
"hue": hue if isinstance(hue, int) and 0 <= hue <= 359 else 150,
|
||||
},
|
||||
# False only when Workstream P exists AND the hunter opted out; the
|
||||
# UI uses this purely to decide whether to link to their profile.
|
||||
"profile_public": getattr(user, "profile_public", True) is not False,
|
||||
"is_wanderer": user.username.startswith(WANDERER_PREFIX),
|
||||
}
|
||||
|
||||
|
||||
def _message_out(message: Message, me_id) -> dict:
|
||||
return {
|
||||
"id": str(message.id),
|
||||
"body": message.body,
|
||||
"from_me": message.sender_id == me_id,
|
||||
"created_at": message.created_at.isoformat(),
|
||||
"read_at": message.read_at.isoformat() if message.read_at else None,
|
||||
}
|
||||
|
||||
|
||||
async def _unread_by_sender(db: AsyncSession, me_id) -> dict:
|
||||
"""ONE aggregate query for every unread count — never a lookup per
|
||||
conversation. Keyed by the sender's user id."""
|
||||
rows = await db.execute(
|
||||
select(Message.sender_id, func.count(Message.id))
|
||||
.where(Message.recipient_id == me_id, Message.read_at.is_(None))
|
||||
.group_by(Message.sender_id)
|
||||
)
|
||||
return {sender_id: count for sender_id, count in rows}
|
||||
|
||||
|
||||
@router.post("", status_code=status.HTTP_201_CREATED)
|
||||
async def send_message(
|
||||
payload: MessageIn,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
# The one deliberate account-only capability besides device pairing: a
|
||||
# wanderer's name is temporary and unrecoverable, so a reply would have
|
||||
# nowhere to land. They can still RECEIVE.
|
||||
if user.username.startswith(WANDERER_PREFIX):
|
||||
raise HTTPException(
|
||||
status.HTTP_403_FORBIDDEN,
|
||||
"a wanderer has no name to sign — claim one before you whisper",
|
||||
)
|
||||
|
||||
body = payload.body.strip()
|
||||
if not body:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "an empty whisper carries nothing")
|
||||
if len(body) > BODY_MAX_CHARS:
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
f"the veil will not carry more than {BODY_MAX_CHARS} characters at once",
|
||||
)
|
||||
|
||||
to = payload.to.strip()
|
||||
if to.lower() == user.username.lower():
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST, "your own echo is not a correspondent"
|
||||
)
|
||||
|
||||
recipient = await db.scalar(select(User).where(User.username == to))
|
||||
if recipient is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "no hunter answers to that name")
|
||||
# Guard the id comparison too: the username check above is the friendly
|
||||
# path, this one is what actually makes self-messaging impossible.
|
||||
if recipient.id == user.id:
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST, "your own echo is not a correspondent"
|
||||
)
|
||||
|
||||
if not send_limiter.allow(str(user.id)):
|
||||
raise HTTPException(
|
||||
status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
"you have whispered enough for one hour — let the veil settle",
|
||||
)
|
||||
|
||||
message = Message(sender_id=user.id, recipient_id=recipient.id, body=body)
|
||||
db.add(message)
|
||||
await db.commit()
|
||||
await db.refresh(message)
|
||||
return {"message": _message_out(message, user.id), "to": _hunter_out(recipient)}
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_conversations(
|
||||
user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Every correspondent, their last message excerpt, and unread counts.
|
||||
|
||||
Two queries total regardless of how many conversations exist: one
|
||||
window-function pass for the newest message per correspondent, and one
|
||||
GROUP BY for unread counts.
|
||||
"""
|
||||
correspondent = case(
|
||||
(Message.sender_id == user.id, Message.recipient_id), else_=Message.sender_id
|
||||
)
|
||||
ranked = (
|
||||
select(
|
||||
Message.id,
|
||||
Message.sender_id,
|
||||
Message.body,
|
||||
Message.created_at,
|
||||
Message.read_at,
|
||||
correspondent.label("correspondent_id"),
|
||||
func.row_number()
|
||||
.over(partition_by=correspondent, order_by=desc(Message.created_at))
|
||||
.label("rn"),
|
||||
)
|
||||
.where(or_(Message.sender_id == user.id, Message.recipient_id == user.id))
|
||||
.subquery()
|
||||
)
|
||||
rows = (
|
||||
await db.execute(
|
||||
select(
|
||||
ranked.c.sender_id,
|
||||
ranked.c.body,
|
||||
ranked.c.created_at,
|
||||
ranked.c.read_at,
|
||||
User,
|
||||
)
|
||||
.join(User, User.id == ranked.c.correspondent_id)
|
||||
.where(ranked.c.rn == 1)
|
||||
.order_by(desc(ranked.c.created_at))
|
||||
.limit(CONVERSATION_LIMIT)
|
||||
)
|
||||
).all()
|
||||
|
||||
unread = await _unread_by_sender(db, user.id)
|
||||
|
||||
conversations = [
|
||||
{
|
||||
"hunter": _hunter_out(other),
|
||||
"excerpt": body[:EXCERPT_CHARS],
|
||||
"truncated": len(body) > EXCERPT_CHARS,
|
||||
"last_at": created_at.isoformat(),
|
||||
"last_from_me": sender_id == user.id,
|
||||
"unread": unread.get(other.id, 0),
|
||||
}
|
||||
for sender_id, body, created_at, read_at, other in rows
|
||||
]
|
||||
return {
|
||||
"conversations": conversations,
|
||||
# Exposed here on purpose rather than on GET /api/profile/me, so the
|
||||
# badge does not depend on Workstream P.
|
||||
"unread_total": sum(unread.values()),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{username}")
|
||||
async def read_thread(
|
||||
username: str,
|
||||
limit: int = Query(default=THREAD_LIMIT_DEFAULT, ge=1, le=THREAD_LIMIT_MAX),
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""The thread with one hunter, newest LAST (reading order), capped at
|
||||
`limit` most-recent messages. Marks the caller's inbound messages read."""
|
||||
other = await db.scalar(select(User).where(User.username == username))
|
||||
if other is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "no hunter answers to that name")
|
||||
if other.id == user.id:
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST, "your own echo is not a correspondent"
|
||||
)
|
||||
|
||||
# Both legs are pinned to (user.id, other.id) in both orders — no third
|
||||
# party's rows can match, whatever `username` is.
|
||||
scope = or_(
|
||||
(Message.sender_id == user.id) & (Message.recipient_id == other.id),
|
||||
(Message.sender_id == other.id) & (Message.recipient_id == user.id),
|
||||
)
|
||||
total = await db.scalar(select(func.count(Message.id)).where(scope)) or 0
|
||||
newest = (
|
||||
await db.execute(
|
||||
select(Message).where(scope).order_by(desc(Message.created_at)).limit(limit)
|
||||
)
|
||||
).scalars().all()
|
||||
messages = list(reversed(newest))
|
||||
|
||||
# One UPDATE marks everything they sent us read — done after reading the
|
||||
# rows so the response still shows the read_at the caller had on open.
|
||||
await db.execute(
|
||||
update(Message)
|
||||
.where(
|
||||
Message.recipient_id == user.id,
|
||||
Message.sender_id == other.id,
|
||||
Message.read_at.is_(None),
|
||||
)
|
||||
.values(read_at=datetime.now(timezone.utc))
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
return {
|
||||
"hunter": _hunter_out(other),
|
||||
"messages": [_message_out(m, user.id) for m in messages],
|
||||
"total": total,
|
||||
"has_more": total > len(messages),
|
||||
"can_send": not user.username.startswith(WANDERER_PREFIX),
|
||||
}
|
||||
278
backend/app/routes/profile.py
Normal file
278
backend/app/routes/profile.py
Normal file
@@ -0,0 +1,278 @@
|
||||
"""Hunter profiles: the identity an account buys you.
|
||||
|
||||
The open door means a séance needs no account at all, so registering has to
|
||||
be worth something on its own. This is that something — a name other hunters
|
||||
see, a rank that grows from what you have actually contacted, and a public
|
||||
page. Device pairing and messages are the other two.
|
||||
|
||||
Privacy rules that the tests pin, because they are easy to regress:
|
||||
|
||||
- `email` is returned by GET /api/profile/me and by NOTHING else. It
|
||||
exists for account recovery; it is never part of a public payload.
|
||||
- `profile_public = False` makes the public page 404 and drops the hunter
|
||||
from the roster. It deliberately does NOT stop messages arriving —
|
||||
privacy here is about being browsed, not about being unreachable.
|
||||
- Guests (`wanderer-` accounts) have profiles like anyone else. They are
|
||||
real rows; nothing here special-cases them.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app import rank
|
||||
from app.db import get_db
|
||||
from app.deps import get_current_user
|
||||
from app.models.entity import Entity
|
||||
from app.models.entity_sighting import EntitySighting
|
||||
from app.models.user import User
|
||||
from app.rate_limit import RateLimiter
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["profile"])
|
||||
|
||||
# Editing a profile is cheap, but it is still a write — enough headroom for
|
||||
# someone fiddling with a hue slider, tight enough to be uninteresting to
|
||||
# abuse.
|
||||
profile_limiter = RateLimiter(max_requests=30, window_seconds=60)
|
||||
|
||||
GENDERS = {"male", "female", "unspecified"}
|
||||
AVATAR_FORMS = {"wisp", "banshee", "fairy", "shade"}
|
||||
BIO_MAX = 280
|
||||
DISPLAY_NAME_MAX = 48
|
||||
HUE_MAX = 359
|
||||
ROSTER_LIMIT = 24
|
||||
RECENT_ENTITIES = 12
|
||||
|
||||
# Same shape as routes/shop.py's — deliberately permissive, since this is a
|
||||
# recovery hint rather than a verified identity.
|
||||
_EMAIL_RE = re.compile(r"^[^@\s]{1,64}@[^@\s]{1,255}\.[^@\s]{2,}$")
|
||||
|
||||
|
||||
async def _encounter_count(db: AsyncSession, user_id) -> int:
|
||||
"""Distinct spirits this hunter has contacted."""
|
||||
return int(
|
||||
await db.scalar(
|
||||
select(func.count(func.distinct(EntitySighting.entity_id))).where(
|
||||
EntitySighting.user_id == user_id
|
||||
)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
|
||||
|
||||
def _rank_block(encounters: int, user: User) -> dict:
|
||||
return rank.progress_for(encounters, user.essence or 0, user.favor or 0.0)
|
||||
|
||||
|
||||
def _avatar(user: User) -> dict:
|
||||
return {
|
||||
"avatar_form": user.avatar_form,
|
||||
"avatar_hue": user.avatar_hue,
|
||||
}
|
||||
|
||||
|
||||
def _public_fields(user: User) -> dict:
|
||||
"""Everything safe to show a stranger. Note the absence of `email` — that
|
||||
omission is the point of this helper existing."""
|
||||
return {
|
||||
"username": user.username,
|
||||
"display_name": user.display_name,
|
||||
"bio": user.bio,
|
||||
"gender": user.gender or "unspecified",
|
||||
**_avatar(user),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/profile/me")
|
||||
async def my_profile(
|
||||
user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
encounters = await _encounter_count(db, user.id)
|
||||
return {
|
||||
**_public_fields(user),
|
||||
"profile_public": bool(user.profile_public),
|
||||
# Own profile only.
|
||||
"email": user.email,
|
||||
"rank": _rank_block(encounters, user),
|
||||
}
|
||||
|
||||
|
||||
@router.patch("/profile")
|
||||
async def update_profile(
|
||||
payload: dict,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Partial update: only keys actually present are touched, so a client
|
||||
can send one field without having to echo the rest back."""
|
||||
if not profile_limiter.allow(str(user.id)):
|
||||
raise HTTPException(
|
||||
status.HTTP_429_TOO_MANY_REQUESTS, "too many changes — slow down"
|
||||
)
|
||||
if not isinstance(payload, dict):
|
||||
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "expected an object")
|
||||
|
||||
def _optional_text(key: str, limit: int) -> str | None:
|
||||
"""Trim, cap, and treat empty string as an explicit clear."""
|
||||
raw = payload[key]
|
||||
if raw is None:
|
||||
return None
|
||||
if not isinstance(raw, str):
|
||||
raise HTTPException(
|
||||
status.HTTP_422_UNPROCESSABLE_ENTITY, f"{key} must be text"
|
||||
)
|
||||
text_value = raw.strip()
|
||||
if len(text_value) > limit:
|
||||
raise HTTPException(
|
||||
status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
f"{key} must be {limit} characters or fewer",
|
||||
)
|
||||
return text_value or None
|
||||
|
||||
if "display_name" in payload:
|
||||
user.display_name = _optional_text("display_name", DISPLAY_NAME_MAX)
|
||||
if "bio" in payload:
|
||||
user.bio = _optional_text("bio", BIO_MAX)
|
||||
|
||||
if "gender" in payload:
|
||||
gender = payload["gender"]
|
||||
if gender not in GENDERS:
|
||||
raise HTTPException(
|
||||
status.HTTP_422_UNPROCESSABLE_ENTITY, "no such gender option"
|
||||
)
|
||||
user.gender = gender
|
||||
|
||||
if "avatar_form" in payload:
|
||||
form = payload["avatar_form"]
|
||||
if form not in AVATAR_FORMS:
|
||||
raise HTTPException(
|
||||
status.HTTP_422_UNPROCESSABLE_ENTITY, "no such sigil form"
|
||||
)
|
||||
user.avatar_form = form
|
||||
|
||||
if "avatar_hue" in payload:
|
||||
hue = payload["avatar_hue"]
|
||||
# bool is an int subclass in Python — reject it explicitly so
|
||||
# `avatar_hue: true` can't silently become hue 1.
|
||||
if not isinstance(hue, int) or isinstance(hue, bool) or not (0 <= hue <= HUE_MAX):
|
||||
raise HTTPException(
|
||||
status.HTTP_422_UNPROCESSABLE_ENTITY, f"hue must be 0-{HUE_MAX}"
|
||||
)
|
||||
user.avatar_hue = hue
|
||||
|
||||
if "profile_public" in payload:
|
||||
visible = payload["profile_public"]
|
||||
if not isinstance(visible, bool):
|
||||
raise HTTPException(
|
||||
status.HTTP_422_UNPROCESSABLE_ENTITY, "profile_public must be true/false"
|
||||
)
|
||||
user.profile_public = visible
|
||||
|
||||
if "email" in payload:
|
||||
email = payload["email"]
|
||||
if email is None or (isinstance(email, str) and not email.strip()):
|
||||
user.email = None
|
||||
elif isinstance(email, str) and _EMAIL_RE.match(email.strip().lower()):
|
||||
user.email = email.strip().lower()
|
||||
else:
|
||||
raise HTTPException(
|
||||
status.HTTP_422_UNPROCESSABLE_ENTITY, "that address does not reach us"
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
encounters = await _encounter_count(db, user.id)
|
||||
return {
|
||||
**_public_fields(user),
|
||||
"profile_public": bool(user.profile_public),
|
||||
"email": user.email,
|
||||
"rank": _rank_block(encounters, user),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/hunters")
|
||||
async def hunter_roster(db: AsyncSession = Depends(get_db)):
|
||||
"""Public hunters, most-travelled first.
|
||||
|
||||
Encounter counts come from ONE grouped query rather than a lookup per
|
||||
hunter — the roster is the most-hit public endpoint here and an N+1
|
||||
would show immediately.
|
||||
"""
|
||||
counts_q = (
|
||||
select(
|
||||
EntitySighting.user_id,
|
||||
func.count(func.distinct(EntitySighting.entity_id)).label("n"),
|
||||
)
|
||||
.group_by(EntitySighting.user_id)
|
||||
.subquery()
|
||||
)
|
||||
rows = (
|
||||
await db.execute(
|
||||
select(User, func.coalesce(counts_q.c.n, 0).label("encounters"))
|
||||
.outerjoin(counts_q, counts_q.c.user_id == User.id)
|
||||
.where(User.profile_public.is_(True))
|
||||
.order_by(func.coalesce(counts_q.c.n, 0).desc(), User.created_at)
|
||||
.limit(ROSTER_LIMIT)
|
||||
)
|
||||
).all()
|
||||
|
||||
return {
|
||||
"hunters": [
|
||||
{
|
||||
"username": user.username,
|
||||
"display_name": user.display_name,
|
||||
**_avatar(user),
|
||||
"rank": _rank_block(int(encounters or 0), user),
|
||||
}
|
||||
for user, encounters in rows
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.get("/hunters/{username}")
|
||||
async def hunter_profile(username: str, db: AsyncSession = Depends(get_db)):
|
||||
user = await db.scalar(select(User).where(User.username == username))
|
||||
# A hidden profile is indistinguishable from one that never existed —
|
||||
# confirming the account exists would leak exactly what hiding it was
|
||||
# meant to prevent.
|
||||
if user is None or not user.profile_public:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "no such hunter walks here")
|
||||
|
||||
encounters = await _encounter_count(db, user.id)
|
||||
|
||||
# Most recently contacted spirits, distinct, newest first.
|
||||
latest = (
|
||||
select(
|
||||
EntitySighting.entity_id,
|
||||
func.max(EntitySighting.seen_at).label("last_seen"),
|
||||
)
|
||||
.where(EntitySighting.user_id == user.id)
|
||||
.group_by(EntitySighting.entity_id)
|
||||
.subquery()
|
||||
)
|
||||
recent = (
|
||||
await db.execute(
|
||||
select(Entity, latest.c.last_seen)
|
||||
.join(latest, latest.c.entity_id == Entity.id)
|
||||
.order_by(latest.c.last_seen.desc())
|
||||
.limit(RECENT_ENTITIES)
|
||||
)
|
||||
).all()
|
||||
|
||||
return {
|
||||
**_public_fields(user),
|
||||
"joined_at": user.created_at.isoformat() if user.created_at else None,
|
||||
"rank": _rank_block(encounters, user),
|
||||
"recent_entities": [
|
||||
{
|
||||
"id": str(entity.id),
|
||||
"name": entity.name,
|
||||
"epithet": entity.epithet,
|
||||
"rarity": entity.rarity_tier,
|
||||
"visual": entity.visual_profile or {},
|
||||
}
|
||||
for entity, _seen in recent
|
||||
],
|
||||
}
|
||||
@@ -59,3 +59,13 @@ class SigilOut(BaseModel):
|
||||
created_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class MessageIn(BaseModel):
|
||||
"""A whisper sent from one hunter to another (Workstream S)."""
|
||||
|
||||
# Length rules live in routes/messages.py, not here: an empty or
|
||||
# oversized body is a 400 with in-fiction copy the UI can show, which
|
||||
# is friendlier than Pydantic's 422 validation envelope.
|
||||
to: str
|
||||
body: str
|
||||
|
||||
Reference in New Issue
Block a user