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>
50 lines
2.2 KiB
Python
50 lines
2.2 KiB
Python
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
|
|
)
|