"""Per-account settings with a JSONB preferences grab-bag. Rows are created lazily on first write. Reads of a missing row return the caller-supplied default — no upfront row creation per account. Settings live in `preferences` until they meet the promotion criteria in Section 4.6 of FLOWPILOT-MIGRATION.md (hot path / validation / joins), at which point a future migration adds a typed column and the helpers prefer it. """ from __future__ import annotations import uuid from datetime import datetime, timezone from typing import Any, TYPE_CHECKING from sqlalchemy import DateTime, ForeignKey, text from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.dialects.postgresql import UUID, JSONB, insert as pg_insert from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.sql import select from app.core.database import Base if TYPE_CHECKING: from app.models.account import Account class AccountSettings(Base): """One row per account. Created lazily on first `set_setting` call.""" __tablename__ = "account_settings" account_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("accounts.id", ondelete="CASCADE"), primary_key=True, ) preferences: Mapped[dict[str, Any]] = mapped_column( JSONB, nullable=False, default=dict, server_default=text("'{}'::jsonb") ) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) ) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), ) account: Mapped["Account"] = relationship("Account", foreign_keys=[account_id]) @classmethod async def get_setting( cls, db: AsyncSession, account_id: uuid.UUID, key: str, default: Any = None, ) -> Any: """Return preferences[key] for the account, or `default` if no row/no key. Never creates a row — this is the pure-read path. """ result = await db.execute( select(cls.preferences).where(cls.account_id == account_id) ) prefs = result.scalar_one_or_none() if prefs is None: return default return prefs.get(key, default) @classmethod async def set_setting( cls, db: AsyncSession, account_id: uuid.UUID, key: str, value: Any, ) -> None: """Upsert preferences[key] = value for the account. Creates the row on first write; on subsequent writes, merges the key into the existing preferences JSON without clobbering other keys. Uses PostgreSQL's `||` jsonb merge operator via ON CONFLICT DO UPDATE. """ stmt = pg_insert(cls).values( account_id=account_id, preferences={key: value}, ) stmt = stmt.on_conflict_do_update( index_elements=[cls.account_id], set_={ # Merge the new {key: value} into the existing preferences. # The `||` operator on jsonb overwrites matching keys and keeps # all other keys intact. "preferences": cls.preferences.op("||")(stmt.excluded.preferences), "updated_at": text("now()"), }, ) await db.execute(stmt)