"""SQLAlchemy 2 declarative models — the 7 production tables. Tables: users, documents, reports, jobs, service_tokens, invoices (stub), passkey_credentials. See docs/ARCHITECTURE.md §7. UUIDs default to gen_random_uuid() server-side (built into Postgres 13+, no extension needed for pg16). """ from __future__ import annotations import datetime as dt import uuid from sqlalchemy import ( BigInteger, Boolean, CheckConstraint, DateTime, ForeignKey, Index, Integer, String, Text, UniqueConstraint, func, text, ) from sqlalchemy.dialects.postgresql import JSONB, UUID from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship class Base(DeclarativeBase): """Declarative base for all models.""" def _now() -> dt.datetime: return dt.datetime.now(tz=dt.UTC) class User(Base): __tablename__ = "users" id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), primary_key=True, server_default=text("gen_random_uuid()"), ) telegram_id: Mapped[int | None] = mapped_column(BigInteger, unique=True) email: Mapped[str | None] = mapped_column(Text, unique=True) name: Mapped[str | None] = mapped_column(Text) password_hash: Mapped[str | None] = mapped_column(Text) password_reset_token_hash: Mapped[str | None] = mapped_column(Text) password_reset_expires_at: Mapped[dt.datetime | None] = mapped_column(DateTime(timezone=True)) magic_link_token_hash: Mapped[str | None] = mapped_column(Text) magic_link_expires_at: Mapped[dt.datetime | None] = mapped_column(DateTime(timezone=True)) is_active: Mapped[bool] = mapped_column( Boolean, nullable=False, default=True, server_default=text("true") ) role: Mapped[str] = mapped_column( String, nullable=False, default="user", server_default=text("'user'") ) telegram_profile_json: Mapped[str | None] = mapped_column(Text) telegram_verified: Mapped[bool] = mapped_column( Boolean, nullable=False, default=True, server_default=text("true") ) telegram_bound_at: Mapped[dt.datetime | None] = mapped_column(DateTime(timezone=True)) created_at: Mapped[dt.datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now() ) credits_left: Mapped[int] = mapped_column(Integer, nullable=False, default=0) __table_args__ = ( CheckConstraint("credits_left >= 0", name="users_credits_nonneg"), # A user must have at least one identity anchor. CheckConstraint( "telegram_id IS NOT NULL OR email IS NOT NULL", name="users_identity_present", ), CheckConstraint("role IN ('user', 'admin')", name="users_role_check"), ) documents: Mapped[list[Document]] = relationship( back_populates="user", cascade="all, delete-orphan" ) api_keys: Mapped[list[ApiKey]] = relationship( back_populates="user", cascade="all, delete-orphan" ) passkey_credentials: Mapped[list[PasskeyCredential]] = relationship( back_populates="user", cascade="all, delete-orphan" ) class PasskeyCredential(Base): """Registered WebAuthn credential (passkey) bound to a user. `credential_id`/`public_key` are base64url-encoded bytes as produced by the webauthn helpers; the raw bytes never touch the DB. """ __tablename__ = "passkey_credentials" id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), primary_key=True, server_default=text("gen_random_uuid()"), ) user_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, ) credential_id: Mapped[str] = mapped_column(Text, nullable=False, unique=True) public_key: Mapped[str] = mapped_column(Text, nullable=False) sign_count: Mapped[int] = mapped_column( BigInteger, nullable=False, default=0, server_default=text("0") ) device_name: Mapped[str | None] = mapped_column(Text) created_at: Mapped[dt.datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now() ) last_used_at: Mapped[dt.datetime | None] = mapped_column(DateTime(timezone=True)) user: Mapped[User] = relationship(back_populates="passkey_credentials") __table_args__ = ( CheckConstraint("sign_count >= 0", name="passkey_credentials_sign_count_nonneg"), Index("passkey_credentials_user_idx", "user_id"), ) class Document(Base): __tablename__ = "documents" id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), primary_key=True, server_default=text("gen_random_uuid()"), ) user_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, ) s3_key: Mapped[str] = mapped_column(Text, nullable=False) extracted_s3_key: Mapped[str | None] = mapped_column(Text) filename: Mapped[str] = mapped_column(Text, nullable=False) mime: Mapped[str] = mapped_column(Text, nullable=False) bytes_: Mapped[int] = mapped_column("bytes", BigInteger, nullable=False, default=0) status: Mapped[str] = mapped_column( String, nullable=False, default="queued", server_default=text("'queued'"), ) stage: Mapped[str | None] = mapped_column(Text) refunded: Mapped[bool] = mapped_column( Boolean, nullable=False, default=False, server_default=text("false") ) created_at: Mapped[dt.datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now() ) updated_at: Mapped[dt.datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=_now ) user: Mapped[User] = relationship(back_populates="documents") report: Mapped[Report | None] = relationship( back_populates="document", cascade="all, delete-orphan", uselist=False ) jobs: Mapped[list[Job]] = relationship(back_populates="document", cascade="all, delete-orphan") api_key_requests: Mapped[list[ApiKeyRequest]] = relationship( back_populates="document", cascade="all, delete-orphan" ) __table_args__ = ( CheckConstraint( "status IN ('queued','extracting','ocr','analyzing','done','failed')", name="documents_status_check", ), Index("documents_user_created_idx", "user_id", "created_at"), Index("documents_status_idx", "status"), ) class Report(Base): __tablename__ = "reports" id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), primary_key=True, server_default=text("gen_random_uuid()"), ) document_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("documents.id", ondelete="CASCADE"), nullable=False, unique=True, ) content_json: Mapped[dict[str, object]] = mapped_column(JSONB, nullable=False) markdown: Mapped[str] = mapped_column(Text, nullable=False) model_used: Mapped[str | None] = mapped_column(Text) prompt_tokens: Mapped[int] = mapped_column(Integer, nullable=False, default=0) eval_tokens: Mapped[int] = mapped_column(Integer, nullable=False, default=0) latency_ms: Mapped[int] = mapped_column(Integer, nullable=False, default=0) created_at: Mapped[dt.datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now() ) document: Mapped[Document] = relationship(back_populates="report") class Job(Base): __tablename__ = "jobs" id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), primary_key=True, server_default=text("gen_random_uuid()"), ) document_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("documents.id", ondelete="CASCADE"), nullable=False, ) correlation_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False) queue: Mapped[str] = mapped_column(String, nullable=False) attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=0) max_attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=5) last_failure_class: Mapped[str | None] = mapped_column(Text) last_error: Mapped[str | None] = mapped_column(Text) dlq: Mapped[bool] = mapped_column( Boolean, nullable=False, default=False, server_default=text("false") ) status: Mapped[str] = mapped_column( String, nullable=False, default="pending", server_default=text("'pending'"), ) created_at: Mapped[dt.datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now() ) updated_at: Mapped[dt.datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=_now ) document: Mapped[Document] = relationship(back_populates="jobs") __table_args__ = ( CheckConstraint("queue IN ('extract','analyze')", name="jobs_queue_check"), CheckConstraint( "status IN ('pending','running','retrying','dlq','done')", name="jobs_status_check", ), Index("jobs_correlation_idx", "correlation_id"), Index("jobs_document_idx", "document_id"), ) class ServiceToken(Base): __tablename__ = "service_tokens" id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), primary_key=True, server_default=text("gen_random_uuid()"), ) name: Mapped[str] = mapped_column(Text, unique=True, nullable=False) token_hash: Mapped[str] = mapped_column(Text, nullable=False) adapter: Mapped[str] = mapped_column(String, nullable=False) revoked: Mapped[bool] = mapped_column( Boolean, nullable=False, default=False, server_default=text("false") ) created_at: Mapped[dt.datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now() ) last_used_at: Mapped[dt.datetime | None] = mapped_column(DateTime(timezone=True)) __table_args__ = ( CheckConstraint("adapter IN ('bot','web','cli')", name="service_tokens_adapter_check"), ) class ApiKey(Base): """B2B API key: opaque bearer secret hashed via SHA-256.""" __tablename__ = "api_keys" id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), primary_key=True, server_default=text("gen_random_uuid()"), ) user_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, ) name: Mapped[str] = mapped_column(Text, nullable=False) key_hash: Mapped[str] = mapped_column(Text, nullable=False) rate_limit_rps: Mapped[int] = mapped_column( Integer, nullable=False, default=3, server_default=text("3") ) monthly_quota: Mapped[int | None] = mapped_column(Integer) monthly_used: Mapped[int] = mapped_column( Integer, nullable=False, default=0, server_default=text("0") ) resets_at: Mapped[dt.datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now() ) revoked: Mapped[bool] = mapped_column( Boolean, nullable=False, default=False, server_default=text("false") ) created_at: Mapped[dt.datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now() ) last_used_at: Mapped[dt.datetime | None] = mapped_column(DateTime(timezone=True)) user: Mapped[User] = relationship(back_populates="api_keys") requests: Mapped[list[ApiKeyRequest]] = relationship( back_populates="api_key", cascade="all, delete-orphan" ) __table_args__ = ( CheckConstraint("rate_limit_rps > 0", name="api_keys_rate_limit_positive"), CheckConstraint("monthly_used >= 0", name="api_keys_monthly_used_nonneg"), CheckConstraint( "monthly_quota IS NULL OR monthly_quota >= 0", name="api_keys_monthly_quota_nonneg", ), Index("api_keys_user_idx", "user_id"), Index("api_keys_hash_idx", "key_hash"), UniqueConstraint("user_id", "name", name="api_keys_user_name_unique"), ) class ApiKeyRequest(Base): """One B2B API call = one request row for usage/dashboard.""" __tablename__ = "api_key_requests" id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), primary_key=True, server_default=text("gen_random_uuid()"), ) api_key_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("api_keys.id", ondelete="CASCADE"), nullable=False, ) document_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("documents.id", ondelete="CASCADE"), nullable=False, ) created_at: Mapped[dt.datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now() ) api_key: Mapped[ApiKey] = relationship(back_populates="requests") document: Mapped[Document] = relationship(back_populates="api_key_requests") __table_args__ = ( Index("api_key_requests_key_created_idx", "api_key_id", text("created_at DESC")), ) class Invoice(Base): """STUB — schema forward-compatible for ЮKassa. No payment logic yet.""" __tablename__ = "invoices" id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), primary_key=True, server_default=text("gen_random_uuid()"), ) user_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, ) amount: Mapped[int] = mapped_column(Integer, nullable=False) # kopecks status: Mapped[str] = mapped_column( String, nullable=False, default="draft", server_default=text("'draft'") ) provider: Mapped[str | None] = mapped_column(Text) external_id: Mapped[str | None] = mapped_column(Text) created_at: Mapped[dt.datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now() ) paid_at: Mapped[dt.datetime | None] = mapped_column(DateTime(timezone=True)) __table_args__ = ( CheckConstraint( "status IN ('draft','pending','succeeded','cancelled','refunded')", name="invoices_status_check", ), Index("invoices_user_idx", "user_id", "created_at"), )