"""ORM models. The embedding column uses pgvector on Postgres and JSON elsewhere (so unit tests run on sqlite).""" from __future__ import annotations import uuid from datetime import UTC, datetime from sqlalchemy import ( JSON, DateTime, ForeignKey, Integer, String, Text, types, ) from sqlalchemy.orm import Mapped, mapped_column, relationship from app.db import Base EMBEDDING_DIM = 384 def _utcnow() -> datetime: return datetime.now(UTC) class EmbeddingType(types.TypeDecorator): """Vector(384) on Postgres, JSON list[float] on other dialects.""" impl = JSON cache_ok = True def load_dialect_impl(self, dialect): if dialect.name == "postgresql": from pgvector.sqlalchemy import Vector return dialect.type_descriptor(Vector(EMBEDDING_DIM)) return dialect.type_descriptor(JSON()) def process_bind_param(self, value, dialect): if value is None: return None return list(value) def process_result_value(self, value, dialect): if value is None: return None return list(value) class Config(Base): __tablename__ = "config" id: Mapped[int] = mapped_column(Integer, primary_key=True) key: Mapped[str] = mapped_column(String(100), unique=True, index=True) value: Mapped[dict] = mapped_column(JSON, default=dict) class Tenant(Base): """A merchant served by the shared multi-tenant backend.""" __tablename__ = "tenants" id: Mapped[int] = mapped_column(Integer, primary_key=True) slug: Mapped[str] = mapped_column(String(64), unique=True, index=True) name: Mapped[str] = mapped_column(String(255), default="") brand_name: Mapped[str] = mapped_column(String(255), default="Asistente") brand_color: Mapped[str] = mapped_column(String(16), default="#e8491d") welcome: Mapped[str] = mapped_column(Text, default="¡Hola! ¿En qué puedo ayudarte?") # Persona shown in the widget (name + avatar photo/illustration URL) assistant_name: Mapped[str] = mapped_column(String(120), default="") avatar_url: Mapped[str] = mapped_column(String(1000), default="") # Shopify (optional — only for order tracking / live catalog). Secret encrypted. shopify_shop: Mapped[str] = mapped_column(String(255), default="") shopify_client_id: Mapped[str] = mapped_column(String(255), default="") shopify_client_secret_enc: Mapped[str] = mapped_column(Text, default="") shopify_api_version: Mapped[str] = mapped_column(String(20), default="2026-01") read_all_orders: Mapped[bool] = mapped_column(default=False) # Agentic write-actions — opt-in per store (default OFF; merchant enables). allow_cancel: Mapped[bool] = mapped_column(default=False) allow_address_change: Mapped[bool] = mapped_column(default=False) allow_returns: Mapped[bool] = mapped_column(default=False) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow) class KnowledgeSource(Base): __tablename__ = "knowledge_sources" id: Mapped[int] = mapped_column(Integer, primary_key=True) tenant_id: Mapped[int | None] = mapped_column( ForeignKey("tenants.id", ondelete="CASCADE"), nullable=True, index=True ) kind: Mapped[str] = mapped_column(String(10)) # "file" | "url" name: Mapped[str] = mapped_column(String(500)) location: Mapped[str] = mapped_column(String(2000)) # file path or URL status: Mapped[str] = mapped_column(String(20), default="pending") # pending|indexed|error error: Mapped[str | None] = mapped_column(Text, nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow) chunks: Mapped[list[KnowledgeChunk]] = relationship( back_populates="source", cascade="all, delete-orphan" ) class KnowledgeChunk(Base): __tablename__ = "knowledge_chunks" id: Mapped[int] = mapped_column(Integer, primary_key=True) source_id: Mapped[int] = mapped_column( ForeignKey("knowledge_sources.id", ondelete="CASCADE"), index=True ) tenant_id: Mapped[int | None] = mapped_column( ForeignKey("tenants.id", ondelete="CASCADE"), nullable=True, index=True ) ordinal: Mapped[int] = mapped_column(Integer, default=0) text: Mapped[str] = mapped_column(Text) embedding: Mapped[list[float]] = mapped_column(EmbeddingType) meta: Mapped[dict] = mapped_column(JSON, default=dict) source: Mapped[KnowledgeSource] = relationship(back_populates="chunks") class ChatSession(Base): __tablename__ = "chat_sessions" id: Mapped[str] = mapped_column(String(40), primary_key=True, default=lambda: uuid.uuid4().hex) tenant_id: Mapped[int | None] = mapped_column( ForeignKey("tenants.id", ondelete="CASCADE"), nullable=True, index=True ) shop: Mapped[str] = mapped_column(String(255), default="") lang: Mapped[str | None] = mapped_column(String(10), nullable=True) verified: Mapped[bool] = mapped_column(default=False) verified_order_id: Mapped[str | None] = mapped_column(String(80), nullable=True) verify_attempts: Mapped[int] = mapped_column(Integer, default=0) locked_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow) last_seen: Mapped[datetime] = mapped_column( DateTime(timezone=True), default=_utcnow, onupdate=_utcnow ) messages: Mapped[list[ChatMessage]] = relationship( back_populates="session", cascade="all, delete-orphan", order_by="ChatMessage.id", lazy="selectin", ) class Event(Base): """Per-tenant analytics event (chat -> sales funnel).""" __tablename__ = "events" id: Mapped[int] = mapped_column(Integer, primary_key=True) tenant_id: Mapped[int | None] = mapped_column( ForeignKey("tenants.id", ondelete="CASCADE"), nullable=True, index=True ) type: Mapped[str] = mapped_column(String(40), index=True) session_id: Mapped[str | None] = mapped_column(String(40), nullable=True) meta: Mapped[dict] = mapped_column(JSON, default=dict) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), default=_utcnow, index=True ) class StockWatch(Base): """Per-tenant back-in-stock / price-drop capture.""" __tablename__ = "stock_watches" id: Mapped[int] = mapped_column(Integer, primary_key=True) tenant_id: Mapped[int | None] = mapped_column( ForeignKey("tenants.id", ondelete="CASCADE"), nullable=True, index=True ) email: Mapped[str] = mapped_column(String(320)) product_title: Mapped[str] = mapped_column(String(500), default="") variant_id: Mapped[str] = mapped_column(String(40), default="") notified: Mapped[bool] = mapped_column(default=False) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow) class ChatMessage(Base): __tablename__ = "chat_messages" id: Mapped[int] = mapped_column(Integer, primary_key=True) session_id: Mapped[str] = mapped_column( ForeignKey("chat_sessions.id", ondelete="CASCADE"), index=True ) role: Mapped[str] = mapped_column(String(20)) # user|assistant|tool|system content: Mapped[str] = mapped_column(Text) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow) session: Mapped[ChatSession] = relationship(back_populates="messages")