from datetime import date, datetime from sqlalchemy import ( BigInteger, Boolean, Date, DateTime, Float, ForeignKey, Integer, JSON, String, Text, UniqueConstraint, func, ) from sqlalchemy.orm import Mapped, mapped_column, relationship from app.db import Base class User(Base): __tablename__ = "users" id: Mapped[int] = mapped_column(primary_key=True) # NULL for an email+password account that hasn't linked Google yet (Postgres allows # multiple NULLs under a unique index). Set on Google sign-in / in-app linking. google_sub: Mapped[str | None] = mapped_column(String(64), unique=True, index=True) email: Mapped[str] = mapped_column(String(320), unique=True, index=True) # argon2id hash for email+password login; NULL for Google-only / demo accounts. password_hash: Mapped[str | None] = mapped_column(String(255)) # Email ownership proven (Google sign-in is implicitly verified; password registration # verifies via a link). Login requires this AND is_active. email_verified: Mapped[bool] = mapped_column( Boolean, default=False, server_default="false" ) # Admin-approved & enabled. A pending password registration starts inactive; admin # approval activates it. A deactivated account can't log in. is_active: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true") # Admin-imposed access block, distinct from is_active (approval lifecycle). A suspended # account can't sign in by any method and its live sessions are rejected; the user is told # why and pointed at the operator's contact. The demo account is never suspendable. is_suspended: Mapped[bool] = mapped_column( Boolean, default=False, server_default="false" ) display_name: Mapped[str | None] = mapped_column(String(255)) avatar_url: Mapped[str | None] = mapped_column(String(1024)) role: Mapped[str] = mapped_column(String(16), default="user", server_default="user") # The user's own YouTube channel id, resolved lazily (channels.list?mine=true) and # cached. Used to exclude their own channel from playlist discovery. NULL = unresolved. yt_channel_id: Mapped[str | None] = mapped_column(String(32)) # The single shared "demo" account: signed into without Google OAuth (via a whitelisted # email on the login page), has no OAuth token / YouTube scope, and its per-user state is # shared by everyone who enters this way. Exactly one such row is expected. is_demo: Mapped[bool] = mapped_column( Boolean, default=False, server_default="false", index=True ) # Free-form UI preferences (theme, color scheme, font scale, default filters…). preferences: Mapped[dict | None] = mapped_column(JSON) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now() ) token: Mapped["OAuthToken | None"] = relationship( back_populates="user", uselist=False, cascade="all, delete-orphan" ) subscriptions: Mapped[list["Subscription"]] = relationship( back_populates="user", cascade="all, delete-orphan" ) class OAuthToken(Base): __tablename__ = "oauth_tokens" id: Mapped[int] = mapped_column(primary_key=True) user_id: Mapped[int] = mapped_column( ForeignKey("users.id", ondelete="CASCADE"), unique=True ) # Refresh token encrypted at rest (Fernet). Access token is short-lived and low risk. refresh_token_enc: Mapped[str | None] = mapped_column(String) access_token: Mapped[str | None] = mapped_column(String) expiry: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) scopes: Mapped[str | None] = mapped_column(String) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), onupdate=func.now() ) user: Mapped["User"] = relationship(back_populates="token") class AuthToken(Base): """Single-use, expiring token for email verification or password reset. Only the SHA-256 HASH of the token is stored, so a DB leak can't be used to verify/reset; the raw token lives only in the emailed link. Consumed (used_at set) on first valid use.""" __tablename__ = "auth_tokens" id: Mapped[int] = mapped_column(primary_key=True) user_id: Mapped[int] = mapped_column( ForeignKey("users.id", ondelete="CASCADE"), index=True ) kind: Mapped[str] = mapped_column(String(16)) # "verify" | "reset" token_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True) expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True)) used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now() ) class Invite(Base): """Access-request / whitelist row. Source of truth for who may sign in; the env ALLOWED_EMAILS/ADMIN_EMAILS act only as a bootstrap fallback (see auth.is_allowed).""" __tablename__ = "invites" id: Mapped[int] = mapped_column(primary_key=True) email: Mapped[str] = mapped_column(String(320), unique=True, index=True) # pending | approved | denied status: Mapped[str] = mapped_column( String(16), default="pending", server_default="pending" ) note: Mapped[str | None] = mapped_column(Text) requested_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now() ) decided_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) decided_by: Mapped[str | None] = mapped_column(String(320)) # admin email class DemoWhitelist(Base): """Emails that may enter the shared demo account from the login page (no Google OAuth). A hidden, admin-curated list of "keys to the same door" — every entry logs into the one shared demo user. Distinct from Invite (which gates real Google sign-in).""" __tablename__ = "demo_whitelist" id: Mapped[int] = mapped_column(primary_key=True) email: Mapped[str] = mapped_column(String(320), unique=True, index=True) note: Mapped[str | None] = mapped_column(Text) added_by: Mapped[str | None] = mapped_column(String(320)) # admin email created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now() ) class Channel(Base): """A YouTube channel. Shared across all users (one channel's videos are the same for everyone), so its expensive metadata is fetched and stored only once.""" __tablename__ = "channels" id: Mapped[str] = mapped_column(String(32), primary_key=True) # "UC..." channel id title: Mapped[str | None] = mapped_column(String(255)) handle: Mapped[str | None] = mapped_column(String(255)) description: Mapped[str | None] = mapped_column(Text) thumbnail_url: Mapped[str | None] = mapped_column(String(1024)) uploads_playlist_id: Mapped[str | None] = mapped_column(String(64)) subscriber_count: Mapped[int | None] = mapped_column(BigInteger) video_count: Mapped[int | None] = mapped_column(BigInteger) topic_categories: Mapped[list | None] = mapped_column(JSON) default_language: Mapped[str | None] = mapped_column(String(16)) country: Mapped[str | None] = mapped_column(String(8)) # Sync bookkeeping. details_synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) recent_synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) last_rss_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) backfill_cursor: Mapped[str | None] = mapped_column(String(128)) backfill_done: Mapped[bool] = mapped_column( Boolean, default=False, server_default="false" ) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now() ) videos: Mapped[list["Video"]] = relationship(back_populates="channel") subscriptions: Mapped[list["Subscription"]] = relationship(back_populates="channel") class Subscription(Base): """Per-user link to a channel, with the user's own priority/visibility overrides.""" __tablename__ = "subscriptions" __table_args__ = (UniqueConstraint("user_id", "channel_id", name="uq_user_channel"),) id: Mapped[int] = mapped_column(primary_key=True) user_id: Mapped[int] = mapped_column( ForeignKey("users.id", ondelete="CASCADE"), index=True ) channel_id: Mapped[str] = mapped_column( ForeignKey("channels.id", ondelete="CASCADE"), index=True ) # YouTube's subscription resource id, needed to unsubscribe via the API. yt_subscription_id: Mapped[str | None] = mapped_column(String(128)) priority: Mapped[int] = mapped_column(Integer, default=0, server_default="0") hidden: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") # Per-user opt-in to full-history (deep) backfill of this channel. Default off so a # new user's unique channels don't trigger a big shared-quota burst; the deep # scheduler only picks up channels at least one user has requested. deep_requested: Mapped[bool] = mapped_column( Boolean, default=False, server_default="false" ) subscribed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now() ) user: Mapped["User"] = relationship(back_populates="subscriptions") channel: Mapped["Channel"] = relationship(back_populates="subscriptions") class Video(Base): """A YouTube video. Shared across users; per-user state lives elsewhere.""" __tablename__ = "videos" id: Mapped[str] = mapped_column(String(16), primary_key=True) # video id channel_id: Mapped[str] = mapped_column( ForeignKey("channels.id", ondelete="CASCADE"), index=True ) title: Mapped[str | None] = mapped_column(Text) description: Mapped[str | None] = mapped_column(Text) published_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), index=True ) thumbnail_url: Mapped[str | None] = mapped_column(String(1024)) duration_seconds: Mapped[int | None] = mapped_column(Integer) view_count: Mapped[int | None] = mapped_column(BigInteger) like_count: Mapped[int | None] = mapped_column(BigInteger) category_id: Mapped[int | None] = mapped_column(Integer) topic_categories: Mapped[list | None] = mapped_column(JSON) default_language: Mapped[str | None] = mapped_column(String(16)) detected_language: Mapped[str | None] = mapped_column(String(16)) is_short: Mapped[bool] = mapped_column( Boolean, default=False, server_default="false", index=True ) # Whether the youtube.com/shorts/ probe has run for this video. shorts_probed: Mapped[bool] = mapped_column( Boolean, default=False, server_default="false", index=True ) # none | live | upcoming | premiere | was_live live_status: Mapped[str] = mapped_column( String(16), default="none", server_default="none", index=True ) enriched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) # videos.list?part=status fields (populated during enrichment); used by the # maintenance/validation job to decide whether a video is playable anywhere. embeddable: Mapped[bool | None] = mapped_column(Boolean) privacy_status: Mapped[str | None] = mapped_column(String(16)) # public|unlisted|private upload_status: Mapped[str | None] = mapped_column(String(16)) # processed|failed|rejected|deleted # Maintenance/validation lifecycle. last_checked_at drives the rolling re-validation # (least-recently-checked first); unavailable_since marks a video as currently # unplayable (hidden from the feed immediately, hard-deleted after a grace period); # unavailable_reason records why (deleted|private|paywalled|abandoned|stuck_live). last_checked_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), index=True ) unavailable_since: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), index=True ) unavailable_reason: Mapped[str | None] = mapped_column(String(24)) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now() ) channel: Mapped["Channel"] = relationship(back_populates="videos") class Tag(Base): """A label. System tags (user_id NULL) are computed automatically and shared by all users; user tags (user_id set) are personal. category groups them in the UI.""" __tablename__ = "tags" id: Mapped[int] = mapped_column(primary_key=True) user_id: Mapped[int | None] = mapped_column( ForeignKey("users.id", ondelete="CASCADE"), index=True ) name: Mapped[str] = mapped_column(String(64)) color: Mapped[str | None] = mapped_column(String(16)) # language | topic | other category: Mapped[str] = mapped_column( String(16), default="other", server_default="other" ) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now() ) class ChannelTag(Base): """Links a tag to a channel. source='auto' rows are managed by the auto-tagger and regenerated freely; source='user' rows are never touched by the system.""" __tablename__ = "channel_tags" __table_args__ = ( UniqueConstraint("channel_id", "tag_id", "user_id", name="uq_channel_tag"), ) id: Mapped[int] = mapped_column(primary_key=True) channel_id: Mapped[str] = mapped_column( ForeignKey("channels.id", ondelete="CASCADE"), index=True ) tag_id: Mapped[int] = mapped_column( ForeignKey("tags.id", ondelete="CASCADE"), index=True ) # NULL = system/auto link visible to everyone; set = a specific user's link. user_id: Mapped[int | None] = mapped_column( ForeignKey("users.id", ondelete="CASCADE") ) source: Mapped[str] = mapped_column(String(8), default="auto", server_default="auto") confidence: Mapped[float | None] = mapped_column(Float) class VideoState(Base): """Per-user watch state for a video. Rows exist only for non-default states; the absence of a row means 'new/unseen'.""" __tablename__ = "video_states" __table_args__ = (UniqueConstraint("user_id", "video_id", name="uq_user_video"),) id: Mapped[int] = mapped_column(primary_key=True) user_id: Mapped[int] = mapped_column( ForeignKey("users.id", ondelete="CASCADE"), index=True ) video_id: Mapped[str] = mapped_column( ForeignKey("videos.id", ondelete="CASCADE"), index=True ) # new | watched | saved | hidden status: Mapped[str] = mapped_column(String(12), default="new", server_default="new") watched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) # Resume position (seconds into the video) for the in-app player. Orthogonal to # status: a partially-watched video keeps status "new" but a non-zero position, so # it can render a progress bar and match the "in progress" feed filter. 0 = not # started / finished (trivially-early and near-finished positions are not stored). position_seconds: Mapped[int] = mapped_column( Integer, default=0, server_default="0", nullable=False ) progress_updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), onupdate=func.now() ) class ApiQuotaUsage(Base): """Tracks YouTube Data API units spent per Pacific-time day (the quota resets at midnight Pacific). The whole app shares one daily budget.""" __tablename__ = "api_quota_usage" day: Mapped[date] = mapped_column(Date, primary_key=True) units_used: Mapped[int] = mapped_column(Integer, default=0, server_default="0") class QuotaEvent(Base): """Per-spend audit log for YouTube API quota attribution. `user_id` is the end-user whose action caused the spend; NULL means background/system work (scheduler backfill, enrichment, resync). The aggregate budget still lives in ApiQuotaUsage; this is detail.""" __tablename__ = "quota_events" id: Mapped[int] = mapped_column(primary_key=True) # SET NULL on user delete: keep the audit trail (it just becomes system-attributed). user_id: Mapped[int | None] = mapped_column( ForeignKey("users.id", ondelete="SET NULL"), index=True ) action: Mapped[str] = mapped_column(String(40), index=True) units: Mapped[int] = mapped_column(Integer) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), index=True ) class AppState(Base): """Single-row global app state (admin-controlled).""" __tablename__ = "app_state" id: Mapped[int] = mapped_column(primary_key=True, default=1) sync_paused: Mapped[bool] = mapped_column( Boolean, default=False, server_default="false" ) # Admin override for the maintenance job's rolling re-validation batch (videos re-checked # per run). NULL = use the config/env default (settings.maintenance_revalidate_batch). maintenance_revalidate_batch: Mapped[int | None] = mapped_column(Integer) # First-run install wizard. `configured` is False on a fresh instance (the app runs in # setup mode: only the wizard works) and flipped True when the wizard finishes. While # unconfigured, a one-time setup token (only its SHA-256 hash stored) gates the wizard; # the raw token + URL are printed to the container logs on each startup. configured: Mapped[bool] = mapped_column( Boolean, default=False, server_default="false" ) setup_token_hash: Mapped[str | None] = mapped_column(String(64)) class SchedulerSetting(Base): """Admin override for a scheduler job's run interval (minutes). One row per job id; absent = use the env/config default. DB-backed so it's editable from the admin UI at runtime without a redeploy (env stays a fallback).""" __tablename__ = "scheduler_settings" job_id: Mapped[str] = mapped_column(String(40), primary_key=True) interval_minutes: Mapped[int] = mapped_column(Integer) class SystemConfig(Base): """Admin-set overrides for operational config that otherwise comes from env/config defaults. Generic key/value store (one row per key); absent = use the env/config default. DB-backed so it's editable from the admin Configuration page at runtime without a redeploy. Secret values (e.g. SMTP password) are stored Fernet-encrypted (`encrypted` flag) using TOKEN_ENCRYPTION_KEY; non-secrets are plaintext. The set of known keys + their types/groups/defaults lives in app.sysconfig (the single source of truth).""" __tablename__ = "system_config" key: Mapped[str] = mapped_column(String(64), primary_key=True) value: Mapped[str | None] = mapped_column(Text) encrypted: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), default=func.now(), onupdate=func.now() ) class Playlist(Base): """A per-user named, ordered collection of videos from the shared catalog. `kind`: 'user' (normal) or 'watch_later' (built-in, undeletable, one per user). `source`: 'local' (created here) or 'youtube' (mirrored from the user's YouTube playlists). `yt_playlist_id` links a mirrored/exported playlist to its YouTube id. `dirty` marks a YouTube-linked playlist with local edits not yet pushed, so the read sync won't clobber them. (source/yt_playlist_id/dirty are forward-looking for the YT sync phases; the foundation phase only uses local playlists.)""" __tablename__ = "playlists" id: Mapped[int] = mapped_column(primary_key=True) user_id: Mapped[int] = mapped_column( ForeignKey("users.id", ondelete="CASCADE"), index=True ) name: Mapped[str] = mapped_column(String(255)) kind: Mapped[str] = mapped_column(String(16), default="user", server_default="user") source: Mapped[str] = mapped_column( String(16), default="local", server_default="local" ) yt_playlist_id: Mapped[str | None] = mapped_column(String(64)) # True when the current items/name differ from `synced_fingerprint` (unpushed edits). dirty: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") # SHA-256 of name + ordered video ids as last synced from / pushed to YouTube; the # baseline `dirty` is derived against. NULL = no baseline yet (treated as dirty). synced_fingerprint: Mapped[str | None] = mapped_column(String) position: Mapped[int] = mapped_column(Integer, default=0, server_default="0") created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now() ) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), onupdate=func.now() ) items: Mapped[list["PlaylistItem"]] = relationship( back_populates="playlist", cascade="all, delete-orphan", order_by="PlaylistItem.position", ) class PlaylistItem(Base): """A video's membership in a playlist, with its order within that playlist.""" __tablename__ = "playlist_items" __table_args__ = ( UniqueConstraint("playlist_id", "video_id", name="uq_playlist_video"), ) id: Mapped[int] = mapped_column(primary_key=True) playlist_id: Mapped[int] = mapped_column( ForeignKey("playlists.id", ondelete="CASCADE"), index=True ) video_id: Mapped[str] = mapped_column( ForeignKey("videos.id", ondelete="CASCADE"), index=True ) position: Mapped[int] = mapped_column(Integer, default=0, server_default="0") added_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now() ) playlist: Mapped["Playlist"] = relationship(back_populates="items") class Notification(Base): """A durable, server-backed per-user notification (the inbox center, phase 1). Distinct from the client-side transient "bell" (toasts kept only in localStorage): these survive reloads and devices, and are produced server-side (first producer = the maintenance/validation job's batched "N saved/playlisted videos removed" notice). The column is named `data` (not `metadata`, which SQLAlchemy's declarative Base reserves): a free-form JSON payload (e.g. the list of removed videos) the UI can render. `read` and `dismissed` are separate: read clears the unread badge but keeps the row in the inbox; dismissed removes it from the default view. Unread rows never auto-expire; read ones are trimmed past a soft per-user cap (see app.notifications.trim_read).""" __tablename__ = "notifications" id: Mapped[int] = mapped_column(primary_key=True) user_id: Mapped[int] = mapped_column( ForeignKey("users.id", ondelete="CASCADE"), index=True ) type: Mapped[str] = mapped_column(String(32), index=True) # e.g. "maintenance" title: Mapped[str] = mapped_column(String(255)) body: Mapped[str | None] = mapped_column(Text) data: Mapped[dict | None] = mapped_column(JSON) read: Mapped[bool] = mapped_column( Boolean, default=False, server_default="false", index=True ) dismissed: Mapped[bool] = mapped_column( Boolean, default=False, server_default="false" ) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), index=True ) class Message(Base): """A message in the notification module (phase 2). Two kinds share this table: - `kind="user"`: a private, END-TO-END ENCRYPTED direct message between two users. The server only ever stores `ciphertext` + `iv` (base64); it never holds the plaintext or the keys, so not even an admin can read it. A conversation is just the set of messages between two users — no separate thread entity; queries group by the {sender, recipient} pair. - `kind="system"`: a server-authored plaintext message (e.g. the welcome, or future admin announcements). `sender_id` is NULL and `body` holds the text. These are non-private boilerplate, readable without any E2EE key setup, and surface as a "Siftlode" conversation. `read_at` is stamped when the recipient opens the thread (NULL = unread, feeds the nav badge). The recipient FK cascades (and sender, for user messages) so a GDPR account erasure removes that user's messages outright.""" __tablename__ = "messages" id: Mapped[int] = mapped_column(primary_key=True) kind: Mapped[str] = mapped_column( String(16), default="user", server_default="user" ) # NULL for system messages (no human sender). sender_id: Mapped[int | None] = mapped_column( ForeignKey("users.id", ondelete="CASCADE"), index=True ) recipient_id: Mapped[int] = mapped_column( ForeignKey("users.id", ondelete="CASCADE"), index=True ) # System messages carry plaintext `body`; user messages carry `ciphertext`+`iv` (base64). body: Mapped[str | None] = mapped_column(Text) ciphertext: Mapped[str | None] = mapped_column(Text) iv: Mapped[str | None] = mapped_column(String(32)) read_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), index=True ) class MessageKey(Base): """A user's end-to-end-encryption key material for direct messaging (phase 2). One row per user. `public_key` (base64 SPKI, ECDH P-256) is shared with others so they can derive the pairwise conversation key. `wrapped_private_key` is the user's private key encrypted IN THE BROWSER with a key derived (PBKDF2) from a message passphrase the server never sees — so the server stores only an opaque blob it cannot unwrap. `salt`/`wrap_iv` parameterise that derivation; `key_check` is a small ciphertext the client decrypts to verify a passphrase on unlock. The server is purely a key directory + blob store; it can never read any message.""" __tablename__ = "message_keys" user_id: Mapped[int] = mapped_column( ForeignKey("users.id", ondelete="CASCADE"), primary_key=True ) public_key: Mapped[str] = mapped_column(Text) wrapped_private_key: Mapped[str] = mapped_column(Text) salt: Mapped[str] = mapped_column(String(64)) wrap_iv: Mapped[str] = mapped_column(String(32)) key_check: Mapped[str] = mapped_column(Text) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now() )