""" Full v1 SQLAlchemy 2.0 ORM schema for DocuVault. All 11 tables declared here: users, quotas, refresh_tokens, folders, documents, topics, document_topics, shares, audit_log, cloud_connections, groups. Key decisions: D-01: Full v1 skeleton in Phase 1 migration D-02: groups table stub (v2 feature, seeded for schema completeness per PROJECT.md) D-03: documents.user_id is nullable in Phase 1 (no auth yet); Phase 2 adds NOT NULL AuditLog note: The metadata column is declared as `metadata_` (ORM attribute name) with `name="metadata"` (DB column name). This is required because `metadata` is a reserved attribute on SQLAlchemy's DeclarativeBase and would cause silent conflicts if used as an attribute name directly. Python compat note: `Optional[X]` is used instead of `X | None` union syntax because the host environment may be Python < 3.10. Both are equivalent. """ from __future__ import annotations import uuid from datetime import datetime from typing import Optional from sqlalchemy import ( Boolean, BigInteger, ForeignKey, Index, String, Text, TIMESTAMP, UniqueConstraint, Integer, ) from sqlalchemy.dialects.postgresql import UUID, INET, JSONB from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column from sqlalchemy.sql import func class Base(DeclarativeBase): pass class User(Base): __tablename__ = "users" id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 ) handle: Mapped[str] = mapped_column(String, unique=True, nullable=False) email: Mapped[str] = mapped_column(String, unique=True, nullable=False) password_hash: Mapped[str] = mapped_column(Text, nullable=False) totp_secret: Mapped[Optional[str]] = mapped_column(Text, nullable=True) totp_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) role: Mapped[str] = mapped_column(String, nullable=False, default="user") is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) password_must_change: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="false") ai_provider: Mapped[Optional[str]] = mapped_column(Text, nullable=True) ai_model: Mapped[Optional[str]] = mapped_column(Text, nullable=True) default_storage_backend: Mapped[str] = mapped_column( String, nullable=False, default="minio" ) pdf_open_mode: Mapped[str] = mapped_column( String, nullable=False, server_default="in_app" ) created_at: Mapped[datetime] = mapped_column( TIMESTAMP(timezone=True), nullable=False, server_default=func.now() ) class Quota(Base): __tablename__ = "quotas" user_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), primary_key=True, ) # 100 MB default free-tier quota (STORE-01); admin can override limit_bytes per user limit_bytes: Mapped[int] = mapped_column( BigInteger, nullable=False, default=104857600 ) used_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0) class RefreshToken(Base): __tablename__ = "refresh_tokens" id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 ) user_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, ) token_hash: Mapped[str] = mapped_column(Text, unique=True, nullable=False) expires_at: Mapped[datetime] = mapped_column( TIMESTAMP(timezone=True), nullable=False ) revoked: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) created_at: Mapped[datetime] = mapped_column( TIMESTAMP(timezone=True), nullable=False, server_default=func.now() ) __table_args__ = (Index("ix_refresh_tokens_user_revoked", "user_id", "revoked"),) class BackupCode(Base): """Single-use backup codes for TOTP recovery (AUTH-02). code_hash stores the Argon2 hash of the original code (never plaintext). used_at is None when the code is unused; set to now() on first use. Verification iterates ALL codes to prevent timing-based enumeration (SEC-06). """ __tablename__ = "backup_codes" id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 ) user_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, ) code_hash: Mapped[str] = mapped_column(Text, nullable=False) used_at: Mapped[Optional[datetime]] = mapped_column( TIMESTAMP(timezone=True), nullable=True ) __table_args__ = (Index("ix_backup_codes_user_id", "user_id"),) class Folder(Base): __tablename__ = "folders" id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 ) user_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, ) parent_id: Mapped[Optional[uuid.UUID]] = mapped_column( UUID(as_uuid=True), ForeignKey("folders.id", ondelete="CASCADE"), nullable=True, ) name: Mapped[str] = mapped_column(Text, nullable=False) created_at: Mapped[datetime] = mapped_column( TIMESTAMP(timezone=True), nullable=False, server_default=func.now() ) __table_args__ = ( UniqueConstraint("user_id", "parent_id", "name", name="uq_folders_user_parent_name"), ) class Document(Base): __tablename__ = "documents" id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 ) # D-03: user_id is NULLABLE in Phase 1 — no auth system yet. # Phase 2 migration adds NOT NULL constraint after users/auth are live. user_id: Mapped[Optional[uuid.UUID]] = mapped_column( UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=True, ) folder_id: Mapped[Optional[uuid.UUID]] = mapped_column( UUID(as_uuid=True), ForeignKey("folders.id", ondelete="SET NULL"), nullable=True, ) # original human-readable filename — stored in DB only, never in the MinIO key filename: Mapped[str] = mapped_column(Text, nullable=False) # MinIO object key: {user_id}/{document_id}/{uuid4()}{ext} object_key: Mapped[str] = mapped_column(Text, nullable=False) content_type: Mapped[str] = mapped_column(Text, nullable=False) size_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0) storage_backend: Mapped[str] = mapped_column(String, nullable=False, default="minio") extracted_text: Mapped[Optional[str]] = mapped_column(Text, nullable=True) status: Mapped[str] = mapped_column(String, nullable=False, default="pending") created_at: Mapped[datetime] = mapped_column( TIMESTAMP(timezone=True), nullable=False, server_default=func.now() ) updated_at: Mapped[datetime] = mapped_column( TIMESTAMP(timezone=True), nullable=False, server_default=func.now() ) __table_args__ = ( Index("ix_documents_user_folder", "user_id", "folder_id"), Index("ix_documents_user_created", "user_id", "created_at"), ) class Topic(Base): __tablename__ = "topics" id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 ) user_id: Mapped[Optional[uuid.UUID]] = mapped_column( UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=True, ) name: Mapped[str] = mapped_column(Text, nullable=False) description: Mapped[str] = mapped_column(Text, nullable=False, default="") color: Mapped[str] = mapped_column(String(7), nullable=False, default="#6366f1") __table_args__ = (UniqueConstraint("user_id", "name", name="uq_topics_user_name"),) class DocumentTopic(Base): __tablename__ = "document_topics" document_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("documents.id", ondelete="CASCADE"), primary_key=True, ) topic_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("topics.id", ondelete="CASCADE"), primary_key=True, ) class Share(Base): __tablename__ = "shares" id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 ) document_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("documents.id", ondelete="CASCADE"), nullable=False, ) owner_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, ) recipient_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, ) permission: Mapped[str] = mapped_column(String, nullable=False, default="view") created_at: Mapped[datetime] = mapped_column( TIMESTAMP(timezone=True), nullable=False, server_default=func.now() ) __table_args__ = ( UniqueConstraint("document_id", "recipient_id", name="uq_shares_document_recipient"), Index("ix_shares_recipient", "recipient_id"), ) class AuditLog(Base): __tablename__ = "audit_log" id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) user_id: Mapped[Optional[uuid.UUID]] = mapped_column( UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True, ) actor_id: Mapped[Optional[uuid.UUID]] = mapped_column( UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True, ) event_type: Mapped[str] = mapped_column(Text, nullable=False) resource_id: Mapped[Optional[uuid.UUID]] = mapped_column( UUID(as_uuid=True), nullable=True ) ip_address: Mapped[Optional[str]] = mapped_column(INET, nullable=True) # ORM attribute is `metadata_` to avoid collision with DeclarativeBase.metadata. # The DB column is named "metadata" via the mapped_column name= kwarg. metadata_: Mapped[Optional[dict]] = mapped_column("metadata", JSONB, nullable=True) created_at: Mapped[datetime] = mapped_column( TIMESTAMP(timezone=True), nullable=False, server_default=func.now() ) __table_args__ = ( Index("ix_audit_user_created", "user_id", "created_at"), Index("ix_audit_event_created", "event_type", "created_at"), ) class CloudConnection(Base): __tablename__ = "cloud_connections" id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 ) user_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, ) provider: Mapped[str] = mapped_column(String, nullable=False) display_name: Mapped[str] = mapped_column(Text, nullable=False) credentials_enc: Mapped[str] = mapped_column(Text, nullable=False) status: Mapped[str] = mapped_column(String, nullable=False, default="ACTIVE") connected_at: Mapped[datetime] = mapped_column( TIMESTAMP(timezone=True), nullable=False, server_default=func.now() ) __table_args__ = (Index("ix_cloud_connections_user", "user_id"),) class CloudItem(Base): """Durable per-item cloud metadata indexed by (connection_id, provider_item_id). Phase 12: metadata browsing only — no MinIO object_key field. Phase 14: extracted_text, analysis_status, semantic_index_status, semantic_index_data are reserved for byte analysis and semantic search. D-18: provider_size never flows to quotas.used_bytes. """ __tablename__ = "cloud_items" id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 ) user_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, ) connection_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("cloud_connections.id", ondelete="CASCADE"), nullable=False, ) provider_item_id: Mapped[str] = mapped_column(Text, nullable=False) parent_ref: Mapped[Optional[str]] = mapped_column(Text, nullable=True) path_snapshot: Mapped[Optional[str]] = mapped_column(Text, nullable=True) name: Mapped[str] = mapped_column(Text, nullable=False) kind: Mapped[str] = mapped_column(String(8), nullable=False) # "file" | "folder" content_type: Mapped[Optional[str]] = mapped_column(Text, nullable=True) # Provider-reported size — never used to update quotas.used_bytes (D-18) provider_size: Mapped[Optional[int]] = mapped_column(BigInteger, nullable=True) etag: Mapped[Optional[str]] = mapped_column(Text, nullable=True) version: Mapped[Optional[str]] = mapped_column(Text, nullable=True) modified_at: Mapped[Optional[datetime]] = mapped_column( TIMESTAMP(timezone=True), nullable=True ) last_seen_at: Mapped[datetime] = mapped_column( TIMESTAMP(timezone=True), nullable=False, server_default=func.now() ) deleted_at: Mapped[Optional[datetime]] = mapped_column( TIMESTAMP(timezone=True), nullable=True ) # Phase 14 reserved fields extracted_text: Mapped[Optional[str]] = mapped_column(Text, nullable=True) analysis_status: Mapped[str] = mapped_column( String(16), nullable=False, default="pending" ) semantic_index_status: Mapped[str] = mapped_column( String(16), nullable=False, default="none" ) semantic_index_data: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True) created_at: Mapped[datetime] = mapped_column( TIMESTAMP(timezone=True), nullable=False, server_default=func.now() ) updated_at: Mapped[datetime] = mapped_column( TIMESTAMP(timezone=True), nullable=False, server_default=func.now() ) __table_args__ = ( UniqueConstraint( "connection_id", "provider_item_id", name="uq_cloud_items_connection_provider_item", ), Index("ix_cloud_items_user_id", "user_id"), Index("ix_cloud_items_connection_id", "connection_id"), Index("ix_cloud_items_connection_parent", "connection_id", "parent_ref"), ) class CloudItemTopic(Base): """Association between CloudItem and Topic — no Document row required.""" __tablename__ = "cloud_item_topics" cloud_item_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("cloud_items.id", ondelete="CASCADE"), primary_key=True, ) topic_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("topics.id", ondelete="CASCADE"), primary_key=True, ) class CloudFolderState(Base): """Per-connection folder freshness row for cached-first navigation. parent_ref = '' represents the connection root (allows unique constraint). refresh_state: "refreshing" | "fresh" | "warning" """ __tablename__ = "cloud_folder_states" id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 ) user_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, ) connection_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("cloud_connections.id", ondelete="CASCADE"), nullable=False, ) # '' for root, provider ref for non-root parent_ref: Mapped[str] = mapped_column(Text, nullable=False, default="") refresh_state: Mapped[str] = mapped_column( String(16), nullable=False, default="fresh" ) last_refreshed_at: Mapped[Optional[datetime]] = mapped_column( TIMESTAMP(timezone=True), nullable=True ) error_code: Mapped[Optional[str]] = mapped_column(String(64), nullable=True) error_message: Mapped[Optional[str]] = mapped_column(Text, nullable=True) created_at: Mapped[datetime] = mapped_column( TIMESTAMP(timezone=True), nullable=False, server_default=func.now() ) updated_at: Mapped[datetime] = mapped_column( TIMESTAMP(timezone=True), nullable=False, server_default=func.now() ) __table_args__ = ( UniqueConstraint( "connection_id", "parent_ref", name="uq_cloud_folder_states_connection_parent", ), Index("ix_cloud_folder_states_connection", "connection_id"), ) class Group(Base): """v2 stub — empty table, seeded for schema completeness (PROJECT.md D-02). Groups are a v2 feature; the table is created in Phase 1 so the schema is complete and future migrations don't need to alter the dependency ordering. No rows will be inserted until Phase 2 or later. """ __tablename__ = "groups" id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 ) name: Mapped[str] = mapped_column(Text, unique=True, nullable=False) created_at: Mapped[datetime] = mapped_column( TIMESTAMP(timezone=True), nullable=False, server_default=func.now() ) class SystemSettings(Base): """AI provider configuration table — one row per provider. Stores the active AI provider's API key (Fernet-encrypted), base URL, model name, and context window size. Only one row has is_active=TRUE at any given time; the admin panel flips all rows atomically. Encryption (D-05): api_key_enc is encrypted with HKDF/Fernet using info=b"ai-provider-settings" for domain separation from cloud credentials (info=b"cloud-credentials"). Master key is settings.cloud_creds_key (env var CLOUD_CREDS_KEY). See services/ai_config.py for the encryption helpers. Design reference: 07-RESEARCH.md Pattern 1. Migration: 0005_system_settings.py. """ __tablename__ = "system_settings" id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 ) provider_id: Mapped[str] = mapped_column(String, nullable=False, unique=True) api_key_enc: Mapped[Optional[str]] = mapped_column(Text, nullable=True) base_url: Mapped[Optional[str]] = mapped_column(Text, nullable=True) model_name: Mapped[str] = mapped_column(Text, nullable=False, default="") context_chars: Mapped[int] = mapped_column(Integer, nullable=False, default=8000) is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) created_at: Mapped[datetime] = mapped_column( TIMESTAMP(timezone=True), nullable=False, server_default=func.now() ) updated_at: Mapped[datetime] = mapped_column( TIMESTAMP(timezone=True), nullable=False, server_default=func.now() ) __table_args__ = ( UniqueConstraint("provider_id", name="uq_system_settings_provider_id"), )