thermograph/backend/accounts/models.py
Emi Griffith 8b70a87555
All checks were successful
PR build (required check) / changes (pull_request) Successful in 8s
secrets-guard / encrypted (pull_request) Successful in 5s
shell-lint / shellcheck (pull_request) Successful in 8s
PR build (required check) / validate-observability (pull_request) Has been skipped
PR build (required check) / build-frontend (pull_request) Successful in 1m11s
PR build (required check) / build-backend (pull_request) Successful in 1m35s
PR build (required check) / gate (pull_request) Successful in 1s
accounts: sign in with Discord, sharing the existing link callback
Discord was already linkable from a signed-in session; it could not
authenticate one. Adds /discord/login/start, which resolves a Discord
identity to an account and issues a session.

Both flows return to the existing /discord/link/callback. Discord only
honours redirect URIs registered in the developer portal, so a second
callback path would have blocked this behind an operator change; the
signed state now carries a purpose, and since that is inside the HMAC a
state can only verify under the flow it was minted for.

Account resolution, in order: an existing discord_id (the durable key,
no email needed); otherwise the Discord email, but only when Discord
reports it verified — that flag is the sole evidence the person owns the
address, and matching on an unverified one would hand over the account.
Failing both, a new account is created.

A login state carries no user id, so it is replayable against whoever is
signed in. It therefore never links: doing so would be a forced-linking
takeover. Only link/start, whose state is bound to a user id, may link.

Accounts created this way have no password their owner has ever seen,
so user.discord_only records that and unlink is refused for them — no
reset-password router is mounted, so unlinking would be unrecoverable.
Migration 0003 adds the column conditionally: 0001 builds the schema
from live model metadata, so a fresh database already has it.

Also fixes two latent bugs in the link flow: the callback redirected to
/subscriptions, which no route serves (it is /alerts), and nothing ever
read the ?discord= status it has always sent, so a completed link gave
no feedback. Both now surface as a toast. Linking a Discord account that
another account already owns returned a 500 from the unique constraint;
it now explains itself.
2026-07-26 10:43:17 -07:00

197 lines
10 KiB
Python

"""ORM tables for the account domain (data/accounts.sqlite).
``User`` / ``AccessToken`` are fastapi-users' base tables (UUID primary keys);
the access-token table backs a database session strategy, so logins survive a
process restart and are individually revocable. ``Subscription`` and
``Notification`` are our own, keyed to a user and cascading on delete.
"""
import time
import uuid
from fastapi_users_db_sqlalchemy import SQLAlchemyBaseUserTableUUID
from fastapi_users_db_sqlalchemy.access_token import SQLAlchemyBaseAccessTokenTableUUID
from fastapi_users_db_sqlalchemy.generics import GUID
from sqlalchemy import (
JSON,
Boolean,
CheckConstraint,
Float,
ForeignKey,
Index,
Integer,
String,
Text,
UniqueConstraint,
false,
)
from sqlalchemy.orm import Mapped, mapped_column
from accounts.db import Base
class User(SQLAlchemyBaseUserTableUUID, Base):
# Inherits id (UUID), email (unique), hashed_password, is_active,
# is_superuser, is_verified. Optional extras:
display_name: Mapped[str | None] = mapped_column(String(120), nullable=True)
# Linked Discord account id (OAuth2 identify) — the key DM alerts reach the user
# by, and the identity "Sign in with Discord" resolves an account from. Unique,
# so one Discord account can never own two Thermograph accounts.
discord_id: Mapped[str | None] = mapped_column(String(32), unique=True, nullable=True)
# Whether to also deliver alerts as a Discord DM. Set True on linking (an active
# opt-in); the user can mute it while staying linked. Same migration caveat.
discord_dm: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False,
server_default=false())
# True when the account was created by "Sign in with Discord" and so has no
# password its owner has ever seen (hashed_password is NOT NULL, so the row
# carries a generated one nobody knows). Load-bearing: unlinking Discord from
# such an account would remove its only way in, so discord_link.py refuses.
discord_only: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False,
server_default=false())
class AccessToken(SQLAlchemyBaseAccessTokenTableUUID, Base):
# Inherits token (PK), user_id (FK -> user.id), created_at. Rows here ARE the
# sessions: DatabaseStrategy looks a cookie's token up in this table.
pass
class Subscription(Base):
__tablename__ = "subscription"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[uuid.UUID] = mapped_column(
GUID, ForeignKey("user.id", ondelete="CASCADE"), nullable=False
)
# grid.snap(lat, lon)["id"] — the stable per-location key used across the app.
cell_id: Mapped[str] = mapped_column(String(40), nullable=False)
label: Mapped[str | None] = mapped_column(String(200), nullable=True)
lat: Mapped[float] = mapped_column(Float, nullable=False)
lon: Mapped[float] = mapped_column(Float, nullable=False)
# Unusualness cutoff the user picked; the low tail mirrors it at 100-threshold.
threshold: Mapped[int] = mapped_column(Integer, nullable=False)
# Grading metric keys this subscription watches, e.g. ["tmax", "feels", "precip"].
metrics: Mapped[list] = mapped_column(JSON, nullable=False, default=list)
# 'observed' (a recorded day crossed) or 'forecast' (an upcoming day is projected to).
kind: Mapped[str] = mapped_column(String(16), nullable=False, default="observed")
# Also alert the cold/low tail for temperature-like metrics (precip stays one-sided).
two_sided: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
# Epoch seconds of the last notification emitted — enforces the weekly cap.
last_notified_at: Mapped[float | None] = mapped_column(Float, nullable=True)
created_at: Mapped[float] = mapped_column(Float, nullable=False, default=time.time)
__table_args__ = (
CheckConstraint("threshold BETWEEN 95 AND 99", name="ck_sub_threshold"),
CheckConstraint("kind IN ('observed','forecast')", name="ck_sub_kind"),
# One observed + one forecast subscription per location per user.
UniqueConstraint("user_id", "cell_id", "kind", name="uq_sub_user_cell_kind"),
Index("idx_sub_user", "user_id"),
Index("idx_sub_active", "active"),
)
class Notification(Base):
__tablename__ = "notification"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[uuid.UUID] = mapped_column(
GUID, ForeignKey("user.id", ondelete="CASCADE"), nullable=False
)
subscription_id: Mapped[int] = mapped_column(
Integer, ForeignKey("subscription.id", ondelete="CASCADE"), nullable=False
)
event_date: Mapped[str] = mapped_column(String(10), nullable=False) # YYYY-MM-DD
metric: Mapped[str] = mapped_column(String(16), nullable=False)
direction: Mapped[str] = mapped_column(String(4), nullable=False) # 'high' | 'low'
kind: Mapped[str] = mapped_column(String(16), nullable=False, default="observed")
percentile: Mapped[float] = mapped_column(Float, nullable=False)
value: Mapped[float | None] = mapped_column(Float, nullable=True)
grade: Mapped[str | None] = mapped_column(String(40), nullable=True)
title: Mapped[str] = mapped_column(String(200), nullable=False)
body: Mapped[str | None] = mapped_column(Text, nullable=True)
# 'inapp' today; the seam for future 'email' / 'push' delivery.
channel: Mapped[str] = mapped_column(String(16), nullable=False, default="inapp")
created_at: Mapped[float] = mapped_column(Float, nullable=False, default=time.time)
read_at: Mapped[float | None] = mapped_column(Float, nullable=True) # NULL == unread
__table_args__ = (
# The dedup key: a given event (day+metric+direction+kind) notifies a
# subscription at most once, so re-running the evaluator never repeats it.
UniqueConstraint(
"subscription_id", "event_date", "metric", "direction", "kind",
name="uq_notif_event",
),
Index("idx_notif_user_created", "user_id", "created_at"),
Index("idx_notif_user_read", "user_id", "read_at"),
)
class PushSubscription(Base):
"""A single browser/device Web Push registration, owned by a user.
One row per device (a user with a phone + a laptop has two). The `endpoint`
is the push service URL the browser handed us; it's the natural identity, so
re-subscribing from the same device updates the keys in place rather than
duplicating. Rows are pruned when the push service reports the endpoint gone
(404/410) — see notify.py / api_accounts.py.
"""
__tablename__ = "push_subscription"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[uuid.UUID] = mapped_column(
GUID, ForeignKey("user.id", ondelete="CASCADE"), nullable=False
)
# The push service URL (per-device). Unique — it identifies the device.
endpoint: Mapped[str] = mapped_column(Text, nullable=False)
# The two client keys from PushSubscription.toJSON().keys, used to encrypt the
# payload so only this device can read it.
p256dh: Mapped[str] = mapped_column(String(200), nullable=False)
auth: Mapped[str] = mapped_column(String(100), nullable=False)
# Best-effort label for a future "manage devices" view.
user_agent: Mapped[str | None] = mapped_column(String(300), nullable=True)
created_at: Mapped[float] = mapped_column(Float, nullable=False, default=time.time)
last_used_at: Mapped[float | None] = mapped_column(Float, nullable=True)
__table_args__ = (
UniqueConstraint("endpoint", name="uq_push_endpoint"),
Index("idx_push_user", "user_id"),
)
class PendingDigest(Base):
"""A monthly-digest signup, collected before email delivery is wired up.
The digest form ships ahead of SMTP on purpose: building the list is the
slow part, and making people wait for the mailer would throw away every
signup in the meantime. Rows land here unconfirmed; once delivery is live, a
confirmation pass mails each address and stamps ``confirmed_at``.
Deliberately NOT tied to ``user`` — signing up for the digest must not
require an account, and most subscribers won't have one.
"""
__tablename__ = "pending_digest"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
# 320 = the practical maximum length of an email address (64 local + @ + 255 domain).
email: Mapped[str] = mapped_column(String(320), nullable=False)
# The place the digest should cover. Optional: an address with no place is
# still a real signup, and the place can be asked for at confirmation time.
place_label: Mapped[str | None] = mapped_column(String(200), nullable=True)
lat: Mapped[float | None] = mapped_column(Float, nullable=True)
lon: Mapped[float | None] = mapped_column(Float, nullable=True)
cell_id: Mapped[str | None] = mapped_column(String(32), nullable=True)
# Where the signup came from (bare referrer domain), for attribution only.
source: Mapped[str | None] = mapped_column(String(64), nullable=True)
created_at: Mapped[float] = mapped_column(Float, nullable=False, default=time.time)
# Set when the address is verified. The double opt-in token is stored as a
# sha256 hash, never in the clear, so a leaked database can't confirm addresses.
token_hash: Mapped[str | None] = mapped_column(String(64), nullable=True)
confirmed_at: Mapped[float | None] = mapped_column(Float, nullable=True)
last_sent_at: Mapped[float | None] = mapped_column(Float, nullable=True)
unsubscribed_at: Mapped[float | None] = mapped_column(Float, nullable=True)
__table_args__ = (
# One row per address: a re-submit updates in place rather than
# duplicating, which is what makes the form idempotent.
UniqueConstraint("email", name="uq_pending_digest_email"),
)