"""SQLAlchemy ORM models for all persistent tables. Only auth, billing, scout config, and memory data live here. User content (notes, tasks, etc.) lives exclusively on the client. Table inventory: users — account credentials + tier refresh_tokens — hashed refresh token store subscriptions — Stripe subscription records local_scout_configs — per-device batch scout configs cloud_scout_configs — OAuth-backed cloud scout configs scout_run_logs — execution history for all scouts memory_core — per-user persistent key/value preferences (encrypted) memory_associative — per-user semantic memory with embeddings (encrypted) memory_episodic — per-user session summaries (encrypted) memory_proactive — per-user behavioral patterns (encrypted) memory_relations — per-user entity/relation graph (Mem0g-light, Phase 3) """ from __future__ import annotations import uuid from datetime import datetime, timezone from pgvector.sqlalchemy import Vector from sqlalchemy import ( Boolean, DateTime, Enum, Float, ForeignKey, Integer, JSON, LargeBinary, String, Text, Uuid, func, ) from sqlalchemy.orm import Mapped, mapped_column, relationship from app.db import Base # ── Helpers ────────────────────────────────────────────────────────────── def _uuid() -> str: return str(uuid.uuid4()) def _now() -> datetime: return datetime.now(timezone.utc) # ── Enum types ──────────────────────────────────────────────────────────── TierEnum = Enum("free", "pro", "power", "team", name="billing_tier") AgentTypeEnum = Enum("local", "cloud", name="agent_type") AgentStatusEnum = Enum("running", "success", "error", "partial", name="agent_run_status") CloudProviderEnum = Enum("gmail", "teams", "outlook", name="cloud_provider") # ── Models ──────────────────────────────────────────────────────────────── class User(Base): __tablename__ = "users" id: Mapped[str] = mapped_column( Uuid(as_uuid=False), primary_key=True, default=_uuid ) email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True) name: Mapped[str | None] = mapped_column(String(100), nullable=True) surname: Mapped[str | None] = mapped_column(String(100), nullable=True) password_hash: Mapped[str | None] = mapped_column(String(255), nullable=True) avatar_url: Mapped[str | None] = mapped_column(Text, nullable=True) tier: Mapped[str] = mapped_column(TierEnum, nullable=False, default="free") stripe_customer_id: Mapped[str | None] = mapped_column(String(255), nullable=True) # Per-user Fernet key (base64-urlsafe, 44 chars). Generated on registration. # Used to encrypt/decrypt all memory rows for this user. encryption_key: Mapped[str | None] = mapped_column(String(64), nullable=True) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now() ) onboarding_completed_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), nullable=True, default=None ) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now() ) refresh_tokens: Mapped[list[RefreshToken]] = relationship( back_populates="user", cascade="all, delete-orphan" ) subscription: Mapped[Subscription | None] = relationship( back_populates="user", uselist=False, cascade="all, delete-orphan" ) oauth_accounts: Mapped[list[OAuthAccount]] = relationship( back_populates="user", cascade="all, delete-orphan" ) class RefreshToken(Base): __tablename__ = "refresh_tokens" id: Mapped[str] = mapped_column( Uuid(as_uuid=False), primary_key=True, default=_uuid ) user_id: Mapped[str] = mapped_column( Uuid(as_uuid=False), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True ) token_hash: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, index=True) expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now() ) user: Mapped[User] = relationship(back_populates="refresh_tokens") class OAuthAccount(Base): __tablename__ = "oauth_accounts" id: Mapped[str] = mapped_column( Uuid(as_uuid=False), primary_key=True, default=_uuid ) user_id: Mapped[str] = mapped_column( Uuid(as_uuid=False), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True ) provider: Mapped[str] = mapped_column(String(50), nullable=False) provider_user_id: Mapped[str] = mapped_column(String(255), nullable=False) provider_email: Mapped[str | None] = mapped_column(String(255), nullable=True) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now() ) user: Mapped[User] = relationship(back_populates="oauth_accounts") class Subscription(Base): __tablename__ = "subscriptions" id: Mapped[str] = mapped_column( Uuid(as_uuid=False), primary_key=True, default=_uuid ) user_id: Mapped[str] = mapped_column( Uuid(as_uuid=False), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, unique=True, index=True ) stripe_subscription_id: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True) tier: Mapped[str] = mapped_column(TierEnum, nullable=False, default="free") status: Mapped[str] = mapped_column(String(50), nullable=False, default="free") current_period_end: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now() ) user: Mapped[User] = relationship(back_populates="subscription") class LocalScoutConfig(Base): __tablename__ = "local_scout_configs" id: Mapped[str] = mapped_column( Uuid(as_uuid=False), primary_key=True, default=_uuid ) user_id: Mapped[str] = mapped_column( Uuid(as_uuid=False), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True ) device_id: Mapped[str] = mapped_column(String(255), nullable=False) name: Mapped[str] = mapped_column(String(255), nullable=False) directory_paths: Mapped[list] = mapped_column(JSON, nullable=False, default=list) data_types: Mapped[list] = mapped_column(JSON, nullable=False, default=list) prompt_template: Mapped[str] = mapped_column(Text, nullable=False, default="") scout_config: Mapped[dict | None] = mapped_column(JSON, nullable=True) file_extensions: Mapped[list] = mapped_column(JSON, nullable=False, default=list) schedule_cron: Mapped[str] = mapped_column(String(100), nullable=False, default="0 */6 * * *") enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) last_run_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now() ) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now() ) run_logs: Mapped[list["ScoutRunLog"]] = relationship( back_populates="local_scout", primaryjoin="and_(ScoutRunLog.scout_id == LocalScoutConfig.id, ScoutRunLog.scout_type == 'local')", foreign_keys="ScoutRunLog.scout_id", cascade="all, delete-orphan", overlaps="run_logs,cloud_scout", ) class CloudScoutConfig(Base): __tablename__ = "cloud_scout_configs" id: Mapped[str] = mapped_column( Uuid(as_uuid=False), primary_key=True, default=_uuid ) user_id: Mapped[str] = mapped_column( Uuid(as_uuid=False), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True ) provider: Mapped[str] = mapped_column(CloudProviderEnum, nullable=False) name: Mapped[str] = mapped_column(String(255), nullable=False) data_types: Mapped[list] = mapped_column(JSON, nullable=False, default=list) prompt_template: Mapped[str] = mapped_column(Text, nullable=False, default="") oauth_token_encrypted: Mapped[str | None] = mapped_column(Text, nullable=True) filter_config: Mapped[dict | None] = mapped_column(JSON, nullable=True) schedule_cron: Mapped[str] = mapped_column(String(100), nullable=False, default="0 */6 * * *") enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) last_run_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now() ) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now() ) run_logs: Mapped[list["ScoutRunLog"]] = relationship( back_populates="cloud_scout", primaryjoin="and_(ScoutRunLog.scout_id == CloudScoutConfig.id, ScoutRunLog.scout_type == 'cloud')", foreign_keys="ScoutRunLog.scout_id", cascade="all, delete-orphan", overlaps="run_logs,local_scout", ) class ScoutRunLog(Base): __tablename__ = "scout_run_logs" id: Mapped[str] = mapped_column( Uuid(as_uuid=False), primary_key=True, default=_uuid ) # Plain string — not a FK because it references either local_scout_configs or cloud_scout_configs # depending on scout_type. Query by (scout_id, scout_type) to locate the source config. scout_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True) scout_type: Mapped[str] = mapped_column(AgentTypeEnum, nullable=False) user_id: Mapped[str] = mapped_column( Uuid(as_uuid=False), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True ) status: Mapped[str] = mapped_column(AgentStatusEnum, nullable=False, default="running") items_processed: Mapped[int] = mapped_column(Integer, nullable=False, default=0) items_created: Mapped[int] = mapped_column(Integer, nullable=False, default=0) tokens_used: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0") errors: Mapped[list | None] = mapped_column(JSON, nullable=True) started_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now() ) completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) local_scout: Mapped["LocalScoutConfig | None"] = relationship( back_populates="run_logs", primaryjoin="and_(ScoutRunLog.scout_id == LocalScoutConfig.id, ScoutRunLog.scout_type == 'local')", foreign_keys="ScoutRunLog.scout_id", overlaps="run_logs,cloud_scout", ) cloud_scout: Mapped["CloudScoutConfig | None"] = relationship( back_populates="run_logs", primaryjoin="and_(ScoutRunLog.scout_id == CloudScoutConfig.id, ScoutRunLog.scout_type == 'cloud')", foreign_keys="ScoutRunLog.scout_id", overlaps="run_logs,local_scout", ) class MonthlyTokenUsage(Base): __tablename__ = "monthly_token_usage" user_id: Mapped[str] = mapped_column( Uuid(as_uuid=False), ForeignKey("users.id", ondelete="CASCADE"), primary_key=True ) year_month: Mapped[str] = mapped_column(String(7), primary_key=True) # 'YYYY-MM' feature: Mapped[str] = mapped_column(String(64), primary_key=True) tokens_used: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0") # ── Memory models ───────────────────────────────────────────────────────────── class MemoryCore(Base): """Per-user persistent key/value preferences, encrypted at rest. Examples: preferred_language, timezone, work_style. Decrypted in-memory only using User.encryption_key. """ __tablename__ = "memory_core" id: Mapped[str] = mapped_column(Uuid(as_uuid=False), primary_key=True, default=_uuid) user_id: Mapped[str] = mapped_column( Uuid(as_uuid=False), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True, ) key: Mapped[str] = mapped_column(String(255), nullable=False) value_encrypted: Mapped[str] = mapped_column(Text, nullable=False) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now() ) class MemoryAssociative(Base): """Per-user semantic memory: encrypted content + pgvector embedding for similarity search. Production: ``embedding`` column is ``vector(1536)`` via pgvector. Tests (SQLite): stored as JSON list. """ __tablename__ = "memory_associative" id: Mapped[str] = mapped_column(Uuid(as_uuid=False), primary_key=True, default=_uuid) user_id: Mapped[str] = mapped_column( Uuid(as_uuid=False), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True, ) content_encrypted: Mapped[str] = mapped_column(Text, nullable=False) # vector(1536) via pgvector; SQLite tests use NULL embeddings so no dialect issue. embedding: Mapped[list | None] = mapped_column(Vector(1536), nullable=True) entity_type: Mapped[str | None] = mapped_column(String(100), nullable=True) entity_id: Mapped[str | None] = mapped_column(String(255), nullable=True) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now() ) class MemoryEpisodic(Base): """Per-user session summaries, encrypted at rest. One row per session interaction; used to recall recent conversations. """ __tablename__ = "memory_episodic" id: Mapped[str] = mapped_column(Uuid(as_uuid=False), primary_key=True, default=_uuid) user_id: Mapped[str] = mapped_column( Uuid(as_uuid=False), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True, ) summary_encrypted: Mapped[str] = mapped_column(Text, nullable=False) session_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now() ) class MemoryProactive(Base): """Per-user inferred behavioral patterns, encrypted at rest. Confidence in [0.0, 1.0]; only patterns above threshold are injected. Source: 'inferred' (from episodes) or 'explicit' (user-stated). """ __tablename__ = "memory_proactive" id: Mapped[str] = mapped_column(Uuid(as_uuid=False), primary_key=True, default=_uuid) user_id: Mapped[str] = mapped_column( Uuid(as_uuid=False), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True, ) pattern_encrypted: Mapped[str] = mapped_column(Text, nullable=False) confidence: Mapped[float] = mapped_column(Float, nullable=False, default=0.5) source: Mapped[str] = mapped_column(String(50), nullable=False, default="inferred") created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now() ) class ExtractionQueue(Base): """Batch extraction queue for Free-tier users (Phase 2). Pro/Power/Team users get realtime asyncio.create_task() extraction. Free users get a queue row here; a daily cron (Phase 5) drains it. """ __tablename__ = "extraction_queue" id: Mapped[str] = mapped_column(Uuid(as_uuid=False), primary_key=True, default=_uuid) user_id: Mapped[str] = mapped_column( Uuid(as_uuid=False), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True, ) episode_id: Mapped[str | None] = mapped_column( Uuid(as_uuid=False), nullable=True, ) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now() ) class MemoryRelation(Base): """Per-user entity/relation graph row (Mem0g-light, Phase 3). subject_label/object_label are plaintext entity identifiers (not user content). notes_encrypted is optional Fernet-encrypted per-user commentary. confidence in [0.0, 1.0] — decays 5 % per 30 days since last_confirmed_at. """ __tablename__ = "memory_relations" id: Mapped[str] = mapped_column(Uuid(as_uuid=False), primary_key=True, default=_uuid) user_id: Mapped[str] = mapped_column( Uuid(as_uuid=False), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True, ) subject_label: Mapped[str] = mapped_column(String(128), nullable=False) subject_type: Mapped[str] = mapped_column(String(32), nullable=False) predicate: Mapped[str] = mapped_column(String(64), nullable=False) object_label: Mapped[str] = mapped_column(String(128), nullable=False) object_type: Mapped[str] = mapped_column(String(32), nullable=False) confidence: Mapped[float] = mapped_column(Float, nullable=False, default=0.7) source_episode_id: Mapped[str | None] = mapped_column( Uuid(as_uuid=False), ForeignKey("memory_episodic.id", ondelete="SET NULL"), nullable=True, ) notes_encrypted: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now() ) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now() ) last_confirmed_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), nullable=True ) class Plugin(Base): """Plugin marketplace catalog entry.""" __tablename__ = "plugins" id: Mapped[str] = mapped_column(String(255), primary_key=True) name: Mapped[str] = mapped_column(String(255), nullable=False) description: Mapped[str] = mapped_column(Text, nullable=False) version: Mapped[str] = mapped_column(String(50), nullable=False) author_name: Mapped[str] = mapped_column(String(255), nullable=False) category: Mapped[str] = mapped_column(String(100), nullable=False) price_cents: Mapped[int] = mapped_column(Integer, nullable=False, default=0) permissions: Mapped[str] = mapped_column(Text, nullable=False, default="[]") status: Mapped[str] = mapped_column(String(50), nullable=False, default="pending") s3_package_key: Mapped[str | None] = mapped_column(String(500), nullable=True) install_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) avg_rating: Mapped[float] = mapped_column(Float, nullable=False, default=0.0) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now() )