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 )