commit b14c6b508a861ea1ad7c63c0dd40aba0ebf9c599 Author: saquib Date: Tue Sep 8 11:00:05 2026 +0530 latest code diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..8a11324 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,54 @@ +.git +.gitignore +.gitattributes +.env +.env.* +!.env.example +__pycache__/ +*.py[cod] +*$py.class +*.so +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.coverage +.coverage.* +htmlcov/ +.pytype/ +.pyre/ +*.egg-info/ +dist/ +build/ +.eggs/ +.venv/ +venv/ +env/ +ENV/ +.vscode/ +.idea/ +*.swp +*.swo +*~ +.DS_Store +Thumbs.db +storage_drive/ +storage/ +uploads/ +downloads/ +temp/ +tmp/ +Dockerfile.* +docker-compose*.yml +.docker/ +*.log +logs/ +.pytest_cache/ +htmlcov/ +coverage.xml +redis_data/ +qdrant_data/ +clamav_data/ +*.md +requirements*.txt +openapi.json +.pre-commit-config.yaml diff --git a/.env.development b/.env.development new file mode 100644 index 0000000..da7bce5 --- /dev/null +++ b/.env.development @@ -0,0 +1,68 @@ +APP_ENV=development +HOST=0.0.0.0 +PORT=20001 +DEBUG=False +DB_HOST=106.51.105.22 +DB_PORT=5432 +DB_NAME=docqube_development +DB_USER=docqube_user +DB_PASSWORD=d20kMfypQE8Z +DB_SSLMODE=disable +PGBOUNCER_PORT=6432 +SAAS_TRUST_SECRET=docqube_trust_secret_2026 +SAAS_BASE_URL=http://localhost:8000 +SAAS_PUBLIC_KEY="" +APP_SECRET=Pk7rcnYeSE-9vgTuevzfvKJ5YAs6Bj0QjgFxyJW00oKMnmPlRxUfU8lAm33GAgxL +ALGORITHM=HS256 +ACCESS_TOKEN_EXPIRE_MINUTES=60 +REDIS_HOST=redis +REDIS_PORT=6379 +REDIS_PASSWORD=JVCzB612F573hYwqZZ +CLAMAV_HOST=clamav +CLAMAV_PORT=3310 +CLAMAV_TIMEOUT_SECONDS=20 +CLAMAV_CHUNK_SIZE=1048576 +STORAGE_DRIVE_DIR=storage_drive +API_BASE_URL=https://docqube.maskantech.in +GOOGLE_CLIENT_ID=989963514121-nh9ibk3e4u1dh6jp4ql17tue7f9suk38.apps.googleusercontent.com +GOOGLE_CLIENT_SECRET=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 +DEEPSEEK_API_KEY=sk-95235bd6d3b440d6b6e25bd722813f3c +DEEPSEEK_MODEL=deepseek-chat +DEEPSEEK_BASE_URL=https://api.deepseek.com/v1 +LLM_TEMPERATURE=0.35 +MAX_RESPONSE_TOKENS=1500 +HF_HUB_OFFLINE=0 +TRANSFORMERS_OFFLINE=0 +VECTOR_DB=qdrant +QDRANT_HOST=qdrant +QDRANT_PORT=6333 +QDRANT_COLLECTION=docqube_collection +RERANKER_MODEL=cross-encoder/ms-marco-MiniLM-L-12-v2 +EMBEDDING_MODEL=BAAI/bge-base-en-v1.5 +CHUNK_SIZE=850 +CHUNK_OVERLAP=180 +TOP_K_QA=3 +TOP_K_SUMMARY=4 +MAX_CONTEXT_CHARS=4000 +MIN_SIMILARITY_SCORE=0.18 +LOG_LEVEL=WARNING +CORS_ORIGINS=https://docqube.maskantech.in +SMTP_HOST=smtp.gmail.com +SMTP_PORT=465 +SMTP_USER=info.maskantech@gmail.com +SMTP_PASSWORD=tuthpljtkrwchgxd +MAIL_FROM=info.maskantech@gmail.com +SMTP_SECURE=True +FRONTEND_URL=https://docqube.maskantech.in +CHAT_REDIS_MAX_MESSAGES=10 +CHAT_REDIS_TTL_SECONDS=86400 +CHAT_DAILY_CREDITS_LIMIT=1000 +DOCUSEAL_API_URL=https://api.docuseal.com +DOCUSEAL_API_KEY=prUReaHxSnChGoeeL7xnf5kxsXVLAzfCbn1RvbhnoMz +DOCUSEAL_ACCOUNT_EMAIL=info.maskantech+test@gmail.com +ZOHO_CLIENT_ID="1000.EX179O66BC7FKAWMR17Q4V4ZV4OW9K" +ZOHO_CLIENT_SECRET="9c8e6414aebdbed66b5bc5f9c2a866fb4b4b159a93" +ZOHO_REFRESH_TOKEN="1000.58b1696404de858cd887a29005879ed5.487b427449d87b3fd0f1cb883f4d1b7d" +ZOHO_WEBHOOK_SECRET="0iWuLqg4z41RMWmgI6ql5qbPE3fCE4tc" +ZOHO_DOMAIN="in" +MAX_ACTIVE_DEVICES=5 \ No newline at end of file diff --git a/.env.localdev b/.env.localdev new file mode 100644 index 0000000..38ff103 --- /dev/null +++ b/.env.localdev @@ -0,0 +1,132 @@ +# Environment Configuration +APP_ENV=localdev + +# Server Configuration +HOST=127.0.0.1 +PORT=20001 +DEBUG=True + +# DB_HOST=localhost +# DB_PORT=5432 +# DB_NAME=new-base +# DB_USER=postgres +# DB_PASSWORD=ribai123 +# DB_SSLMODE=disable +# PGBOUNCER_PORT=5432 +DB_HOST=106.51.105.22 +DB_PORT=5432 +DB_NAME=docqube_local +DB_USER=docqube_user +DB_PASSWORD=d20kMfypQE8Z +DB_SSLMODE=disable +PGBOUNCER_PORT=6432 + +# Security Configuration +APP_SECRET=VBN20DIyTbuqRJKKpxB2C4aDVYRUENa1rdzFXPLXwxA +ALGORITHM=HS256 +ACCESS_TOKEN_EXPIRE_MINUTES=60 + +# Redis Configuration +REDIS_HOST=106.51.105.22 +REDIS_PORT=6388 +REDIS_PASSWORD=JVCzB612F573hYwqZZ + +CLAMAV_HOST=106.51.105.22 +CLAMAV_PORT=3310 +CLAMAV_TIMEOUT_SECONDS=20 +CLAMAV_CHUNK_SIZE=1048576 +STORAGE_DRIVE_DIR=storage_drive +API_BASE_URL=http://localhost:20001 + + +#Saas +SAAS_TRUST_SECRET=VBN20DIyTbuqRJKKpxB2C4aDVYRUENa1rdzFXPLXwxA +SAAS_BASE_URL=http://localhost:8000 +SAAS_PUBLIC_KEY="" + +# Google Auth Configuration +GOOGLE_CLIENT_ID=989963514121-nh9ibk3e4u1dh6jp4ql17tue7f9suk38.apps.googleusercontent.com +GOOGLE_CLIENT_SECRET=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 + +# AI & Models Configuration +# ========================= +# AI (DeepSeek) +# ========================= +DEEPSEEK_API_KEY=sk-95235bd6d3b440d6b6e25bd722813f3c +DEEPSEEK_MODEL=deepseek-chat +DEEPSEEK_BASE_URL=https://api.deepseek.com/v1 +LLM_TEMPERATURE=0.35 +MAX_RESPONSE_TOKENS=1500 + +# Extraction / NVIDIA AI Configuration +EXTRACTION_API_KEY="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" +EXTRACTION_BASE_URL=https://integrate.api.nvidia.com/v1 +EXTRACTION_MODEL=mistralai/mistral-medium-3-instruct +EXTRACTION_VISION_MODEL=meta/llama-3.2-90b-vision-instruct + + +# Force Offline Mode for local models (Prevents startup delays/HF checks) +HF_HUB_OFFLINE=0 +TRANSFORMERS_OFFLINE=0 + +# Vector DB Configuration +VECTOR_DB=qdrant +QDRANT_HOST=localhost +QDRANT_PORT=6333 +QDRANT_COLLECTION=docqube_collection +RERANKER_MODEL=cross-encoder/ms-marco-MiniLM-L-12-v2 + +# ========================= +# EMBEDDING +# ========================= +EMBEDDING_MODEL=BAAI/bge-base-en-v1.5 +# RAG SETTINGS +# ========================= +CHUNK_SIZE=850 +CHUNK_OVERLAP=180 +TOP_K_QA=3 +TOP_K_SUMMARY=4 +MAX_CONTEXT_CHARS=4000 +MIN_SIMILARITY_SCORE=0.18 + +# Logging Configuration +LOG_LEVEL=INFO + +# CORS Configuration +CORS_ORIGINS=* + +# Email Configuration +SMTP_HOST=smtp.gmail.com +SMTP_PORT=465 +SMTP_USER=info.maskantech@gmail.com +SMTP_PASSWORD=ftqjghdbthlwpnsh +MAIL_FROM=info.maskantech@gmail.com +SMTP_SECURE=True + + +# Frontend Configuration +FRONTEND_URL=http://localhost:5173 + +# whisper Model Configuration +WHISPER_MODEL=small + + +# Chat Redis Settings +CHAT_REDIS_MAX_MESSAGES=10 +CHAT_REDIS_TTL_SECONDS=86400 +CHAT_DAILY_CREDITS_LIMIT=1000 + +# Docuseal API Settings +# DOCUSEAL_API_URL=https://api.docuseal.com +# DOCUSEAL_API_KEY=prUReaHxSnChGoeeL7xnf5kxsXVLAzfCbn1RvbhnoMz +# DOCUSEAL_ACCOUNT_EMAIL=info.maskantech+test@gmail.com + +#ZOHO API Settings +# ZOHO_CLIENT_ID="1000.EX179O66BC7FKAWMR17Q4V4ZV4OW9K" +# ZOHO_CLIENT_SECRET="9c8e6414aebdbed66b5bc5f9c2a866fb4b4b159a93" +# ZOHO_REFRESH_TOKEN="1000.58b1696404de858cd887a29005879ed5.487b427449d87b3fd0f1cb883f4d1b7d" +ZOHO_WEBHOOK_SECRET="0iWuLqg4z41RMWmgI6ql5qbPE3fCE4tc" +ZOHO_DOMAIN="in" +MAX_ACTIVE_DEVICES=10 + + diff --git a/.env.production b/.env.production new file mode 100644 index 0000000..c78fa98 --- /dev/null +++ b/.env.production @@ -0,0 +1,97 @@ +# Environment Configuration +APP_ENV=production + +# Server Configuration +HOST=127.0.0.1 +PORT=20001 +DEBUG=True + +DB_HOST=127.0.0.1 +DB_PORT=5432 +DB_NAME=prod_docqube +DB_USER=prod_user +DB_PASSWORD='GO5cA8hK3@47Uq9im44' +DB_SSLMODE=disable +PGBOUNCER_PORT=6432 + + +#saas +SAAS_TRUST_SECRET=docqube_trust_secret_2026 +SAAS_BASE_URL=http://localhost:8000 +SAAS_PUBLIC_KEY="" + +# Security Configuration +APP_SECRET=VBN20DIyTbuqRJKKpxB2C4aDVYRUENa1rdzFXPLXwxA +ALGORITHM=HS256 +ACCESS_TOKEN_EXPIRE_MINUTES=60 + +# Redis Configuration +REDIS_HOST=31.57.228.186 +REDIS_PORT=6379 +REDIS_PASSWORD=TP0TmTqyWzBIuvrE + +CLAMAV_HOST=31.57.228.186 +CLAMAV_PORT=3310 +CLAMAV_TIMEOUT_SECONDS=20 +CLAMAV_CHUNK_SIZE=1048576 +STORAGE_DRIVE_DIR=storage_drive +API_BASE_URL=https://api.docqube.com + +# Google Auth Configuration +GOOGLE_CLIENT_ID=989963514121-nh9ibk3e4u1dh6jp4ql17tue7f9suk38.apps.googleusercontent.com +GOOGLE_CLIENT_SECRET=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 + + +# ========================= +# AI (DeepSeek) +# ========================= +DEEPSEEK_API_KEY=sk-f51cbc9eca6b466d949bfdd60a892a97 +DEEPSEEK_MODEL=deepseek-chat +DEEPSEEK_BASE_URL=https://api.deepseek.com/v1 + +# ========================= +# RAG +# ========================= +EMBEDDING_MODEL=BAAI/bge-base-en-v1.5 +CHUNK_SIZE=300 +CHUNK_OVERLAP=80 +TOP_K=3 +# Logging Configuration +LOG_LEVEL=INFO + +# CORS Configuration +CORS_ORIGINS=https://docqube.com,https://api.docqube.com +COOKIE_DOMAIN=.docqube.com +DISABLE_CSRF=True + +# Email Configuration +SMTP_HOST=smtp.hostinger.com +SMTP_PORT=465 +SMTP_USER=support@docqube.com +SMTP_PASSWORD=DQsupport@765$ +MAIL_FROM=support@docqube.com +SMTP_SECURE=True + +# Frontend Configuration +FRONTEND_URL=https://docqube.com + +# whisper Model Configuration +WHISPER_MODEL=small + +# Chat Redis Settings +CHAT_REDIS_MAX_MESSAGES=10 +CHAT_REDIS_TTL_SECONDS=86400 +CHAT_DAILY_CREDITS_LIMIT=1000 + + +# Docuseal API Settings +DOCUSEAL_API_URL=https://api.docuseal.com +DOCUSEAL_API_KEY=prUReaHxSnChGoeeL7xnf5kxsXVLAzfCbn1RvbhnoMz +DOCUSEAL_ACCOUNT_EMAIL=info.maskantech+test@gmail.com + +#ZOHO API Settings +ZOHO_CLIENT_ID="1000.EX179O66BC7FKAWMR17Q4V4ZV4OW9K" +ZOHO_CLIENT_SECRET="9c8e6414aebdbed66b5bc5f9c2a866fb4b4b159a93" +ZOHO_REFRESH_TOKEN="1000.58b1696404de858cd887a29005879ed5.487b427449d87b3fd0f1cb883f4d1b7d" +ZOHO_WEBHOOK_SECRET="0iWuLqg4z41RMWmgI6ql5qbPE3fCE4tc" +ZOHO_DOMAIN="in" \ No newline at end of file diff --git a/.env.test b/.env.test new file mode 100644 index 0000000..0bda686 --- /dev/null +++ b/.env.test @@ -0,0 +1,68 @@ +APP_ENV=test +HOST=0.0.0.0 +PORT=20002 +DEBUG=False +DB_HOST=106.51.105.22 +DB_PORT=5432 +DB_NAME=docqube_test +DB_USER=docqube_user +DB_PASSWORD=d20kMfypQE8Z +DB_SSLMODE=disable +PGBOUNCER_PORT=6432 +SAAS_TRUST_SECRET=docqube_trust_secret_2026 +SAAS_BASE_URL=http://localhost:8000 +SAAS_PUBLIC_KEY="" +APP_SECRET=VBN20DIyTbuqRJKKpxB2C4aDVYRUENa1rdzFXPLXwxA +ALGORITHM=HS256 +ACCESS_TOKEN_EXPIRE_MINUTES=60 +REDIS_HOST=redis +REDIS_PORT=6379 +REDIS_PASSWORD=JVCzB612F573hYwqZZ +CLAMAV_HOST=clamav +CLAMAV_PORT=3310 +CLAMAV_TIMEOUT_SECONDS=20 +CLAMAV_CHUNK_SIZE=1048576 +STORAGE_DRIVE_DIR=storage_drive +API_BASE_URL=https://docqubeapi-test.maskantech.in +GOOGLE_CLIENT_ID=989963514121-nh9ibk3e4u1dh6jp4ql17tue7f9suk38.apps.googleusercontent.com +GOOGLE_CLIENT_SECRET=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 +DEEPSEEK_API_KEY=sk-43e2e5e6d450447687b3b664f3a91f33 +DEEPSEEK_MODEL=deepseek-chat +DEEPSEEK_BASE_URL=https://api.deepseek.com/v1 +LLM_TEMPERATURE=0.35 +MAX_RESPONSE_TOKENS=1500 +HF_HUB_OFFLINE=0 +TRANSFORMERS_OFFLINE=0 +VECTOR_DB=qdrant +QDRANT_HOST=qdrant +QDRANT_PORT=6333 +QDRANT_COLLECTION=docqube_collection +RERANKER_MODEL=cross-encoder/ms-marco-MiniLM-L-12-v2 +EMBEDDING_MODEL=BAAI/bge-base-en-v1.5 +CHUNK_SIZE=850 +CHUNK_OVERLAP=180 +TOP_K_QA=3 +TOP_K_SUMMARY=4 +MAX_CONTEXT_CHARS=4000 +MIN_SIMILARITY_SCORE=0.18 +LOG_LEVEL=INFO +CORS_ORIGINS=https://docqube-test.maskantech.in,https://docqubeapp-test.maskantech.in,https://docqubeapi-test.maskantech.in +SMTP_HOST=smtp.gmail.com +SMTP_PORT=465 +SMTP_USER=info.maskantech@gmail.com +SMTP_PASSWORD=tuthpljtkrwchgxd +MAIL_FROM=info.maskantech@gmail.com +SMTP_SECURE=True +FRONTEND_URL=https://docqube-test.maskantech.in,https://docqubeapp-test.maskantech.in,https://docqubeapi-test.maskantech.in +CHAT_REDIS_MAX_MESSAGES=10 +CHAT_REDIS_TTL_SECONDS=86400 +CHAT_DAILY_CREDITS_LIMIT=1000 +DOCUSEAL_API_URL=https://api.docuseal.com +DOCUSEAL_API_KEY=prUReaHxSnChGoeeL7xnf5kxsXVLAzfCbn1RvbhnoMz +DOCUSEAL_ACCOUNT_EMAIL=info.maskantech+test@gmail.com +ZOHO_CLIENT_ID="1000.EX179O66BC7FKAWMR17Q4V4ZV4OW9K" +ZOHO_CLIENT_SECRET="9c8e6414aebdbed66b5bc5f9c2a866fb4b4b159a93" +ZOHO_REFRESH_TOKEN="1000.58b1696404de858cd887a29005879ed5.487b427449d87b3fd0f1cb883f4d1b7d" +ZOHO_WEBHOOK_SECRET="0iWuLqg4z41RMWmgI6ql5qbPE3fCE4tc" +ZOHO_DOMAIN="in" +MAX_ACTIVE_DEVICES=5 \ No newline at end of file diff --git a/.env.uat b/.env.uat new file mode 100644 index 0000000..ca5430d --- /dev/null +++ b/.env.uat @@ -0,0 +1,68 @@ +# Environment Configuration +APP_ENV=uat + +# Server Configuration +HOST=127.0.0.1 +PORT=20001 +DEBUG=True + +DB_HOST=106.51.105.22 +DB_PORT=5432 +DB_NAME=docqube_uat +DB_USER=docqube_user +DB_PASSWORD=d20kMfypQE8Z +DB_SSLMODE=disable +PGBOUNCER_PORT=6432 + +# Security Configuration +APP_SECRET=VBN20DIyTbuqRJKKpxB2C4aDVYRUENa1rdzFXPLXwxA +ALGORITHM=HS256 +ACCESS_TOKEN_EXPIRE_MINUTES=60 + +# Redis Configuration +REDIS_HOST=106.51.105.22 +REDIS_PORT=6391 +REDIS_PASSWORD=JVCzB612F573hYwqZZ + +CLAMAV_HOST=106.51.105.22 +CLAMAV_PORT=3310 +CLAMAV_TIMEOUT_SECONDS=20 +CLAMAV_CHUNK_SIZE=1048576 +STORAGE_DRIVE_DIR=storage_drive +API_BASE_URL=http://localhost:20001 + +# Google Auth Configuration +GOOGLE_CLIENT_ID=989963514121-nh9ibk3e4u1dh6jp4ql17tue7f9suk38.apps.googleusercontent.com +GOOGLE_CLIENT_SECRET=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 + +# AI & Models Configuration +OPENAI_API_KEY="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" +OPENAI_BASE_URL=https://integrate.api.nvidia.com/v1 +OPENAI_MODEL=mistralai/mistral-medium-3-instruct +OPENAI_VISION_MODEL=meta/llama-3.2-90b-vision-instruct +MODEL_SERVER_URL=http://localhost:9000 + +# Logging Configuration +LOG_LEVEL=INFO + +# CORS Configuration +CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173 + +# Email Configuration +SMTP_HOST=smtp.hostinger.com +SMTP_PORT=465 +SMTP_USER=info@maskantech.in +SMTP_PASSWORD=Infomaskan@123 +MAIL_FROM=info@maskantech.in +SMTP_SECURE=True + +# Frontend Configuration +FRONTEND_URL=http://localhost:5173 + +# whisper Model Configuration +WHISPER_MODEL=small + +# Chat Redis Settings +CHAT_REDIS_MAX_MESSAGES=10 +CHAT_REDIS_TTL_SECONDS=86400 +CHAT_DAILY_CREDITS_LIMIT=1000 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..67179d7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,79 @@ +# Python bytecode and caches +__pycache__/ +*.py[cod] +*$py.class +*.so + +# Virtual environments +.venv/ +venv/ +env/ +ENV/ +env1/ + +# Environment files and local secrets +!.env.example + +# Test, coverage, and static-analysis output +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.pytype/ +.pyre/ +.coverage +.coverage.* +coverage.xml +htmlcov/ +tests/ + +# Build and packaging output +build/ +dist/ +*.egg-info/ +.eggs/ +pip-wheel-metadata/ + +# Application runtime data +app/temp_uploads/* +storage_drive/ +storage/ +uploads/ +downloads/ +temp/ +tmp/ +logs/ +*.log + +# Local ML and Hugging Face caches +.cache/ +cache/ +model_cache/ +hub/ +!app/modules/**/models/ +!app/**/models/ + +# Local Docker/service data +.docker/ +redis_data/ +qdrant_data/ +clamav_data/ + +# IDE and editor files +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS files +.DS_Store +Thumbs.db +Desktop.ini +# Local rework environment — never commit +.env.rework +.env.rework_test +app/temp_uploads/* +!app/temp_uploads/.gitkeep +.env.rework_rls +.gitea/workflows/ +.github/workflows/ \ No newline at end of file diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..81ce884 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,58 @@ +# B4.3 — the fast gates, before the commit rather than after the push. +# +# Only checks that run in about a second belong here. The characterization +# suite, the cross-tenant probes and the query budgets need a database and take +# a minute; they stay in CI. A pre-commit hook that takes a minute gets +# `--no-verify`d, and then none of it runs. +# +# pip install pre-commit && pre-commit install +# +# Everything here is also enforced in CI, so an uninstalled hook is a slower +# feedback loop rather than a hole. + +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-json + - id: check-toml + - id: check-merge-conflict + - id: check-added-large-files + args: [--maxkb=512] + - id: detect-private-key + + - repo: local + hooks: + # The three rules that are cheap to check and expensive to discover late. + - id: no-exception-text-in-responses + name: No exception text in HTTP responses + entry: python -m pytest tests/probes/test_error_disclosure.py::test_no_exception_text_is_returned_to_callers -q + language: system + pass_filenames: false + files: ^app/.*\.py$ + description: > + `detail=f"...{e}"` sends internal errors to the caller. It reached an + unauthenticated endpoint once already. + + - id: transaction-discipline + name: Route handlers do not commit + entry: python -m pytest tests/probes/test_transaction_discipline.py -q + language: system + pass_filenames: false + files: ^app/.*\.py$ + description: > + The request owns the transaction. A handler that commits splits one + request into several and leaves partial state behind on failure. + + - id: layering + name: Import layering contracts + entry: lint-imports + language: system + pass_filenames: false + files: ^app/.*\.py$ + description: > + routes → controllers → services → repositories → models, one + direction only. diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..e028118 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.11.8 \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8b0ce6e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,80 @@ +ARG PYTHON_VERSION=3.11.8 + +FROM python:${PYTHON_VERSION}-slim-bookworm AS builder + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + UV_COMPILE_BYTECODE=1 \ + UV_LINK_MODE=copy \ + UV_PYTHON_DOWNLOADS=0 + +COPY --from=ghcr.io/astral-sh/uv:0.12.8 /uv /uvx /bin/ + +WORKDIR /app + +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + build-essential \ + pkg-config \ + libcairo2-dev \ + libpango1.0-dev \ + libgdk-pixbuf-2.0-dev \ + libffi-dev \ + libglib2.0-dev \ + libjpeg-dev \ + libopenjp2-7-dev \ + && rm -rf /var/lib/apt/lists/* + +COPY pyproject.toml uv.lock .python-version ./ + +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync \ + --locked \ + --no-install-project \ + --no-editable + +FROM python:${PYTHON_VERSION}-slim-bookworm AS runtime + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PYTHONPATH=/app \ + PATH="/app/.venv/bin:$PATH" + +WORKDIR /app + +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + ca-certificates \ + libmagic1 \ + poppler-utils \ + libcairo2 \ + libpango-1.0-0 \ + libpangocairo-1.0-0 \ + libgdk-pixbuf-2.0-0 \ + libffi8 \ + libglib2.0-0 \ + libgl1 \ + libjpeg62-turbo \ + libopenjp2-7 \ + fonts-dejavu-core \ + fonts-liberation \ + shared-mime-info \ + && rm -rf /var/lib/apt/lists/* + +RUN addgroup --system app && \ + adduser --system \ + --ingroup app \ + --home /home/app \ + app + +COPY --from=builder --chown=app:app /app/.venv /app/.venv + +COPY --chown=app:app app ./app +COPY --chown=app:app manage.py . +COPY --chown=app:app alembic ./alembic +COPY --chown=app:app alembic.ini . +COPY --chown=app:app scripts ./scripts + +USER app + +CMD ["python", "manage.py", "run"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..b523c1f --- /dev/null +++ b/README.md @@ -0,0 +1,426 @@ +# Docqube Backend + +**FastAPI backend application for Docqube** + +A modern, container-ready backend built with FastAPI, managed by **uv**, and orchestrated with **Docker Compose**. + +--- + +## Table of Contents + +- [Requirements](#requirements) +- [Python Version](#python-version) +- [Installing uv](#installing-uv) +- [Getting Started](#getting-started) +- [Local Development](#local-development) +- [Dependency Management](#dependency-management) +- [Docker Compose](#docker-compose) +- [Running Celery Worker](#running-celery-worker) +- [Useful uv Commands](#useful-uv-commands) +- [Development Workflow](#development-workflow) +- [Source of Truth](#source-of-truth) + +--- + +## Requirements + +| Tool | Version / Notes | +|-------------------|--------------------------| +| **Python** | `3.11.8` (strict) | +| **uv** | Latest | +| **Docker** | Docker Desktop / Engine | +| **Docker Compose**| v2+ | + +--- + +## Python Version + +This project is **locked to Python 3.11**. + +```text +Python 3.11.8 +``` + +| File | Setting | +|-------------------|----------------------------------| +| `pyproject.toml` | `requires-python = ">=3.11,<3.12"` | +| `.python-version` | `3.11.8` | + +**Verify:** + +```bash +uv run python --version +``` + +> **Expected:** `Python 3.11.8` +> Do **not** use Python 3.12+ unless compatibility has been fully verified and the project configuration intentionally updated. + +--- + +## Installing uv + +### Windows + +```powershell +powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex" +``` + +Close and reopen PowerShell, then verify: + +```powershell +uv --version +``` + +### macOS / Linux + +```bash +curl -LsSf https://astral.sh/uv/install.sh | sh +``` + +Restart the terminal and verify: + +```bash +uv --version +``` + +--- + +## Getting Started + +```bash +# 1. Clone the repository +git clone +cd docqube_backend + +# 2. Install all dependencies +uv sync +``` + +`uv sync` will: + +- Create `.venv` automatically +- Use Python 3.11 +- Resolve dependencies from `pyproject.toml` +- Install exact versions from `uv.lock` +- Install Windows-specific packages when on Windows + +> You do **not** need to manually create or activate a virtual environment. + +--- + +## Local Development + +### Install dependencies + +```bash +uv sync +``` + +### Run the application + +```bash +uv run python manage.py run --env local +``` + +The application starts with the local environment configuration +(example: `http://127.0.0.1:20001`). +Host and port are controlled by the application settings. + +--- + +## Database Migrations & Seeding + +All database migrations and seeds are executed through `uv`: + +### 1. Apply Migrations (By Environment) + +**Local Environment:** +```bash +uv run python manage.py migrate --env local +``` + +**Test Environment:** +```bash +uv run python manage.py migrate --env test +``` + +**Production Environment:** +```bash +uv run python manage.py migrate --env production +``` + +*Or directly via Alembic (uses current environment variables):* +```bash +uv run alembic upgrade head +``` + +### 2. Create a New Migration (Autogenerate from Models) + +```bash +uv run alembic revision --autogenerate -m "your_migration_name" +``` + +### 3. Rollback the Last Migration + +```bash +uv run alembic downgrade -1 +``` + +### 4. Seed Database (By Environment) + +**Local:** +```bash +uv run python manage.py seed --env local +``` + +**Production:** +```bash +uv run python manage.py seed --env production +``` + +--- + +## Dependency Management + +All Python dependencies are managed exclusively through: + +- `pyproject.toml` +- `uv.lock` + +> **Do not** maintain a separate `requirements.txt`. + +### Add a production dependency + +```bash +uv add +``` + +Examples: + +```bash +uv add httpx +uv add "httpx==0.28.1" +uv add "pikepdf>=8.0.0,<11.0.0" +``` + +### Add a development dependency + +```bash +uv add --dev pytest +uv add --dev ruff +uv sync +``` + +### Remove a dependency + +```bash +uv remove +uv sync +``` + +### Upgrade dependencies + +**Single package:** + +```bash +uv lock --upgrade-package +uv sync +``` + +Example: + +```bash +uv lock --upgrade-package fastapi +uv sync +``` + +**Specific version:** + +```bash +uv add "fastapi==0.116.1" +``` + +**All packages** (use only for deliberate maintenance): + +```bash +uv lock --upgrade +uv sync +``` + +### Lockfile + +`uv.lock` **must** be committed to Git. +It guarantees identical dependency resolution across developer machines, CI, Docker, staging, and production. + +> **Never** edit `uv.lock` manually. + +### Inspection commands + +```bash +# Full dependency tree +uv tree + +# Find a package (Windows PowerShell) +uv tree | Select-String pikepdf + +# Find a package (Linux / macOS) +uv tree | grep pikepdf + +# Inspect an installed package +uv pip show pikepdf +uv pip show torch +``` + +--- + +## Docker Compose + +### Development environment + +```bash +# Start (build + run) +docker compose -p docqube-development -f docker-compose.development.yml up -d --build + +# Follow logs +docker compose -p docqube-development -f docker-compose.development.yml logs -f + +# Stop +docker compose -p docqube-development -f docker-compose.development.yml down + +# Normal rebuild +docker compose -p docqube-development -f docker-compose.development.yml up -d --build + +# Completely fresh build (no cache) +docker compose -p docqube-development -f docker-compose.development.yml build --no-cache +docker compose -p docqube-development -f docker-compose.development.yml up -d +``` + +--- + +## Running Celery Worker + +### Locally + +```bash +uv run celery -A app.tasks.celery_app:celery_app worker --loglevel=info +``` + +> The worker requires the correct Redis and environment configuration. + +### Recreate the virtual environment + +If `.venv` becomes corrupted: + +**Windows (PowerShell)** + +```powershell +Remove-Item -Recurse -Force .venv +uv sync +``` + +**Linux / macOS** + +```bash +rm -rf .venv +uv sync +``` + +**Force reinstall** + +```bash +uv sync --reinstall +``` + +--- + +## Useful uv Commands + +| Command | Description | +|----------------------------------------------|-------------------------------------------| +| `uv --version` | Check uv version | +| `uv run python --version` | Check Python version | +| `uv python find` | Locate the Python interpreter | +| `uv sync` | Install / synchronize dependencies | +| `uv add ` | Add a production dependency | +| `uv add --dev ` | Add a development dependency | +| `uv remove ` | Remove a dependency | +| `uv lock` | Update the lockfile | +| `uv lock --upgrade-package ` | Upgrade one package | +| `uv lock --upgrade` | Upgrade all packages | +| `uv tree` | View dependency tree | +| `uv pip show ` | Inspect an installed package | +| `uv run python` | Run Python inside the project environment | +| `uv run python manage.py run --env local` | Run the application | + +--- + +## Development Workflow + +### New developer quick start + +1. Clone the repository +2. Install `uv` +3. Run `uv sync` +4. Create the required environment file (e.g. `.env.development`) +5. Verify Python → `uv run python --version` → `Python 3.11.8` +6. Run locally **or** start the full stack with Docker Compose +7. Check services and view logs + +### Day-to-day workflow + +```bash +# Normal local development +uv sync +uv run python manage.py run --env local + +# Add a package +uv add + +# Remove a package +uv remove + +# Upgrade one package +uv lock --upgrade-package +uv sync +``` + +### Before creating a pull request + +```bash +uv sync +uv run python --version +uv run pytest +docker compose -p docqube-development -f docker-compose.development.yml build +docker compose -p docqube-development -f docker-compose.development.yml up -d +``` + +### Important rules + +**Do** + +- Use `uv sync`, `uv add`, `uv remove`, `uv run` +- Commit `pyproject.toml`, `uv.lock`, and `.python-version` +- Use Docker Compose for the full containerized environment + +**Do not** + +- Use `pip install ...` +- Manually create `env/` or `venv/` +- Manually edit `uv.lock` +- Commit `.venv/` or real `.env` files +- Maintain a manually synchronized `requirements.txt` + +--- + +## Source of Truth + +| Concern | Source of Truth | +|--------------------------------|----------------------------| +| Python version | `.python-version` | +| Python dependencies | `pyproject.toml` | +| Locked dependencies | `uv.lock` | +| Python / Docker environment | `Dockerfile` | +| Environment orchestration | `docker-compose.*.yml` | +| Environment configuration | `.env.*` | + + diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..0e0fb5b --- /dev/null +++ b/alembic.ini @@ -0,0 +1,116 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts +script_location = alembic + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. +prepend_sys_path = . + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the python-dateutil library that can be +# installed by adding `alembic[tz]` to the pip requirements +# string value is passed to dateutil.tz.gettz() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the +# "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to alembic/versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "version_path_separator" below. +# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions + +# version path separator; As mentioned above, this is the character used to split +# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. +# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. +# Valid values for version_path_separator are: +# +# version_path_separator = : +# version_path_separator = ; +# version_path_separator = space +version_path_separator = os # Use os.pathsep. Default configuration used for new projects. + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +sqlalchemy.url = postgresql+psycopg2://dummy + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the exec runner, execute a binary +# hooks = ruff +# ruff.type = exec +# ruff.executable = %(here)s/.venv/bin/ruff +# ruff.options = --fix REVISION_SCRIPT_FILENAME + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/alembic/README b/alembic/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..a918a1b --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,43 @@ +import sys +import os +from logging.config import fileConfig +from sqlalchemy import engine_from_config, pool +from alembic import context + +# Add project root to path +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from app.core.settings import DATABASE_URL +from app.db.database import Base + +# Import all models here so Alembic can detect them. +# +# `app.db.all_models` is the one that matters. Importing `app.main` reaches +# most models by accident — routes import what they use — but it missed +# `user_roles`, `access_groups` and `user_access_groups`, whose every import is +# function-local. Autogenerate reported all three as removed tables and offered +# to drop the SBAC schema. See tests/probes/test_model_registry.py. +import app.db.all_models # noqa: F401 +import app.main +from app.modules.tenant.models.tenant_contact_model import TenantContact + +target_metadata = Base.metadata + +config = context.config +config.set_main_option("sqlalchemy.url", DATABASE_URL.replace('%', '%%')) + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +def run_migrations_online(): + connectable = engine_from_config( + config.get_section(config.config_ini_section), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + with connectable.connect() as connection: + context.configure(connection=connection, target_metadata=target_metadata) + with context.begin_transaction(): + context.run_migrations() + +run_migrations_online() diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 0000000..fbc4b07 --- /dev/null +++ b/alembic/script.py.mako @@ -0,0 +1,26 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/alembic/versions/06001599321d_add_collaboration_chat.py b/alembic/versions/06001599321d_add_collaboration_chat.py new file mode 100644 index 0000000..b0202ae --- /dev/null +++ b/alembic/versions/06001599321d_add_collaboration_chat.py @@ -0,0 +1,26 @@ +"""add collaboration chat + +Revision ID: 06001599321d +Revises: 22dee9a1d631 +Create Date: 2026-02-26 16:13:54.998247 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '06001599321d' +down_revision: Union[str, None] = '22dee9a1d631' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + pass + + +def downgrade() -> None: + pass diff --git a/alembic/versions/0bffea34ee5f_disable_rls_temporarily.py b/alembic/versions/0bffea34ee5f_disable_rls_temporarily.py new file mode 100644 index 0000000..d656678 --- /dev/null +++ b/alembic/versions/0bffea34ee5f_disable_rls_temporarily.py @@ -0,0 +1,35 @@ +"""disable_rls_temporarily + +Revision ID: 0bffea34ee5f +Revises: 14b4aa5f0f5d +Create Date: 2026-09-05 12:01:30.634229 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '0bffea34ee5f' +down_revision: Union[str, None] = '14b4aa5f0f5d' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +RLS_TABLES = [ + "user_org_units", "org_unit_leads", "user_roles", "access_groups", "user_access_groups", + "org_units", "tenant_subscriptions", "tenant_limit_overrides", "chatbot_documents", + "drive_activities", "drive_files", "drive_folders", "notifications", "tenant_smtp_configs", + "user_files", "projects", "roles", "activity_logs", "tenant_storage_configs", + "chat_sessions", "signing_requests", "tenant_signature_configs", "users" +] + +def upgrade() -> None: + for table in RLS_TABLES: + op.execute(f"ALTER TABLE {table} DISABLE ROW LEVEL SECURITY") + +def downgrade() -> None: + for table in RLS_TABLES: + op.execute(f"ALTER TABLE {table} ENABLE ROW LEVEL SECURITY") diff --git a/alembic/versions/0e1a5f2cdea9_add_max_bytes_quota_to_user_storage_.py b/alembic/versions/0e1a5f2cdea9_add_max_bytes_quota_to_user_storage_.py new file mode 100644 index 0000000..f4b7857 --- /dev/null +++ b/alembic/versions/0e1a5f2cdea9_add_max_bytes_quota_to_user_storage_.py @@ -0,0 +1,38 @@ +"""Add max_bytes_quota to user_storage_usage + +Revision ID: 0e1a5f2cdea9 +Revises: 9a6db38b86a6 +Create Date: 2026-03-02 10:48:38.335359 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '0e1a5f2cdea9' +down_revision: Union[str, None] = '9a6db38b86a6' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('project_versions', sa.Column('drive_file_version_id', sa.Integer(), nullable=True)) + op.create_foreign_key(None, 'project_versions', 'drive_file_versions', ['drive_file_version_id'], ['id']) + op.add_column('user_files', sa.Column('is_deleted', sa.Boolean(), nullable=True)) + op.add_column('user_files', sa.Column('deleted_at', sa.TIMESTAMP(), nullable=True)) + op.add_column('user_storage_usage', sa.Column('max_bytes_quota', sa.BigInteger(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('user_storage_usage', 'max_bytes_quota') + op.drop_column('user_files', 'deleted_at') + op.drop_column('user_files', 'is_deleted') + op.drop_constraint(None, 'project_versions', type_='foreignkey') + op.drop_column('project_versions', 'drive_file_version_id') + # ### end Alembic commands ### diff --git a/alembic/versions/14b4aa5f0f5d_merge_multiple_heads.py b/alembic/versions/14b4aa5f0f5d_merge_multiple_heads.py new file mode 100644 index 0000000..fc6d6e1 --- /dev/null +++ b/alembic/versions/14b4aa5f0f5d_merge_multiple_heads.py @@ -0,0 +1,26 @@ +"""Merge multiple heads + +Revision ID: 14b4aa5f0f5d +Revises: e1_0_add_saas_plan_id, z005_add_virus_scan +Create Date: 2026-09-02 19:15:56.213495 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '14b4aa5f0f5d' +down_revision: Union[str, None] = ('e1_0_add_saas_plan_id', 'z005_add_virus_scan') +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + pass + + +def downgrade() -> None: + pass diff --git a/alembic/versions/15f935213b22_add_tenant_storage_settings.py b/alembic/versions/15f935213b22_add_tenant_storage_settings.py new file mode 100644 index 0000000..ea2fbb3 --- /dev/null +++ b/alembic/versions/15f935213b22_add_tenant_storage_settings.py @@ -0,0 +1,66 @@ +"""add tenant storage settings + +Revision ID: 15f935213b22 +Revises: k002_add_docs_tutorial +Create Date: 2026-04-17 15:49:28.721223 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = '15f935213b22' +down_revision: Union[str, None] = 'k002_add_docs_tutorial' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def _index_names(bind, table_name: str) -> set[str]: + inspector = sa.inspect(bind) + return {idx["name"] for idx in inspector.get_indexes(table_name)} + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + + if not inspector.has_table('tenant_storage_configs'): + op.create_table('tenant_storage_configs', + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('tenant_id', sa.UUID(), nullable=False), + sa.Column('b2_key_id', sa.String(length=255), nullable=False), + sa.Column('encrypted_b2_application_key', sa.String(length=512), nullable=False), + sa.Column('b2_quarantine_bucket', sa.String(length=255), nullable=False), + sa.Column('b2_clean_bucket', sa.String(length=255), nullable=False), + sa.Column('b2_endpoint', sa.String(length=512), nullable=True), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.Column('created_by_id', sa.Integer(), nullable=True), + sa.Column('updated_by_id', sa.Integer(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['created_by_id'], ['users.id'], ondelete='SET NULL'), + sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['updated_by_id'], ['users.id'], ondelete='SET NULL'), + sa.PrimaryKeyConstraint('id') + ) + + existing_indexes = _index_names(bind, 'tenant_storage_configs') + if 'ix_tenant_storage_configs_tenant_id' not in existing_indexes: + with op.batch_alter_table('tenant_storage_configs', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_tenant_storage_configs_tenant_id'), ['tenant_id'], unique=True) + + +def downgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + + if inspector.has_table('tenant_storage_configs'): + existing_indexes = _index_names(bind, 'tenant_storage_configs') + if 'ix_tenant_storage_configs_tenant_id' in existing_indexes: + with op.batch_alter_table('tenant_storage_configs', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_tenant_storage_configs_tenant_id')) + + op.drop_table('tenant_storage_configs') diff --git a/alembic/versions/1cb413ffaaa0_add_saas_mapping_tables.py b/alembic/versions/1cb413ffaaa0_add_saas_mapping_tables.py new file mode 100644 index 0000000..a5db058 --- /dev/null +++ b/alembic/versions/1cb413ffaaa0_add_saas_mapping_tables.py @@ -0,0 +1,59 @@ +"""Add SaaS mapping tables + +Revision ID: 1cb413ffaaa0 +Revises: n002_add_signature_imprints +Create Date: 2026-04-23 23:16:32.782986 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '1cb413ffaaa0' +down_revision: Union[str, None] = 'n002_add_signature_imprints' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # --- SaaSTenantMapping --- + op.create_table( + 'saas_tenant_mappings', + sa.Column('id', sa.dialects.postgresql.UUID(as_uuid=True), nullable=False), + sa.Column('saas_tenant_id', sa.String(), nullable=False), + sa.Column('docqube_tenant_id', sa.dialects.postgresql.UUID(as_uuid=True), nullable=False), + sa.Column('metadata', sa.JSON(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True), + sa.ForeignKeyConstraint(['docqube_tenant_id'], ['tenants.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_saas_tenant_mappings_id'), 'saas_tenant_mappings', ['id'], unique=False) + op.create_index(op.f('ix_saas_tenant_mappings_saas_tenant_id'), 'saas_tenant_mappings', ['saas_tenant_id'], unique=True) + + # --- SaaSUserMapping --- + op.create_table( + 'saas_user_mappings', + sa.Column('id', sa.dialects.postgresql.UUID(as_uuid=True), nullable=False), + sa.Column('saas_user_id', sa.String(), nullable=False), + sa.Column('docqube_user_id', sa.Integer(), nullable=False), + sa.Column('metadata', sa.JSON(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True), + sa.ForeignKeyConstraint(['docqube_user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_saas_user_mappings_id'), 'saas_user_mappings', ['id'], unique=False) + op.create_index(op.f('ix_saas_user_mappings_saas_user_id'), 'saas_user_mappings', ['saas_user_id'], unique=True) + + +def downgrade() -> None: + op.drop_index(op.f('ix_saas_user_mappings_saas_user_id'), table_name='saas_user_mappings') + op.drop_index(op.f('ix_saas_user_mappings_id'), table_name='saas_user_mappings') + op.drop_table('saas_user_mappings') + op.drop_index(op.f('ix_saas_tenant_mappings_saas_tenant_id'), table_name='saas_tenant_mappings') + op.drop_index(op.f('ix_saas_tenant_mappings_id'), table_name='saas_tenant_mappings') + op.drop_table('saas_tenant_mappings') diff --git a/alembic/versions/1f4d2a8c9b77_add_missing_vector_columns.py b/alembic/versions/1f4d2a8c9b77_add_missing_vector_columns.py new file mode 100644 index 0000000..ce4314e --- /dev/null +++ b/alembic/versions/1f4d2a8c9b77_add_missing_vector_columns.py @@ -0,0 +1,67 @@ +"""add missing vector columns + +Revision ID: 1f4d2a8c9b77 +Revises: fb6810c36ffb +Create Date: 2026-04-08 22:05:00 +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "1f4d2a8c9b77" +down_revision: Union[str, None] = "fb6810c36ffb" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def _table_exists(bind, table_name: str) -> bool: + return sa.inspect(bind).has_table(table_name) + + +def _column_exists(bind, table_name: str, column_name: str) -> bool: + inspector = sa.inspect(bind) + return column_name in {col["name"] for col in inspector.get_columns(table_name)} + + +def upgrade() -> None: + bind = op.get_bind() + + if _table_exists(bind, "vector_indices"): + if not _column_exists(bind, "vector_indices", "index_name"): + op.add_column( + "vector_indices", sa.Column("index_name", sa.String(length=255), nullable=True) + ) + if not _column_exists(bind, "vector_indices", "checksum"): + op.add_column( + "vector_indices", sa.Column("checksum", sa.String(length=255), nullable=True) + ) + if not _column_exists(bind, "vector_indices", "metadata_info"): + op.add_column( + "vector_indices", sa.Column("metadata_info", sa.JSON(), nullable=True) + ) + + if _table_exists(bind, "vector_chunks"): + if not _column_exists(bind, "vector_chunks", "embedding_json"): + op.add_column( + "vector_chunks", sa.Column("embedding_json", sa.JSON(), nullable=True) + ) + + +def downgrade() -> None: + bind = op.get_bind() + + if _table_exists(bind, "vector_chunks"): + if _column_exists(bind, "vector_chunks", "embedding_json"): + op.drop_column("vector_chunks", "embedding_json") + + if _table_exists(bind, "vector_indices"): + if _column_exists(bind, "vector_indices", "metadata_info"): + op.drop_column("vector_indices", "metadata_info") + if _column_exists(bind, "vector_indices", "checksum"): + op.drop_column("vector_indices", "checksum") + if _column_exists(bind, "vector_indices", "index_name"): + op.drop_column("vector_indices", "index_name") diff --git a/alembic/versions/229359b0d1b2_add_terms_accepted.py b/alembic/versions/229359b0d1b2_add_terms_accepted.py new file mode 100644 index 0000000..9dfe144 --- /dev/null +++ b/alembic/versions/229359b0d1b2_add_terms_accepted.py @@ -0,0 +1,64 @@ +"""add_terms_accepted + +Revision ID: 229359b0d1b2 +Revises: 823a486fd287 +Create Date: 2026-04-01 11:28:44.953518 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = '229359b0d1b2' +down_revision: Union[str, None] = '823a486fd287' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('drive_comments', schema=None) as batch_op: + batch_op.alter_column('rejected_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + type_=sa.DateTime(), + existing_nullable=True) + + with op.batch_alter_table('drive_stars', schema=None) as batch_op: + batch_op.alter_column('created_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + type_=sa.TIMESTAMP(), + nullable=True, + existing_server_default=sa.text('now()')) + + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.add_column(sa.Column('terms_accepted', sa.Boolean(), nullable=False, server_default=sa.text('false'))) + batch_op.drop_index(batch_op.f('ix_users_email')) + batch_op.create_index(batch_op.f('ix_users_email'), ['email'], unique=False) + + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_users_email')) + batch_op.create_index(batch_op.f('ix_users_email'), ['email'], unique=True) + batch_op.drop_column('terms_accepted') + + with op.batch_alter_table('drive_stars', schema=None) as batch_op: + batch_op.alter_column('created_at', + existing_type=sa.TIMESTAMP(), + type_=postgresql.TIMESTAMP(timezone=True), + nullable=False, + existing_server_default=sa.text('now()')) + + with op.batch_alter_table('drive_comments', schema=None) as batch_op: + batch_op.alter_column('rejected_at', + existing_type=sa.DateTime(), + type_=postgresql.TIMESTAMP(timezone=True), + existing_nullable=True) + + # ### end Alembic commands ### diff --git a/alembic/versions/22dee9a1d631_add_shared_by_user_id_to_projectshare.py b/alembic/versions/22dee9a1d631_add_shared_by_user_id_to_projectshare.py new file mode 100644 index 0000000..6ccef18 --- /dev/null +++ b/alembic/versions/22dee9a1d631_add_shared_by_user_id_to_projectshare.py @@ -0,0 +1,32 @@ +"""Add shared_by_user_id to ProjectShare + +Revision ID: 22dee9a1d631 +Revises: dbaa8ef522c0 +Create Date: 2026-02-26 15:33:00.617693 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '22dee9a1d631' +down_revision: Union[str, None] = 'dbaa8ef522c0' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('project_shares', sa.Column('shared_by_user_id', sa.Integer(), nullable=True)) + op.create_foreign_key(None, 'project_shares', 'users', ['shared_by_user_id'], ['id'], ondelete='CASCADE') + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint(None, 'project_shares', type_='foreignkey') + op.drop_column('project_shares', 'shared_by_user_id') + # ### end Alembic commands ### diff --git a/alembic/versions/29c02cb50737_initial_migration.py b/alembic/versions/29c02cb50737_initial_migration.py new file mode 100644 index 0000000..782db29 --- /dev/null +++ b/alembic/versions/29c02cb50737_initial_migration.py @@ -0,0 +1,258 @@ +"""initial migration + +Revision ID: 29c02cb50737 +Revises: +Create Date: 2026-02-16 19:28:02.116622 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = '29c02cb50737' +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('drive_acl', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('resource_type', sa.String(length=10), nullable=False), + sa.Column('resource_id', sa.Integer(), nullable=False), + sa.Column('subject_type', sa.String(length=10), nullable=False), + sa.Column('subject_id', sa.Integer(), nullable=True), + sa.Column('role', sa.String(length=20), nullable=False), + sa.Column('inherited_from', sa.Integer(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('users', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('email', sa.String(length=255), nullable=False), + sa.Column('password_hash', sa.String(length=255), nullable=False), + sa.Column('subscription', sa.String(length=50), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True) + op.create_table('drive_activities', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('resource_type', sa.String(length=10), nullable=False), + sa.Column('resource_id', sa.Integer(), nullable=False), + sa.Column('actor_id', sa.Integer(), nullable=False), + sa.Column('activity_type', sa.String(length=50), nullable=False), + sa.Column('activity_metadata', sa.Text(), nullable=True), + sa.Column('ip_address', sa.String(length=64), nullable=True), + sa.Column('device_info', sa.Text(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['actor_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('drive_folders', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('owner_id', sa.Integer(), nullable=False), + sa.Column('parent_id', sa.Integer(), nullable=True), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('color', sa.String(length=20), nullable=True), + sa.Column('is_starred', sa.Boolean(), nullable=False), + sa.Column('is_trashed', sa.Boolean(), nullable=False), + sa.Column('trashed_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['owner_id'], ['users.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['parent_id'], ['drive_folders.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('drive_shares', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('resource_type', sa.String(length=10), nullable=False), + sa.Column('resource_id', sa.Integer(), nullable=False), + sa.Column('invited_email', sa.String(length=255), nullable=True), + sa.Column('link_token', sa.String(length=255), nullable=True), + sa.Column('role', sa.String(length=20), nullable=False), + sa.Column('expires_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('created_by_id', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['created_by_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('link_token') + ) + op.create_table('drive_stars', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('resource_type', sa.String(length=10), nullable=False), + sa.Column('resource_id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('resource_type', 'resource_id', 'user_id', name='uq_drive_star') + ) + op.create_table('projects', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('status', sa.String(length=50), nullable=False), + sa.Column('file_type', sa.String(length=50), nullable=False), + sa.Column('output_format', sa.String(length=50), nullable=False), + sa.Column('progress', sa.Integer(), nullable=False), + sa.Column('current_step', sa.String(length=50), nullable=False), + sa.Column('session_id', sa.String(length=255), nullable=True), + sa.Column('html_content', sa.Text(), nullable=True), + sa.Column('markdown_content', sa.Text(), nullable=True), + sa.Column('xml_content', sa.Text(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('user_files', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('file_name', sa.String(length=512), nullable=False), + sa.Column('s3_key', sa.String(length=1024), nullable=False), + sa.Column('file_size', sa.BigInteger(), nullable=False), + sa.Column('mime_type', sa.String(length=255), nullable=True), + sa.Column('is_deleted', sa.Boolean(), nullable=False), + sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('s3_key') + ) + op.create_table('user_storage_usage', + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('total_bytes', sa.BigInteger(), nullable=False), + sa.Column('file_count', sa.Integer(), nullable=False), + sa.Column('last_updated', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('user_id') + ) + op.create_table('drive_files', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('folder_id', sa.Integer(), nullable=False), + sa.Column('owner_id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=512), nullable=False), + sa.Column('mime_type', sa.String(length=255), nullable=True), + sa.Column('size', sa.BigInteger(), nullable=False), + sa.Column('hash', sa.String(length=128), nullable=True), + sa.Column('latest_version_id', sa.Integer(), nullable=True), + sa.Column('is_starred', sa.Boolean(), nullable=False), + sa.Column('is_trashed', sa.Boolean(), nullable=False), + sa.Column('trashed_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['folder_id'], ['drive_folders.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['owner_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('project_activities', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('project_id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('activity_type', sa.String(length=50), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('activity_metadata', sa.Text(), nullable=True), + sa.Column('ip_address', sa.String(length=64), nullable=True), + sa.Column('device_info', sa.Text(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('project_comments', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('project_id', sa.Integer(), nullable=False), + sa.Column('author_id', sa.Integer(), nullable=False), + sa.Column('body', sa.Text(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('resolved_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('anchor', postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.ForeignKeyConstraint(['author_id'], ['users.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('project_shares', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('project_id', sa.Integer(), nullable=False), + sa.Column('shared_with_user_id', sa.Integer(), nullable=False), + sa.Column('shared_by_user_id', sa.Integer(), nullable=False), + sa.Column('permission', sa.String(length=50), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['shared_by_user_id'], ['users.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['shared_with_user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('project_id', 'shared_with_user_id', name='unique_project_user_share') + ) + op.create_table('project_versions', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('project_id', sa.Integer(), nullable=False), + sa.Column('created_by_id', sa.Integer(), nullable=False), + sa.Column('version_number', sa.Integer(), nullable=False), + sa.Column('changes_description', sa.String(length=500), nullable=True), + sa.Column('markdown_content', sa.Text(), nullable=True), + sa.Column('xml_content', sa.Text(), nullable=True), + sa.Column('html_content', sa.Text(), nullable=True), + sa.Column('session_id', sa.String(length=255), nullable=True), + sa.Column('is_current', sa.Boolean(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['created_by_id'], ['users.id'], ), + sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('drive_comments', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('file_id', sa.Integer(), nullable=False), + sa.Column('author_id', sa.Integer(), nullable=False), + sa.Column('body', sa.Text(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('resolved_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('anchor', postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.ForeignKeyConstraint(['author_id'], ['users.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['file_id'], ['drive_files.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('drive_file_versions', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('file_id', sa.Integer(), nullable=False), + sa.Column('version_number', sa.Integer(), nullable=False), + sa.Column('storage_key', sa.Text(), nullable=False), + sa.Column('mime_type', sa.String(length=255), nullable=True), + sa.Column('size', sa.BigInteger(), nullable=False), + sa.Column('checksum', sa.String(length=128), nullable=True), + sa.Column('created_by_id', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['created_by_id'], ['users.id'], ), + sa.ForeignKeyConstraint(['file_id'], ['drive_files.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('drive_file_versions') + op.drop_table('drive_comments') + op.drop_table('project_versions') + op.drop_table('project_shares') + op.drop_table('project_comments') + op.drop_table('project_activities') + op.drop_table('drive_files') + op.drop_table('user_storage_usage') + op.drop_table('user_files') + op.drop_table('projects') + op.drop_table('drive_stars') + op.drop_table('drive_shares') + op.drop_table('drive_folders') + op.drop_table('drive_activities') + op.drop_index(op.f('ix_users_email'), table_name='users') + op.drop_table('users') + op.drop_table('drive_acl') + # ### end Alembic commands ### diff --git a/alembic/versions/2b4c62c1cf35_add_subscription_fields_and_.py b/alembic/versions/2b4c62c1cf35_add_subscription_fields_and_.py new file mode 100644 index 0000000..7cf296a --- /dev/null +++ b/alembic/versions/2b4c62c1cf35_add_subscription_fields_and_.py @@ -0,0 +1,51 @@ +"""Add subscription fields and PlanPriceHistory + +Revision ID: 2b4c62c1cf35 +Revises: d2_0_credit_limit_precision +Create Date: 2026-08-19 18:04:44.734002 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = '2b4c62c1cf35' +down_revision: Union[str, None] = 'd2_0_credit_limit_precision' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('plan_price_history', + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('plan_id', sa.UUID(), nullable=False), + sa.Column('old_price', sa.Numeric(precision=10, scale=2), nullable=True), + sa.Column('new_price', sa.Numeric(precision=10, scale=2), nullable=True), + sa.Column('currency', sa.String(length=10), nullable=True), + sa.Column('changed_by', sa.Integer(), nullable=True), + sa.Column('changed_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['plan_id'], ['plans.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_plan_price_history_plan_id'), 'plan_price_history', ['plan_id'], unique=False) + + op.add_column('plans', sa.Column('price_amount', sa.Numeric(precision=10, scale=2), nullable=True)) + op.add_column('plans', sa.Column('interval', sa.String(length=20), nullable=True)) + op.add_column('plans', sa.Column('grace_period_days', sa.Integer(), server_default='0', nullable=False)) + op.add_column('plans', sa.Column('notify_days_before_expiry', sa.Integer(), server_default='7', nullable=False)) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('plans', 'notify_days_before_expiry') + op.drop_column('plans', 'grace_period_days') + op.drop_column('plans', 'interval') + op.drop_column('plans', 'price_amount') + op.drop_index(op.f('ix_plan_price_history_plan_id'), table_name='plan_price_history') + op.drop_table('plan_price_history') + # ### end Alembic commands ### diff --git a/alembic/versions/39d5fdfb5386_add_signature_id_to_signature_imprints.py b/alembic/versions/39d5fdfb5386_add_signature_id_to_signature_imprints.py new file mode 100644 index 0000000..d293a28 --- /dev/null +++ b/alembic/versions/39d5fdfb5386_add_signature_id_to_signature_imprints.py @@ -0,0 +1,128 @@ +"""add signature_id to signature_imprints + +Revision ID: 39d5fdfb5386 +Revises: 1cb413ffaaa0 +Create Date: 2026-04-25 15:33:32.819817 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = '39d5fdfb5386' +down_revision: Union[str, None] = '1cb413ffaaa0' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Legacy tables/indexes — drop only if present (some envs already lack them). + op.execute("DROP TABLE IF EXISTS vector_chunks CASCADE") + op.execute("DROP TABLE IF EXISTS vector_indices CASCADE") + op.execute("DROP TABLE IF EXISTS chat_token_usage CASCADE") + + op.execute("DROP INDEX IF EXISTS ix_chatbot_documents_drive_file_id") + op.execute("DROP INDEX IF EXISTS ix_chatbot_documents_id") + + with op.batch_alter_table('extraction_data', schema=None) as batch_op: + batch_op.alter_column('confidence_score', + existing_type=sa.DOUBLE_PRECISION(precision=53), + nullable=False) + batch_op.alter_column('extraction_status', + existing_type=sa.VARCHAR(length=50), + nullable=False) + op.execute("DROP INDEX IF EXISTS idx_extraction_data_document_id") + op.create_index(op.f('ix_extraction_data_document_id'), 'extraction_data', ['document_id'], unique=False) + + op.execute("DROP INDEX IF EXISTS ix_signature_imprints_file_page") + op.execute("DROP INDEX IF EXISTS ix_signature_imprints_signing_request") + with op.batch_alter_table('signature_imprints', schema=None) as batch_op: + batch_op.add_column(sa.Column('signature_id', sa.String(length=15), nullable=True)) + batch_op.create_index(batch_op.f('ix_signature_imprints_signing_request_id'), ['signing_request_id'], unique=False) + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('signature_imprints', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_signature_imprints_signing_request_id')) + batch_op.create_index(batch_op.f('ix_signature_imprints_signing_request'), ['signing_request_id'], unique=False) + batch_op.create_index(batch_op.f('ix_signature_imprints_file_page'), ['drive_file_id', 'page_number'], unique=False) + batch_op.drop_column('signature_id') + + with op.batch_alter_table('extraction_data', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_extraction_data_document_id')) + batch_op.create_index(batch_op.f('idx_extraction_data_document_id'), ['document_id'], unique=False) + batch_op.alter_column('extraction_status', + existing_type=sa.VARCHAR(length=50), + nullable=True) + batch_op.alter_column('confidence_score', + existing_type=sa.DOUBLE_PRECISION(precision=53), + nullable=True) + + with op.batch_alter_table('chatbot_documents', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_chatbot_documents_id'), ['id'], unique=False) + batch_op.create_index(batch_op.f('ix_chatbot_documents_drive_file_id'), ['drive_file_id'], unique=False) + + op.create_table('chat_token_usage', + sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False), + sa.Column('user_id', sa.INTEGER(), autoincrement=False, nullable=False), + sa.Column('chat_id', sa.INTEGER(), autoincrement=False, nullable=True), + sa.Column('document_id', sa.VARCHAR(length=255), autoincrement=False, nullable=True), + sa.Column('prompt_tokens', sa.BIGINT(), autoincrement=False, nullable=False), + sa.Column('completion_tokens', sa.BIGINT(), autoincrement=False, nullable=False), + sa.Column('total_tokens', sa.BIGINT(), autoincrement=False, nullable=False), + sa.Column('model_name', sa.VARCHAR(length=100), autoincrement=False, nullable=False), + sa.Column('cost_usd', sa.NUMERIC(precision=12, scale=8), autoincrement=False, nullable=False), + sa.Column('timestamp', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), autoincrement=False, nullable=False), + sa.Column('phase', sa.VARCHAR(length=50), autoincrement=False, nullable=True), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], name=op.f('chat_token_usage_user_id_fkey'), ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('chat_token_usage_pkey')) + ) + with op.batch_alter_table('chat_token_usage', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_chat_token_usage_user_time'), ['user_id', 'timestamp'], unique=False) + batch_op.create_index(batch_op.f('ix_chat_token_usage_user_id'), ['user_id'], unique=False) + batch_op.create_index(batch_op.f('ix_chat_token_usage_timestamp'), ['timestamp'], unique=False) + batch_op.create_index(batch_op.f('ix_chat_token_usage_phase'), ['phase'], unique=False) + batch_op.create_index(batch_op.f('ix_chat_token_usage_model_time'), ['model_name', 'timestamp'], unique=False) + batch_op.create_index(batch_op.f('ix_chat_token_usage_model_name'), ['model_name'], unique=False) + batch_op.create_index(batch_op.f('ix_chat_token_usage_id'), ['id'], unique=False) + batch_op.create_index(batch_op.f('ix_chat_token_usage_document_id'), ['document_id'], unique=False) + batch_op.create_index(batch_op.f('ix_chat_token_usage_chat_id'), ['chat_id'], unique=False) + + op.create_table('vector_indices', + sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False), + sa.Column('user_id', sa.INTEGER(), autoincrement=False, nullable=False), + sa.Column('index_data', postgresql.BYTEA(), autoincrement=False, nullable=True), + sa.Column('dimension', sa.INTEGER(), autoincrement=False, nullable=True), + sa.Column('total_vectors', sa.INTEGER(), autoincrement=False, nullable=False), + sa.Column('updated_at', postgresql.TIMESTAMP(timezone=True), autoincrement=False, nullable=False), + sa.Column('index_name', sa.VARCHAR(length=255), autoincrement=False, nullable=True), + sa.Column('checksum', sa.VARCHAR(length=255), autoincrement=False, nullable=True), + sa.Column('metadata_info', postgresql.JSON(astext_type=sa.Text()), autoincrement=False, nullable=True), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], name=op.f('vector_indices_user_id_fkey'), ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('vector_indices_pkey')) + ) + with op.batch_alter_table('vector_indices', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_vector_indices_user_id'), ['user_id'], unique=True) + batch_op.create_index(batch_op.f('ix_vector_indices_id'), ['id'], unique=False) + + op.create_table('vector_chunks', + sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False), + sa.Column('user_id', sa.INTEGER(), autoincrement=False, nullable=False), + sa.Column('document_id', sa.VARCHAR(), autoincrement=False, nullable=False), + sa.Column('chunk_text', sa.TEXT(), autoincrement=False, nullable=False), + sa.Column('chunk_index', sa.INTEGER(), autoincrement=False, nullable=False), + sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), autoincrement=False, nullable=False), + sa.Column('embedding_json', postgresql.JSON(astext_type=sa.Text()), autoincrement=False, nullable=True), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], name=op.f('vector_chunks_user_id_fkey'), ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('vector_chunks_pkey')) + ) + with op.batch_alter_table('vector_chunks', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_vector_chunks_user_id'), ['user_id'], unique=False) + batch_op.create_index(batch_op.f('ix_vector_chunks_id'), ['id'], unique=False) + batch_op.create_index(batch_op.f('ix_vector_chunks_document_id'), ['document_id'], unique=False) + + # ### end Alembic commands ### diff --git a/alembic/versions/3aa7d970b8c2_enforce_enum_constraint_on_driveacl_role.py b/alembic/versions/3aa7d970b8c2_enforce_enum_constraint_on_driveacl_role.py new file mode 100644 index 0000000..fd70e8a --- /dev/null +++ b/alembic/versions/3aa7d970b8c2_enforce_enum_constraint_on_driveacl_role.py @@ -0,0 +1,51 @@ +"""Enforce enum constraint on DriveACL role + +Revision ID: 3aa7d970b8c2 +Revises: 89508c176437 +Create Date: 2026-03-03 22:06:24.190823 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '3aa7d970b8c2' +down_revision: Union[str, None] = '89508c176437' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +# The four valid drive roles +VALID_ROLES = ('viewer', 'commenter', 'editor', 'owner') + + +def upgrade() -> None: + # 1. Create the PostgreSQL enum type (idempotent via IF NOT EXISTS) + op.execute("CREATE TYPE drive_role AS ENUM ('viewer', 'commenter', 'editor', 'owner')") + + # 2. Sanitise any NULL or invalid rows before adding NOT NULL constraint. + # Default unknown/null values to 'viewer' (least-privileged), then log them. + op.execute( + "UPDATE drive_acls SET role = 'viewer' " + "WHERE role IS NULL OR role NOT IN ('viewer', 'commenter', 'editor', 'owner')" + ) + + # 3. Alter the column: cast existing VARCHAR values to the new enum type + op.execute( + "ALTER TABLE drive_acls " + "ALTER COLUMN role TYPE drive_role USING role::drive_role, " + "ALTER COLUMN role SET NOT NULL" + ) + + +def downgrade() -> None: + # Revert column to plain VARCHAR(50), nullable + op.execute( + "ALTER TABLE drive_acls " + "ALTER COLUMN role TYPE VARCHAR(50) USING role::VARCHAR, " + "ALTER COLUMN role DROP NOT NULL" + ) + # Drop the enum type (only if no other table references it) + op.execute("DROP TYPE IF EXISTS drive_role") diff --git a/alembic/versions/41bed7eb6ed9_refactor_documents_and_add_missing_.py b/alembic/versions/41bed7eb6ed9_refactor_documents_and_add_missing_.py new file mode 100644 index 0000000..cf85d17 --- /dev/null +++ b/alembic/versions/41bed7eb6ed9_refactor_documents_and_add_missing_.py @@ -0,0 +1,100 @@ +"""Refactor documents and add missing fields + +Revision ID: 41bed7eb6ed9 +Revises: 06001599321d +Create Date: 2026-02-27 14:21:01.746676 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = '41bed7eb6ed9' +down_revision: Union[str, None] = '06001599321d' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('project_activities') + op.execute("DROP INDEX IF EXISTS ix_collaboration_chat_resource_id") + op.execute("DROP INDEX IF EXISTS ix_collaboration_chat_resource_type") + op.execute("DROP TABLE IF EXISTS collaboration_chat CASCADE") + op.drop_table('project_comments') + op.drop_table('project_shares') + op.add_column('project_versions', sa.Column('changes_description', sa.Text(), nullable=True)) + op.add_column('project_versions', sa.Column('session_id', sa.String(length=255), nullable=True)) + op.drop_column('project_versions', 'description') + op.add_column('projects', sa.Column('drive_file_id', sa.Integer(), nullable=True)) + op.create_index('idx_project_session', 'projects', ['session_id'], unique=False) + op.create_index('idx_project_user_status', 'projects', ['user_id', 'status'], unique=False) + op.create_index(op.f('ix_projects_drive_file_id'), 'projects', ['drive_file_id'], unique=False) + op.create_foreign_key(None, 'projects', 'drive_files', ['drive_file_id'], ['id'], ondelete='CASCADE') + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint(None, 'projects', type_='foreignkey') + op.drop_index(op.f('ix_projects_drive_file_id'), table_name='projects') + op.drop_index('idx_project_user_status', table_name='projects') + op.drop_index('idx_project_session', table_name='projects') + op.drop_column('projects', 'drive_file_id') + op.add_column('project_versions', sa.Column('description', sa.TEXT(), autoincrement=False, nullable=True)) + op.drop_column('project_versions', 'session_id') + op.drop_column('project_versions', 'changes_description') + op.create_table('project_shares', + sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False), + sa.Column('project_id', sa.INTEGER(), autoincrement=False, nullable=False), + sa.Column('shared_with_user_id', sa.INTEGER(), autoincrement=False, nullable=False), + sa.Column('permission', sa.VARCHAR(length=20), autoincrement=False, nullable=False), + sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), autoincrement=False, nullable=False), + sa.Column('shared_by_user_id', sa.INTEGER(), autoincrement=False, nullable=True), + sa.ForeignKeyConstraint(['project_id'], ['projects.id'], name='project_shares_project_id_fkey', ondelete='CASCADE'), + sa.ForeignKeyConstraint(['shared_by_user_id'], ['users.id'], name='project_shares_shared_by_user_id_fkey', ondelete='CASCADE'), + sa.ForeignKeyConstraint(['shared_with_user_id'], ['users.id'], name='project_shares_shared_with_user_id_fkey', ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name='project_shares_pkey') + ) + op.create_table('project_comments', + sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False), + sa.Column('project_id', sa.INTEGER(), autoincrement=False, nullable=False), + sa.Column('author_id', sa.INTEGER(), autoincrement=False, nullable=False), + sa.Column('body', sa.TEXT(), autoincrement=False, nullable=False), + sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), autoincrement=False, nullable=False), + sa.Column('resolved_at', postgresql.TIMESTAMP(timezone=True), autoincrement=False, nullable=True), + sa.Column('anchor', sa.TEXT(), autoincrement=False, nullable=True), + sa.ForeignKeyConstraint(['author_id'], ['users.id'], name='project_comments_author_id_fkey', ondelete='CASCADE'), + sa.ForeignKeyConstraint(['project_id'], ['projects.id'], name='project_comments_project_id_fkey', ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name='project_comments_pkey') + ) + op.create_table('collaboration_chat', + sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False), + sa.Column('resource_type', sa.VARCHAR(length=20), autoincrement=False, nullable=False), + sa.Column('resource_id', sa.INTEGER(), autoincrement=False, nullable=False), + sa.Column('user_id', sa.INTEGER(), autoincrement=False, nullable=False), + sa.Column('message', sa.TEXT(), autoincrement=False, nullable=False), + sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), autoincrement=False, nullable=True), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], name='collaboration_chat_user_id_fkey', ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name='collaboration_chat_pkey') + ) + op.create_index('ix_collaboration_chat_resource_type', 'collaboration_chat', ['resource_type'], unique=False) + op.create_index('ix_collaboration_chat_resource_id', 'collaboration_chat', ['resource_id'], unique=False) + op.create_table('project_activities', + sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False), + sa.Column('project_id', sa.INTEGER(), autoincrement=False, nullable=False), + sa.Column('user_id', sa.INTEGER(), autoincrement=False, nullable=False), + sa.Column('activity_type', sa.VARCHAR(length=50), autoincrement=False, nullable=False), + sa.Column('description', sa.TEXT(), autoincrement=False, nullable=True), + sa.Column('activity_metadata', sa.TEXT(), autoincrement=False, nullable=True), + sa.Column('ip_address', sa.VARCHAR(length=45), autoincrement=False, nullable=True), + sa.Column('device_info', sa.TEXT(), autoincrement=False, nullable=True), + sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), autoincrement=False, nullable=False), + sa.ForeignKeyConstraint(['project_id'], ['projects.id'], name='project_activities_project_id_fkey', ondelete='CASCADE'), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], name='project_activities_user_id_fkey', ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name='project_activities_pkey') + ) + # ### end Alembic commands ### diff --git a/alembic/versions/45c94ee15294_limit_notification_message_to_2000_chars.py b/alembic/versions/45c94ee15294_limit_notification_message_to_2000_chars.py new file mode 100644 index 0000000..c78a1cb --- /dev/null +++ b/alembic/versions/45c94ee15294_limit_notification_message_to_2000_chars.py @@ -0,0 +1,36 @@ +"""Limit Notification message to 2000 chars + +Revision ID: 45c94ee15294 +Revises: d2e2c55290ee +Create Date: 2026-03-03 22:24:55.795394 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '45c94ee15294' +down_revision: Union[str, None] = 'd2e2c55290ee' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('notifications', 'message', + existing_type=sa.TEXT(), + type_=sa.String(length=2000), + existing_nullable=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('notifications', 'message', + existing_type=sa.String(length=2000), + type_=sa.TEXT(), + existing_nullable=False) + # ### end Alembic commands ### diff --git a/alembic/versions/49c19df2c1d4_sync_models.py b/alembic/versions/49c19df2c1d4_sync_models.py new file mode 100644 index 0000000..6889daa --- /dev/null +++ b/alembic/versions/49c19df2c1d4_sync_models.py @@ -0,0 +1,371 @@ +"""sync_models + +Revision ID: 49c19df2c1d4 +Revises: 29c02cb50737 +Create Date: 2026-02-19 11:21:56.538569 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = '49c19df2c1d4' +down_revision: Union[str, None] = '29c02cb50737' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('drive_acls', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('resource_type', sa.String(length=20), nullable=False), + sa.Column('resource_id', sa.Integer(), nullable=False), + sa.Column('subject_id', sa.Integer(), nullable=False), + sa.Column('role', sa.String(length=50), nullable=True), + sa.Column('actor_id', sa.Integer(), nullable=True), + sa.Column('created_at', sa.TIMESTAMP(), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('chatbot_documents', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('document_id', sa.UUID(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('drive_file_id', sa.Integer(), nullable=True), + sa.Column('filename', sa.String(), nullable=False), + sa.Column('file_path', sa.String(), nullable=True), + sa.Column('cloud_url', sa.String(), nullable=True), + sa.Column('chunks_count', sa.Integer(), nullable=True), + sa.Column('created_at', sa.TIMESTAMP(), nullable=True), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_chatbot_documents_document_id'), 'chatbot_documents', ['document_id'], unique=True) + op.create_index(op.f('ix_chatbot_documents_drive_file_id'), 'chatbot_documents', ['drive_file_id'], unique=False) + op.create_index(op.f('ix_chatbot_documents_id'), 'chatbot_documents', ['id'], unique=False) + op.create_index(op.f('ix_chatbot_documents_user_id'), 'chatbot_documents', ['user_id'], unique=False) + op.create_table('vector_chunks', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('document_id', sa.String(), nullable=True), + sa.Column('chunk_text', sa.String(), nullable=False), + sa.Column('chunk_index', sa.Integer(), nullable=True), + sa.Column('created_at', sa.TIMESTAMP(), nullable=True), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_vector_chunks_document_id'), 'vector_chunks', ['document_id'], unique=False) + op.create_index(op.f('ix_vector_chunks_id'), 'vector_chunks', ['id'], unique=False) + op.create_index(op.f('ix_vector_chunks_user_id'), 'vector_chunks', ['user_id'], unique=False) + op.create_table('vector_indices', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('index_data', sa.LargeBinary(), nullable=True), + sa.Column('dimension', sa.Integer(), nullable=True), + sa.Column('total_vectors', sa.Integer(), nullable=True), + sa.Column('updated_at', sa.TIMESTAMP(), nullable=True), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_vector_indices_id'), 'vector_indices', ['id'], unique=False) + op.create_index(op.f('ix_vector_indices_user_id'), 'vector_indices', ['user_id'], unique=True) + op.drop_table('drive_acl') + op.alter_column('drive_activities', 'resource_type', + existing_type=sa.VARCHAR(length=10), + type_=sa.String(length=20), + nullable=True) + op.alter_column('drive_activities', 'resource_id', + existing_type=sa.INTEGER(), + nullable=True) + op.alter_column('drive_activities', 'actor_id', + existing_type=sa.INTEGER(), + nullable=True) + op.alter_column('drive_activities', 'activity_type', + existing_type=sa.VARCHAR(length=50), + nullable=True) + op.alter_column('drive_activities', 'created_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + type_=sa.TIMESTAMP(), + nullable=True, + existing_server_default=sa.text('now()')) + op.drop_constraint(op.f('drive_activities_actor_id_fkey'), 'drive_activities', type_='foreignkey') + op.create_foreign_key(None, 'drive_activities', 'users', ['actor_id'], ['id']) + op.drop_column('drive_activities', 'device_info') + op.drop_column('drive_activities', 'ip_address') + op.add_column('drive_comments', sa.Column('user_id', sa.Integer(), nullable=False)) + op.add_column('drive_comments', sa.Column('content', sa.Text(), nullable=False)) + op.alter_column('drive_comments', 'created_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + type_=sa.TIMESTAMP(), + nullable=True, + existing_server_default=sa.text('now()')) + op.drop_constraint(op.f('drive_comments_file_id_fkey'), 'drive_comments', type_='foreignkey') + op.drop_constraint(op.f('drive_comments_author_id_fkey'), 'drive_comments', type_='foreignkey') + op.create_foreign_key(None, 'drive_comments', 'users', ['user_id'], ['id']) + op.create_foreign_key(None, 'drive_comments', 'drive_files', ['file_id'], ['id']) + op.drop_column('drive_comments', 'anchor') + op.drop_column('drive_comments', 'body') + op.drop_column('drive_comments', 'resolved_at') + op.drop_column('drive_comments', 'author_id') + op.add_column('drive_file_versions', sa.Column('s3_key', sa.String(), nullable=True)) + op.add_column('drive_file_versions', sa.Column('content_type', sa.String(), nullable=True)) + op.add_column('drive_file_versions', sa.Column('uploader_id', sa.Integer(), nullable=True)) + op.alter_column('drive_file_versions', 'file_id', + existing_type=sa.INTEGER(), + nullable=True) + op.alter_column('drive_file_versions', 'version_number', + existing_type=sa.INTEGER(), + nullable=True) + op.alter_column('drive_file_versions', 'size', + existing_type=sa.BIGINT(), + nullable=True) + op.alter_column('drive_file_versions', 'created_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + type_=sa.TIMESTAMP(), + nullable=True, + existing_server_default=sa.text('now()')) + op.drop_constraint(op.f('drive_file_versions_file_id_fkey'), 'drive_file_versions', type_='foreignkey') + op.drop_constraint(op.f('drive_file_versions_created_by_id_fkey'), 'drive_file_versions', type_='foreignkey') + op.create_foreign_key(None, 'drive_file_versions', 'drive_files', ['file_id'], ['id']) + op.create_foreign_key(None, 'drive_file_versions', 'users', ['uploader_id'], ['id']) + op.drop_column('drive_file_versions', 'checksum') + op.drop_column('drive_file_versions', 'created_by_id') + op.drop_column('drive_file_versions', 'storage_key') + op.drop_column('drive_file_versions', 'mime_type') + op.alter_column('drive_stars', 'user_id', + existing_type=sa.INTEGER(), + nullable=True) + op.alter_column('drive_stars', 'resource_type', + existing_type=sa.VARCHAR(length=10), + nullable=True) + op.alter_column('drive_stars', 'resource_id', + existing_type=sa.INTEGER(), + nullable=True) + op.drop_constraint(op.f('uq_drive_star'), 'drive_stars', type_='unique') + op.drop_constraint(op.f('drive_stars_user_id_fkey'), 'drive_stars', type_='foreignkey') + op.create_foreign_key(None, 'drive_stars', 'users', ['user_id'], ['id']) + op.drop_column('drive_stars', 'created_at') + op.alter_column('project_activities', 'ip_address', + existing_type=sa.VARCHAR(length=64), + type_=sa.String(length=45), + existing_nullable=True) + op.alter_column('project_comments', 'anchor', + existing_type=postgresql.JSONB(astext_type=sa.Text()), + type_=sa.Text(), + existing_nullable=True) + op.alter_column('project_shares', 'permission', + existing_type=sa.VARCHAR(length=50), + type_=sa.String(length=20), + existing_nullable=False) + op.drop_constraint(op.f('unique_project_user_share'), 'project_shares', type_='unique') + op.drop_constraint(op.f('project_shares_shared_by_user_id_fkey'), 'project_shares', type_='foreignkey') + op.drop_column('project_shares', 'updated_at') + op.drop_column('project_shares', 'shared_by_user_id') + op.add_column('project_versions', sa.Column('version_label', sa.String(length=50), nullable=False)) + op.add_column('project_versions', sa.Column('description', sa.Text(), nullable=True)) + op.alter_column('project_versions', 'created_by_id', + existing_type=sa.INTEGER(), + nullable=True) + op.drop_constraint(op.f('project_versions_created_by_id_fkey'), 'project_versions', type_='foreignkey') + op.create_foreign_key(None, 'project_versions', 'users', ['created_by_id'], ['id'], ondelete='SET NULL') + op.drop_column('project_versions', 'changes_description') + op.drop_column('project_versions', 'session_id') + op.add_column('user_files', sa.Column('filename', sa.String(), nullable=False)) + op.add_column('user_files', sa.Column('size', sa.BigInteger(), nullable=True)) + op.add_column('user_files', sa.Column('content_type', sa.String(), nullable=True)) + op.add_column('user_files', sa.Column('status', sa.String(), nullable=True)) + op.alter_column('user_files', 'created_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + type_=sa.TIMESTAMP(), + nullable=True, + existing_server_default=sa.text('now()')) + op.drop_constraint(op.f('user_files_s3_key_key'), 'user_files', type_='unique') + op.create_index(op.f('ix_user_files_id'), 'user_files', ['id'], unique=False) + op.create_index(op.f('ix_user_files_user_id'), 'user_files', ['user_id'], unique=False) + op.drop_constraint(op.f('user_files_user_id_fkey'), 'user_files', type_='foreignkey') + op.create_foreign_key(None, 'user_files', 'users', ['user_id'], ['id']) + op.drop_column('user_files', 'file_name') + op.drop_column('user_files', 'deleted_at') + op.drop_column('user_files', 'file_size') + op.drop_column('user_files', 'is_deleted') + op.drop_column('user_files', 'mime_type') + op.add_column('user_storage_usage', sa.Column('id', sa.Integer(), nullable=False)) + op.add_column('user_storage_usage', sa.Column('total_bytes_used', sa.BigInteger(), nullable=True)) + op.add_column('user_storage_usage', sa.Column('total_files_count', sa.Integer(), nullable=True)) + op.alter_column('user_storage_usage', 'last_updated', + existing_type=postgresql.TIMESTAMP(timezone=True), + type_=sa.TIMESTAMP(), + nullable=True, + existing_server_default=sa.text('now()')) + op.create_index(op.f('ix_user_storage_usage_id'), 'user_storage_usage', ['id'], unique=False) + op.create_unique_constraint(None, 'user_storage_usage', ['user_id']) + op.drop_constraint(op.f('user_storage_usage_user_id_fkey'), 'user_storage_usage', type_='foreignkey') + op.create_foreign_key(None, 'user_storage_usage', 'users', ['user_id'], ['id']) + op.drop_column('user_storage_usage', 'total_bytes') + op.drop_column('user_storage_usage', 'file_count') + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('user_storage_usage', sa.Column('file_count', sa.INTEGER(), autoincrement=False, nullable=False)) + op.add_column('user_storage_usage', sa.Column('total_bytes', sa.BIGINT(), autoincrement=False, nullable=False)) + op.drop_constraint(None, 'user_storage_usage', type_='foreignkey') + op.create_foreign_key(op.f('user_storage_usage_user_id_fkey'), 'user_storage_usage', 'users', ['user_id'], ['id'], ondelete='CASCADE') + op.drop_constraint(None, 'user_storage_usage', type_='unique') + op.drop_index(op.f('ix_user_storage_usage_id'), table_name='user_storage_usage') + op.alter_column('user_storage_usage', 'last_updated', + existing_type=sa.TIMESTAMP(), + type_=postgresql.TIMESTAMP(timezone=True), + nullable=False, + existing_server_default=sa.text('now()')) + op.drop_column('user_storage_usage', 'total_files_count') + op.drop_column('user_storage_usage', 'total_bytes_used') + op.drop_column('user_storage_usage', 'id') + op.add_column('user_files', sa.Column('mime_type', sa.VARCHAR(length=255), autoincrement=False, nullable=True)) + op.add_column('user_files', sa.Column('is_deleted', sa.BOOLEAN(), autoincrement=False, nullable=False)) + op.add_column('user_files', sa.Column('file_size', sa.BIGINT(), autoincrement=False, nullable=False)) + op.add_column('user_files', sa.Column('deleted_at', postgresql.TIMESTAMP(timezone=True), autoincrement=False, nullable=True)) + op.add_column('user_files', sa.Column('file_name', sa.VARCHAR(length=512), autoincrement=False, nullable=False)) + op.drop_constraint(None, 'user_files', type_='foreignkey') + op.create_foreign_key(op.f('user_files_user_id_fkey'), 'user_files', 'users', ['user_id'], ['id'], ondelete='CASCADE') + op.drop_index(op.f('ix_user_files_user_id'), table_name='user_files') + op.drop_index(op.f('ix_user_files_id'), table_name='user_files') + op.create_unique_constraint(op.f('user_files_s3_key_key'), 'user_files', ['s3_key']) + op.alter_column('user_files', 'created_at', + existing_type=sa.TIMESTAMP(), + type_=postgresql.TIMESTAMP(timezone=True), + nullable=False, + existing_server_default=sa.text('now()')) + op.drop_column('user_files', 'status') + op.drop_column('user_files', 'content_type') + op.drop_column('user_files', 'size') + op.drop_column('user_files', 'filename') + op.add_column('project_versions', sa.Column('session_id', sa.VARCHAR(length=255), autoincrement=False, nullable=True)) + op.add_column('project_versions', sa.Column('changes_description', sa.VARCHAR(length=500), autoincrement=False, nullable=True)) + op.drop_constraint(None, 'project_versions', type_='foreignkey') + op.create_foreign_key(op.f('project_versions_created_by_id_fkey'), 'project_versions', 'users', ['created_by_id'], ['id']) + op.alter_column('project_versions', 'created_by_id', + existing_type=sa.INTEGER(), + nullable=False) + op.drop_column('project_versions', 'description') + op.drop_column('project_versions', 'version_label') + op.add_column('project_shares', sa.Column('shared_by_user_id', sa.INTEGER(), autoincrement=False, nullable=False)) + op.add_column('project_shares', sa.Column('updated_at', postgresql.TIMESTAMP(timezone=True), autoincrement=False, nullable=False)) + op.create_foreign_key(op.f('project_shares_shared_by_user_id_fkey'), 'project_shares', 'users', ['shared_by_user_id'], ['id'], ondelete='CASCADE') + op.create_unique_constraint(op.f('unique_project_user_share'), 'project_shares', ['project_id', 'shared_with_user_id']) + op.alter_column('project_shares', 'permission', + existing_type=sa.String(length=20), + type_=sa.VARCHAR(length=50), + existing_nullable=False) + op.alter_column('project_comments', 'anchor', + existing_type=sa.Text(), + type_=postgresql.JSONB(astext_type=sa.Text()), + existing_nullable=True) + op.alter_column('project_activities', 'ip_address', + existing_type=sa.String(length=45), + type_=sa.VARCHAR(length=64), + existing_nullable=True) + op.add_column('drive_stars', sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), autoincrement=False, nullable=False)) + op.drop_constraint(None, 'drive_stars', type_='foreignkey') + op.create_foreign_key(op.f('drive_stars_user_id_fkey'), 'drive_stars', 'users', ['user_id'], ['id'], ondelete='CASCADE') + op.create_unique_constraint(op.f('uq_drive_star'), 'drive_stars', ['resource_type', 'resource_id', 'user_id']) + op.alter_column('drive_stars', 'resource_id', + existing_type=sa.INTEGER(), + nullable=False) + op.alter_column('drive_stars', 'resource_type', + existing_type=sa.VARCHAR(length=10), + nullable=False) + op.alter_column('drive_stars', 'user_id', + existing_type=sa.INTEGER(), + nullable=False) + op.add_column('drive_file_versions', sa.Column('mime_type', sa.VARCHAR(length=255), autoincrement=False, nullable=True)) + op.add_column('drive_file_versions', sa.Column('storage_key', sa.TEXT(), autoincrement=False, nullable=False)) + op.add_column('drive_file_versions', sa.Column('created_by_id', sa.INTEGER(), autoincrement=False, nullable=False)) + op.add_column('drive_file_versions', sa.Column('checksum', sa.VARCHAR(length=128), autoincrement=False, nullable=True)) + op.drop_constraint(None, 'drive_file_versions', type_='foreignkey') + op.drop_constraint(None, 'drive_file_versions', type_='foreignkey') + op.create_foreign_key(op.f('drive_file_versions_created_by_id_fkey'), 'drive_file_versions', 'users', ['created_by_id'], ['id']) + op.create_foreign_key(op.f('drive_file_versions_file_id_fkey'), 'drive_file_versions', 'drive_files', ['file_id'], ['id'], ondelete='CASCADE') + op.alter_column('drive_file_versions', 'created_at', + existing_type=sa.TIMESTAMP(), + type_=postgresql.TIMESTAMP(timezone=True), + nullable=False, + existing_server_default=sa.text('now()')) + op.alter_column('drive_file_versions', 'size', + existing_type=sa.BIGINT(), + nullable=False) + op.alter_column('drive_file_versions', 'version_number', + existing_type=sa.INTEGER(), + nullable=False) + op.alter_column('drive_file_versions', 'file_id', + existing_type=sa.INTEGER(), + nullable=False) + op.drop_column('drive_file_versions', 'uploader_id') + op.drop_column('drive_file_versions', 'content_type') + op.drop_column('drive_file_versions', 's3_key') + op.add_column('drive_comments', sa.Column('author_id', sa.INTEGER(), autoincrement=False, nullable=False)) + op.add_column('drive_comments', sa.Column('resolved_at', postgresql.TIMESTAMP(timezone=True), autoincrement=False, nullable=True)) + op.add_column('drive_comments', sa.Column('body', sa.TEXT(), autoincrement=False, nullable=False)) + op.add_column('drive_comments', sa.Column('anchor', postgresql.JSONB(astext_type=sa.Text()), autoincrement=False, nullable=True)) + op.drop_constraint(None, 'drive_comments', type_='foreignkey') + op.drop_constraint(None, 'drive_comments', type_='foreignkey') + op.create_foreign_key(op.f('drive_comments_author_id_fkey'), 'drive_comments', 'users', ['author_id'], ['id'], ondelete='CASCADE') + op.create_foreign_key(op.f('drive_comments_file_id_fkey'), 'drive_comments', 'drive_files', ['file_id'], ['id'], ondelete='CASCADE') + op.alter_column('drive_comments', 'created_at', + existing_type=sa.TIMESTAMP(), + type_=postgresql.TIMESTAMP(timezone=True), + nullable=False, + existing_server_default=sa.text('now()')) + op.drop_column('drive_comments', 'content') + op.drop_column('drive_comments', 'user_id') + op.add_column('drive_activities', sa.Column('ip_address', sa.VARCHAR(length=64), autoincrement=False, nullable=True)) + op.add_column('drive_activities', sa.Column('device_info', sa.TEXT(), autoincrement=False, nullable=True)) + op.drop_constraint(None, 'drive_activities', type_='foreignkey') + op.create_foreign_key(op.f('drive_activities_actor_id_fkey'), 'drive_activities', 'users', ['actor_id'], ['id'], ondelete='CASCADE') + op.alter_column('drive_activities', 'created_at', + existing_type=sa.TIMESTAMP(), + type_=postgresql.TIMESTAMP(timezone=True), + nullable=False, + existing_server_default=sa.text('now()')) + op.alter_column('drive_activities', 'activity_type', + existing_type=sa.VARCHAR(length=50), + nullable=False) + op.alter_column('drive_activities', 'actor_id', + existing_type=sa.INTEGER(), + nullable=False) + op.alter_column('drive_activities', 'resource_id', + existing_type=sa.INTEGER(), + nullable=False) + op.alter_column('drive_activities', 'resource_type', + existing_type=sa.String(length=20), + type_=sa.VARCHAR(length=10), + nullable=False) + op.create_table('drive_acl', + sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False), + sa.Column('resource_type', sa.VARCHAR(length=10), autoincrement=False, nullable=False), + sa.Column('resource_id', sa.INTEGER(), autoincrement=False, nullable=False), + sa.Column('subject_type', sa.VARCHAR(length=10), autoincrement=False, nullable=False), + sa.Column('subject_id', sa.INTEGER(), autoincrement=False, nullable=True), + sa.Column('role', sa.VARCHAR(length=20), autoincrement=False, nullable=False), + sa.Column('inherited_from', sa.INTEGER(), autoincrement=False, nullable=True), + sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), autoincrement=False, nullable=False), + sa.PrimaryKeyConstraint('id', name=op.f('drive_acl_pkey')) + ) + op.drop_index(op.f('ix_vector_indices_user_id'), table_name='vector_indices') + op.drop_index(op.f('ix_vector_indices_id'), table_name='vector_indices') + op.drop_table('vector_indices') + op.drop_index(op.f('ix_vector_chunks_user_id'), table_name='vector_chunks') + op.drop_index(op.f('ix_vector_chunks_id'), table_name='vector_chunks') + op.drop_index(op.f('ix_vector_chunks_document_id'), table_name='vector_chunks') + op.drop_table('vector_chunks') + op.drop_index(op.f('ix_chatbot_documents_user_id'), table_name='chatbot_documents') + op.drop_index(op.f('ix_chatbot_documents_id'), table_name='chatbot_documents') + op.drop_index(op.f('ix_chatbot_documents_drive_file_id'), table_name='chatbot_documents') + op.drop_index(op.f('ix_chatbot_documents_document_id'), table_name='chatbot_documents') + op.drop_table('chatbot_documents') + op.drop_table('drive_acls') + # ### end Alembic commands ### diff --git a/alembic/versions/4a9dc50ce4af_drop_price_display.py b/alembic/versions/4a9dc50ce4af_drop_price_display.py new file mode 100644 index 0000000..e0d7b97 --- /dev/null +++ b/alembic/versions/4a9dc50ce4af_drop_price_display.py @@ -0,0 +1,33 @@ +"""drop_price_display + +Revision ID: 4a9dc50ce4af +Revises: 2b4c62c1cf35 +Create Date: 2026-08-20 11:40:33.072200 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '4a9dc50ce4af' +down_revision: Union[str, None] = '2b4c62c1cf35' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.execute("UPDATE plans SET price_amount = CAST(SUBSTRING(price_display FROM '[\d.]+') AS NUMERIC) WHERE price_display IS NOT NULL AND price_display != 'Contact Us' AND price_amount IS NULL") + op.drop_column('plans', 'price_display') + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('plans', sa.Column('price_display', sa.VARCHAR(length=50), autoincrement=False, nullable=True)) + op.execute("UPDATE plans SET price_display = '$' || price_amount WHERE price_amount IS NOT NULL") + op.execute("UPDATE plans SET price_display = 'Contact Us' WHERE price_amount IS NULL") + # ### end Alembic commands ### diff --git a/alembic/versions/522ee6d91431_add_drive_share_link_fields.py b/alembic/versions/522ee6d91431_add_drive_share_link_fields.py new file mode 100644 index 0000000..558950b --- /dev/null +++ b/alembic/versions/522ee6d91431_add_drive_share_link_fields.py @@ -0,0 +1,30 @@ +"""add_drive_share_link_fields + +Revision ID: 522ee6d91431 +Revises: 8d4dced54966 +Create Date: 2026-02-24 14:35:17.542820 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '522ee6d91431' +down_revision: Union[str, None] = '8d4dced54966' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('drive_shares', sa.Column('is_active', sa.Boolean(), nullable=False)) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('drive_shares', 'is_active') + # ### end Alembic commands ### diff --git a/alembic/versions/540c970ff890_add_tenant_chat_token_quota.py b/alembic/versions/540c970ff890_add_tenant_chat_token_quota.py new file mode 100644 index 0000000..ad07c63 --- /dev/null +++ b/alembic/versions/540c970ff890_add_tenant_chat_token_quota.py @@ -0,0 +1,42 @@ +"""add_tenant_chat_token_quota + +Revision ID: 540c970ff890 +Revises: 7fc8dd61bb95 +Create Date: 2026-05-13 11:21:14.192033 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '540c970ff890' +down_revision: Union[str, None] = '7fc8dd61bb95' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Get current connection and inspector + conn = op.get_bind() + inspector = sa.inspect(conn) + + # Check if columns already exist (defensive migration) + columns = [c['name'] for c in inspector.get_columns('tenants')] + + if 'chat_token_daily_limit' not in columns: + op.add_column('tenants', sa.Column('chat_token_daily_limit', sa.BigInteger(), nullable=False, server_default='1000000')) + + if 'chat_tokens_used_today' not in columns: + op.add_column('tenants', sa.Column('chat_tokens_used_today', sa.BigInteger(), nullable=False, server_default='0')) + + if 'chat_tokens_last_reset_at' not in columns: + op.add_column('tenants', sa.Column('chat_tokens_last_reset_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False)) + + +def downgrade() -> None: + op.drop_column('tenants', 'chat_tokens_last_reset_at') + op.drop_column('tenants', 'chat_tokens_used_today') + op.drop_column('tenants', 'chat_token_daily_limit') diff --git a/alembic/versions/56d090047e85_increase_file_size_constraints.py b/alembic/versions/56d090047e85_increase_file_size_constraints.py new file mode 100644 index 0000000..6247c9b --- /dev/null +++ b/alembic/versions/56d090047e85_increase_file_size_constraints.py @@ -0,0 +1,74 @@ +"""increase_file_size_constraints + +Revision ID: 56d090047e85 +Revises: j002_unlimited_tenant_quota +Create Date: 2026-03-26 15:14:47.276886 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '56d090047e85' +down_revision: Union[str, None] = 'j002_unlimited_tenant_quota' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('projects', schema=None) as batch_op: + # Drop old 10MB constraints + batch_op.drop_constraint('ck_project_markdown_max', type_='check') + batch_op.drop_constraint('ck_project_xml_max', type_='check') + batch_op.drop_constraint('ck_project_html_max', type_='check') + + # Add new 30MB constraints + batch_op.create_check_constraint('ck_project_markdown_max', 'octet_length(markdown_content) <= 31457280') + batch_op.create_check_constraint('ck_project_xml_max', 'octet_length(xml_content) <= 31457280') + batch_op.create_check_constraint('ck_project_html_max', 'octet_length(html_content) <= 31457280') + + + + with op.batch_alter_table('project_versions', schema=None) as batch_op: + # Drop old 10MB constraints + batch_op.drop_constraint('ck_version_markdown_max', type_='check') + batch_op.drop_constraint('ck_version_xml_max', type_='check') + batch_op.drop_constraint('ck_version_html_max', type_='check') + + # Add new 30MB constraints + batch_op.create_check_constraint('ck_version_markdown_max', 'octet_length(markdown_content) <= 31457280') + batch_op.create_check_constraint('ck_version_xml_max', 'octet_length(xml_content) <= 31457280') + batch_op.create_check_constraint('ck_version_html_max', 'octet_length(html_content) <= 31457280') + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('project_versions', schema=None) as batch_op: + # Drop new 30MB constraints + batch_op.drop_constraint('ck_version_markdown_max', type_='check') + batch_op.drop_constraint('ck_version_xml_max', type_='check') + batch_op.drop_constraint('ck_version_html_max', type_='check') + + # Re-add old 10MB constraints + batch_op.create_check_constraint('ck_version_markdown_max', 'octet_length(markdown_content) <= 10485760') + batch_op.create_check_constraint('ck_version_xml_max', 'octet_length(xml_content) <= 10485760') + batch_op.create_check_constraint('ck_version_html_max', 'octet_length(html_content) <= 10485760') + + with op.batch_alter_table('projects', schema=None) as batch_op: + # Drop new 30MB constraints + batch_op.drop_constraint('ck_project_markdown_max', type_='check') + batch_op.drop_constraint('ck_project_xml_max', type_='check') + batch_op.drop_constraint('ck_project_html_max', type_='check') + + # Re-add old 10MB constraints + batch_op.create_check_constraint('ck_project_markdown_max', 'octet_length(markdown_content) <= 10485760') + batch_op.create_check_constraint('ck_project_xml_max', 'octet_length(xml_content) <= 10485760') + batch_op.create_check_constraint('ck_project_html_max', 'octet_length(html_content) <= 10485760') + + + # ### end Alembic commands ### diff --git a/alembic/versions/611d6b99ad11_add_multiple_files_to_signing_request.py b/alembic/versions/611d6b99ad11_add_multiple_files_to_signing_request.py new file mode 100644 index 0000000..3f81ded --- /dev/null +++ b/alembic/versions/611d6b99ad11_add_multiple_files_to_signing_request.py @@ -0,0 +1,34 @@ +"""add_multiple_files_to_signing_request + +Revision ID: 611d6b99ad11 +Revises: d551c03861f4 +Create Date: 2026-07-14 18:00:05.273738 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '611d6b99ad11' +down_revision: Union[str, None] = 'd551c03861f4' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column('signing_requests', sa.Column('drive_file_ids', sa.JSON(), nullable=True)) + op.add_column('signing_requests', sa.Column('signed_file_ids', sa.JSON(), nullable=True)) + op.alter_column('signing_requests', 'drive_file_id', + existing_type=sa.INTEGER(), + nullable=True) + + +def downgrade() -> None: + op.alter_column('signing_requests', 'drive_file_id', + existing_type=sa.INTEGER(), + nullable=False) + op.drop_column('signing_requests', 'signed_file_ids') + op.drop_column('signing_requests', 'drive_file_ids') diff --git a/alembic/versions/6c9cc3567866_merge_multiple_heads.py b/alembic/versions/6c9cc3567866_merge_multiple_heads.py new file mode 100644 index 0000000..309e12b --- /dev/null +++ b/alembic/versions/6c9cc3567866_merge_multiple_heads.py @@ -0,0 +1,26 @@ +"""merge_multiple_heads + +Revision ID: 6c9cc3567866 +Revises: 45c94ee15294, ff9052a97835 +Create Date: 2026-03-05 07:53:09.436096 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '6c9cc3567866' +down_revision: Union[str, None] = ('45c94ee15294', 'ff9052a97835') +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + pass + + +def downgrade() -> None: + pass diff --git a/alembic/versions/73dff81eed72_add_tenant_contacts.py b/alembic/versions/73dff81eed72_add_tenant_contacts.py new file mode 100644 index 0000000..8da7cf2 --- /dev/null +++ b/alembic/versions/73dff81eed72_add_tenant_contacts.py @@ -0,0 +1,43 @@ +"""Add tenant contacts + +Revision ID: 73dff81eed72 +Revises: dfae38e4ede3 +Create Date: 2026-08-12 10:56:21.668158 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = '73dff81eed72' +down_revision: Union[str, None] = 'dfae38e4ede3' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('tenant_contacts', + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('tenant_id', sa.UUID(), nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('email', sa.String(length=255), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('tenant_id', 'email', name='uq_tenant_contacts_tenant_id_email'), + ) + op.create_index(op.f('ix_tenant_contacts_email'), 'tenant_contacts', ['email'], unique=False) + op.create_index(op.f('ix_tenant_contacts_tenant_id'), 'tenant_contacts', ['tenant_id'], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_tenant_contacts_tenant_id'), table_name='tenant_contacts') + op.drop_index(op.f('ix_tenant_contacts_email'), table_name='tenant_contacts') + op.drop_table('tenant_contacts') + # ### end Alembic commands ### diff --git a/alembic/versions/765b053e3dcd_add_vector_index_checksum.py b/alembic/versions/765b053e3dcd_add_vector_index_checksum.py new file mode 100644 index 0000000..64fdc02 --- /dev/null +++ b/alembic/versions/765b053e3dcd_add_vector_index_checksum.py @@ -0,0 +1,30 @@ +"""add_vector_index_checksum + +Revision ID: 765b053e3dcd +Revises: be67927ab00d +Create Date: 2026-03-03 20:25:42.849543 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '765b053e3dcd' +down_revision: Union[str, None] = 'be67927ab00d' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### diff --git a/alembic/versions/7d7641cc1bb2_refactor_global_signature_config.py b/alembic/versions/7d7641cc1bb2_refactor_global_signature_config.py new file mode 100644 index 0000000..2e618d7 --- /dev/null +++ b/alembic/versions/7d7641cc1bb2_refactor_global_signature_config.py @@ -0,0 +1,32 @@ +"""refactor_global_signature_config + +Revision ID: 7d7641cc1bb2 +Revises: b3a7c1e9f042 +Create Date: 2026-07-21 07:19:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = '7d7641cc1bb2' +down_revision: Union[str, None] = 'af57c3d9f1c2' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column('global_signature_configs', sa.Column('global_credentials_json', postgresql.JSON(astext_type=sa.Text()), nullable=True)) + op.add_column('global_signature_configs', sa.Column('enabled_providers', postgresql.JSON(astext_type=sa.Text()), server_default='["docuseal", "zoho_sign"]', nullable=False)) + op.drop_column('global_signature_configs', 'docuseal_credentials') + op.drop_column('global_signature_configs', 'zoho_credentials') + + +def downgrade() -> None: + op.add_column('global_signature_configs', sa.Column('zoho_credentials', postgresql.JSON(astext_type=sa.Text()), autoincrement=False, nullable=True)) + op.add_column('global_signature_configs', sa.Column('docuseal_credentials', postgresql.JSON(astext_type=sa.Text()), autoincrement=False, nullable=True)) + op.drop_column('global_signature_configs', 'enabled_providers') + op.drop_column('global_signature_configs', 'global_credentials_json') diff --git a/alembic/versions/7fc8dd61bb95_add_chat_token_usage_and_quota_tables.py b/alembic/versions/7fc8dd61bb95_add_chat_token_usage_and_quota_tables.py new file mode 100644 index 0000000..974cd55 --- /dev/null +++ b/alembic/versions/7fc8dd61bb95_add_chat_token_usage_and_quota_tables.py @@ -0,0 +1,70 @@ +"""Add chat token usage and quota tables + +Revision ID: 7fc8dd61bb95 +Revises: z002_pref_lang +Create Date: 2026-05-12 18:38:23.935092 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '7fc8dd61bb95' +down_revision: Union[str, None] = 'z002_pref_lang' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Get current connection and inspector + conn = op.get_bind() + inspector = sa.inspect(conn) + + # 1. Create chat_token_usage if missing + if not inspector.has_table('chat_token_usage'): + op.create_table('chat_token_usage', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('session_id', sa.Integer(), nullable=True), + sa.Column('prompt_tokens', sa.BigInteger(), nullable=False, server_default='0'), + sa.Column('completion_tokens', sa.BigInteger(), nullable=False, server_default='0'), + sa.Column('total_tokens', sa.BigInteger(), nullable=False, server_default='0'), + sa.Column('model_name', sa.String(length=100), nullable=False), + sa.Column('cost_usd', sa.DECIMAL(precision=12, scale=8), nullable=False, server_default='0.0'), + sa.Column('timestamp', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('phase', sa.String(length=50), nullable=True), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['session_id'], ['chat_sessions.id'], ondelete='SET NULL'), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('chat_token_usage', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_chat_token_usage_id'), ['id'], unique=False) + batch_op.create_index(batch_op.f('ix_chat_token_usage_timestamp'), ['timestamp'], unique=False) + batch_op.create_index(batch_op.f('ix_chat_token_usage_user_id'), ['user_id'], unique=False) + batch_op.create_index(batch_op.f('ix_chat_token_usage_session_id'), ['session_id'], unique=False) + + # 2. Create chat_token_quota if missing + if not inspector.has_table('chat_token_quota'): + op.create_table('chat_token_quota', + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('daily_limit', sa.BigInteger(), nullable=False), + sa.Column('tokens_used_today', sa.BigInteger(), nullable=False, server_default='0'), + sa.Column('last_reset_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('total_tokens_ever', sa.BigInteger(), nullable=False, server_default='0'), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('user_id') + ) + + +def downgrade() -> None: + conn = op.get_bind() + inspector = sa.inspect(conn) + + if inspector.has_table('chat_token_quota'): + op.drop_table('chat_token_quota') + + if inspector.has_table('chat_token_usage'): + op.drop_table('chat_token_usage') diff --git a/alembic/versions/80be2c61e7fc_add_is_popular_to_plans.py b/alembic/versions/80be2c61e7fc_add_is_popular_to_plans.py new file mode 100644 index 0000000..31a39ef --- /dev/null +++ b/alembic/versions/80be2c61e7fc_add_is_popular_to_plans.py @@ -0,0 +1,30 @@ +"""add_is_popular_to_plans + +Revision ID: 80be2c61e7fc +Revises: 4a9dc50ce4af +Create Date: 2026-08-20 12:47:53.790237 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '80be2c61e7fc' +down_revision: Union[str, None] = '4a9dc50ce4af' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('plans', sa.Column('is_popular', sa.Boolean(), server_default=sa.text('false'), nullable=False)) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('plans', 'is_popular') + # ### end Alembic commands ### diff --git a/alembic/versions/823a486fd287_add_created_at_to_drive_stars.py b/alembic/versions/823a486fd287_add_created_at_to_drive_stars.py new file mode 100644 index 0000000..94fd0a3 --- /dev/null +++ b/alembic/versions/823a486fd287_add_created_at_to_drive_stars.py @@ -0,0 +1,36 @@ +"""add created_at to drive_stars + +Revision ID: 823a486fd287 +Revises: 88717d544229 +Create Date: 2026-03-27 23:24:47.417678 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '823a486fd287' +down_revision: Union[str, None] = '88717d544229' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Add column as nullable first + op.add_column('drive_stars', sa.Column('created_at', sa.DateTime(timezone=True), nullable=True)) + + # Update existing stars to current time so they aren't null + op.execute("UPDATE drive_stars SET created_at = NOW() WHERE created_at IS NULL") + + # Make column non-nullable with server default + op.alter_column('drive_stars', 'created_at', + existing_type=sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text('now()')) + + +def downgrade() -> None: + op.drop_column('drive_stars', 'created_at') diff --git a/alembic/versions/8736ab6bd5af_add_notifications_table.py b/alembic/versions/8736ab6bd5af_add_notifications_table.py new file mode 100644 index 0000000..9b782fb --- /dev/null +++ b/alembic/versions/8736ab6bd5af_add_notifications_table.py @@ -0,0 +1,41 @@ +"""add_notifications_table + +Revision ID: 8736ab6bd5af +Revises: 522ee6d91431 +Create Date: 2026-02-24 15:25:34.045912 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '8736ab6bd5af' +down_revision: Union[str, None] = '522ee6d91431' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + 'notifications', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('notif_type', sa.String(length=50), nullable=False), + sa.Column('title', sa.String(length=255), nullable=False), + sa.Column('message', sa.Text(), nullable=False), + sa.Column('resource_type', sa.String(length=20), nullable=True), + sa.Column('resource_id', sa.Integer(), nullable=True), + sa.Column('is_read', sa.Boolean(), nullable=False, server_default=sa.text('false')), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()')), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + ) + op.create_index('ix_notifications_user_id', 'notifications', ['user_id'], unique=False) + + +def downgrade() -> None: + op.drop_index('ix_notifications_user_id', table_name='notifications') + op.drop_table('notifications') diff --git a/alembic/versions/88717d544229_add_is_default_to_roles.py b/alembic/versions/88717d544229_add_is_default_to_roles.py new file mode 100644 index 0000000..32306f8 --- /dev/null +++ b/alembic/versions/88717d544229_add_is_default_to_roles.py @@ -0,0 +1,34 @@ +"""add is_default to roles + +Revision ID: 88717d544229 +Revises: 56d090047e85 +Create Date: 2026-03-27 14:08:16.581384 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = '88717d544229' +down_revision: Union[str, None] = '56d090047e85' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('roles', schema=None) as batch_op: + batch_op.add_column(sa.Column('is_default', sa.Boolean(), nullable=False, server_default=sa.text('false'))) + + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('roles', schema=None) as batch_op: + batch_op.drop_column('is_default') + + # ### end Alembic commands ### diff --git a/alembic/versions/89508c176437_limit_drivecomment_body_to_10000_chars.py b/alembic/versions/89508c176437_limit_drivecomment_body_to_10000_chars.py new file mode 100644 index 0000000..7c47a5f --- /dev/null +++ b/alembic/versions/89508c176437_limit_drivecomment_body_to_10000_chars.py @@ -0,0 +1,36 @@ +"""Limit DriveComment body to 10000 chars + +Revision ID: 89508c176437 +Revises: ff8052a97834 +Create Date: 2026-03-03 22:02:03.251490 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '89508c176437' +down_revision: Union[str, None] = 'ff8052a97834' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('drive_comments', 'body', + existing_type=sa.TEXT(), + type_=sa.String(length=10000), + existing_nullable=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('drive_comments', 'body', + existing_type=sa.String(length=10000), + type_=sa.TEXT(), + existing_nullable=False) + # ### end Alembic commands ### diff --git a/alembic/versions/8d4dced54966_add_is_root_column.py b/alembic/versions/8d4dced54966_add_is_root_column.py new file mode 100644 index 0000000..d9bcdd7 --- /dev/null +++ b/alembic/versions/8d4dced54966_add_is_root_column.py @@ -0,0 +1,59 @@ +"""add is_root column + +Revision ID: 8d4dced54966 +Revises: cfbee4f91fc2 +Create Date: 2026-02-24 11:15:51.402384 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '8d4dced54966' +down_revision: Union[str, None] = 'cfbee4f91fc2' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('drive_comments', sa.Column('author_id', sa.Integer(), nullable=True)) + op.add_column('drive_comments', sa.Column('body', sa.Text(), nullable=True)) + + # Seeding existing comments if any (though usually clean) + op.execute("UPDATE drive_comments SET author_id = user_id WHERE author_id IS NULL") + op.execute("UPDATE drive_comments SET body = content WHERE body IS NULL") + + op.alter_column('drive_comments', 'author_id', nullable=False) + op.alter_column('drive_comments', 'body', nullable=False) + + op.add_column('drive_comments', sa.Column('anchor', sa.Text(), nullable=True)) + op.add_column('drive_comments', sa.Column('resolved_at', sa.DateTime(), nullable=True)) + op.drop_constraint(op.f('drive_comments_user_id_fkey'), 'drive_comments', type_='foreignkey') + op.create_foreign_key(None, 'drive_comments', 'users', ['author_id'], ['id']) + op.drop_column('drive_comments', 'user_id') + op.drop_column('drive_comments', 'content') + + # 🆕 Add is_root safely + op.add_column('drive_folders', sa.Column('is_root', sa.Boolean(), nullable=True)) + op.execute("UPDATE drive_folders SET is_root = false WHERE is_root IS NULL") + op.execute("UPDATE drive_folders SET is_root = true WHERE parent_id IS NULL") + op.alter_column('drive_folders', 'is_root', nullable=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('drive_folders', 'is_root') + op.add_column('drive_comments', sa.Column('content', sa.TEXT(), autoincrement=False, nullable=False)) + op.add_column('drive_comments', sa.Column('user_id', sa.INTEGER(), autoincrement=False, nullable=False)) + op.drop_constraint(None, 'drive_comments', type_='foreignkey') + op.create_foreign_key(op.f('drive_comments_user_id_fkey'), 'drive_comments', 'users', ['user_id'], ['id']) + op.drop_column('drive_comments', 'resolved_at') + op.drop_column('drive_comments', 'anchor') + op.drop_column('drive_comments', 'body') + op.drop_column('drive_comments', 'author_id') + # ### end Alembic commands ### diff --git a/alembic/versions/905726af73c8_add_plan_roles_and_is_system_to_roles.py b/alembic/versions/905726af73c8_add_plan_roles_and_is_system_to_roles.py new file mode 100644 index 0000000..c51bd43 --- /dev/null +++ b/alembic/versions/905726af73c8_add_plan_roles_and_is_system_to_roles.py @@ -0,0 +1,34 @@ +"""Add plan_roles and is_system to roles + +Revision ID: 905726af73c8 +Revises: 80be2c61e7fc +Create Date: 2026-08-20 19:17:20.939383 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = '905726af73c8' +down_revision: Union[str, None] = '80be2c61e7fc' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table('plan_roles', + sa.Column('plan_id', sa.UUID(), nullable=False), + sa.Column('role_id', sa.UUID(), nullable=False), + sa.ForeignKeyConstraint(['plan_id'], ['plans.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['role_id'], ['roles.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('plan_id', 'role_id') + ) + op.add_column('roles', sa.Column('is_system', sa.Boolean(), nullable=False, server_default='false')) + + +def downgrade() -> None: + op.drop_column('roles', 'is_system') + op.drop_table('plan_roles') diff --git a/alembic/versions/9557c7307575_tenant_storage_config_and_provider.py b/alembic/versions/9557c7307575_tenant_storage_config_and_provider.py new file mode 100644 index 0000000..6469cd6 --- /dev/null +++ b/alembic/versions/9557c7307575_tenant_storage_config_and_provider.py @@ -0,0 +1,415 @@ +"""tenant_storage_config_and_provider + +Revision ID: 9557c7307575 +Revises: h004_signing_forensic_v2 +Create Date: 2026-03-16 13:00:58.602085 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = '9557c7307575' +down_revision: Union[str, None] = 'h004_signing_forensic_v2' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.execute("UPDATE drive_files SET size = 0 WHERE size IS NULL") + op.execute("UPDATE drive_files SET is_starred = false WHERE is_starred IS NULL") + op.execute("UPDATE drive_files SET is_trashed = false WHERE is_trashed IS NULL") + op.execute("UPDATE drive_files SET created_at = CURRENT_TIMESTAMP WHERE created_at IS NULL") + op.execute("UPDATE drive_files SET updated_at = CURRENT_TIMESTAMP WHERE updated_at IS NULL") + + op.execute("UPDATE drive_folders SET is_starred = false WHERE is_starred IS NULL") + op.execute("UPDATE drive_folders SET is_trashed = false WHERE is_trashed IS NULL") + op.execute("UPDATE drive_folders SET created_at = CURRENT_TIMESTAMP WHERE created_at IS NULL") + op.execute("UPDATE drive_folders SET updated_at = CURRENT_TIMESTAMP WHERE updated_at IS NULL") + + op.execute("UPDATE drive_shares SET created_at = CURRENT_TIMESTAMP WHERE created_at IS NULL") + + op.execute("UPDATE project_versions SET is_current = false WHERE is_current IS NULL") + op.execute("UPDATE project_versions SET created_at = CURRENT_TIMESTAMP WHERE created_at IS NULL") + + op.execute("UPDATE projects SET status = 'processing' WHERE status IS NULL") + op.execute("UPDATE projects SET file_type = 'PDF' WHERE file_type IS NULL") + op.execute("UPDATE projects SET output_format = 'EPUB' WHERE output_format IS NULL") + op.execute("UPDATE projects SET progress = 0 WHERE progress IS NULL") + op.execute("UPDATE projects SET current_step = 'upload' WHERE current_step IS NULL") + op.execute("UPDATE projects SET created_at = CURRENT_TIMESTAMP WHERE created_at IS NULL") + op.execute("UPDATE projects SET updated_at = CURRENT_TIMESTAMP WHERE updated_at IS NULL") + + op.execute("UPDATE signing_requests SET created_at = CURRENT_TIMESTAMP WHERE created_at IS NULL") + + op.execute("UPDATE tenants SET created_at = CURRENT_TIMESTAMP WHERE created_at IS NULL") + op.execute("UPDATE tenants SET updated_at = CURRENT_TIMESTAMP WHERE updated_at IS NULL") + + op.execute("UPDATE users SET subscription = 'free' WHERE subscription IS NULL") + op.execute("UPDATE users SET created_at = CURRENT_TIMESTAMP WHERE created_at IS NULL") + + op.create_table('tenant_storage_configs', + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('tenant_id', sa.UUID(), nullable=False), + sa.Column('storage_provider', sa.String(length=50), nullable=False), + sa.Column('b2_key_id', sa.String(length=255), nullable=True), + sa.Column('b2_application_key', sa.String(length=512), nullable=True), + sa.Column('b2_bucket_name', sa.String(length=255), nullable=True), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.Column('created_by_id', sa.Integer(), nullable=True), + sa.Column('updated_by_id', sa.Integer(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['created_by_id'], ['users.id'], ondelete='SET NULL'), + sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['updated_by_id'], ['users.id'], ondelete='SET NULL'), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('tenant_storage_configs', schema=None) as batch_op: + op.execute("CREATE UNIQUE INDEX IF NOT EXISTS ix_tenant_storage_configs_tenant_id ON tenant_storage_configs (tenant_id)") + + with op.batch_alter_table('accesses', schema=None) as batch_op: + batch_op.alter_column('name', + existing_type=sa.VARCHAR(length=100), + type_=sa.String(length=255), + existing_nullable=False) + op.execute("DROP INDEX IF EXISTS ix_accesses_name") + batch_op.drop_constraint(batch_op.f('uq_accesses_access_code'), type_='unique') + op.execute("CREATE UNIQUE INDEX IF NOT EXISTS ix_accesses_access_code ON accesses (access_code)") + op.execute("CREATE INDEX IF NOT EXISTS ix_accesses_category ON accesses (category)") + op.execute("ALTER TABLE accesses DROP COLUMN IF EXISTS description") + + op.execute("DROP INDEX IF EXISTS idx_drive_activities_actor") + op.execute("DROP INDEX IF EXISTS idx_drive_activities_res") + op.execute("ALTER TABLE drive_activities DROP COLUMN IF EXISTS metadata") + + with op.batch_alter_table('drive_file_versions', schema=None) as batch_op: + batch_op.add_column(sa.Column('storage_provider', sa.String(length=50), nullable=False, server_default='minio')) + op.execute("DROP INDEX IF EXISTS idx_drive_versions_file") + + with op.batch_alter_table('drive_files', schema=None) as batch_op: + batch_op.alter_column('size', + existing_type=sa.BIGINT(), + nullable=False, + existing_server_default=sa.text('0')) + batch_op.alter_column('is_starred', + existing_type=sa.BOOLEAN(), + nullable=False, + existing_server_default=sa.text('false')) + batch_op.alter_column('is_trashed', + existing_type=sa.BOOLEAN(), + nullable=False, + existing_server_default=sa.text('false')) + batch_op.alter_column('created_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + nullable=False, + existing_server_default=sa.text('CURRENT_TIMESTAMP')) + batch_op.alter_column('updated_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + nullable=False, + existing_server_default=sa.text('now()')) + op.execute("DROP INDEX IF EXISTS idx_drive_files_folder") + op.execute("DROP INDEX IF EXISTS idx_drive_files_owner") + op.execute("DROP INDEX IF EXISTS idx_drive_files_trashed") + + with op.batch_alter_table('drive_folders', schema=None) as batch_op: + batch_op.alter_column('is_starred', + existing_type=sa.BOOLEAN(), + nullable=False, + existing_server_default=sa.text('false')) + batch_op.alter_column('is_trashed', + existing_type=sa.BOOLEAN(), + nullable=False, + existing_server_default=sa.text('false')) + batch_op.alter_column('created_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + nullable=False, + existing_server_default=sa.text('CURRENT_TIMESTAMP')) + batch_op.alter_column('updated_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + nullable=False, + existing_server_default=sa.text('now()')) + op.execute("DROP INDEX IF EXISTS idx_drive_folders_owner") + op.execute("DROP INDEX IF EXISTS idx_drive_folders_parent") + op.execute("DROP INDEX IF EXISTS idx_drive_folders_trashed") + + with op.batch_alter_table('drive_shares', schema=None) as batch_op: + batch_op.alter_column('created_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + nullable=False, + existing_server_default=sa.text('CURRENT_TIMESTAMP')) + op.execute("DROP INDEX IF EXISTS idx_drive_shares_resource") + op.execute("DROP INDEX IF EXISTS uq_drive_shares_link") + batch_op.create_unique_constraint(None, ['link_token']) + batch_op.drop_constraint(batch_op.f('drive_shares_created_by_id_fkey'), type_='foreignkey') + batch_op.create_foreign_key(None, 'users', ['created_by_id'], ['id'], ondelete='CASCADE') + + with op.batch_alter_table('project_versions', schema=None) as batch_op: + batch_op.alter_column('is_current', + existing_type=sa.BOOLEAN(), + nullable=False, + existing_server_default=sa.text('false')) + batch_op.alter_column('created_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + nullable=False, + existing_server_default=sa.text('CURRENT_TIMESTAMP')) + op.execute("DROP INDEX IF EXISTS idx_project_versions_created_by") + op.execute("DROP INDEX IF EXISTS idx_project_versions_project_id") + + with op.batch_alter_table('projects', schema=None) as batch_op: + batch_op.alter_column('status', + existing_type=sa.VARCHAR(length=50), + nullable=False, + existing_server_default=sa.text("'processing'::character varying")) + batch_op.alter_column('file_type', + existing_type=sa.VARCHAR(length=50), + nullable=False, + existing_server_default=sa.text("'PDF'::character varying")) + batch_op.alter_column('output_format', + existing_type=sa.VARCHAR(length=50), + nullable=False, + existing_server_default=sa.text("'EPUB'::character varying")) + batch_op.alter_column('progress', + existing_type=sa.INTEGER(), + nullable=False, + existing_server_default=sa.text('0')) + batch_op.alter_column('current_step', + existing_type=sa.VARCHAR(length=50), + nullable=False, + existing_server_default=sa.text("'upload'::character varying")) + batch_op.alter_column('created_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + nullable=False, + existing_server_default=sa.text('CURRENT_TIMESTAMP')) + batch_op.alter_column('updated_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + nullable=False, + existing_server_default=sa.text('now()')) + op.execute("DROP INDEX IF EXISTS idx_projects_session_id") + op.execute("DROP INDEX IF EXISTS idx_projects_user_id") + batch_op.create_foreign_key(None, 'users', ['user_id'], ['id'], ondelete='CASCADE') + + with op.batch_alter_table('signing_requests', schema=None) as batch_op: + batch_op.alter_column('created_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + nullable=False, + existing_server_default=sa.text('now()')) + + with op.batch_alter_table('system_configurations', schema=None) as batch_op: + batch_op.drop_constraint(batch_op.f('uq_system_configurations_config_key'), type_='unique') + + with op.batch_alter_table('tenant_smtp_configs', schema=None) as batch_op: + batch_op.drop_constraint(batch_op.f('uq_tenant_smtp_configs_tenant_id'), type_='unique') + op.execute("DROP INDEX IF EXISTS ix_tenant_smtp_configs_tenant_id") + op.execute("CREATE UNIQUE INDEX IF NOT EXISTS ix_tenant_smtp_configs_tenant_id ON tenant_smtp_configs (tenant_id)") + + with op.batch_alter_table('tenants', schema=None) as batch_op: + batch_op.alter_column('created_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + nullable=False, + existing_server_default=sa.text('now()')) + batch_op.alter_column('updated_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + nullable=False, + existing_server_default=sa.text('now()')) + batch_op.drop_constraint(batch_op.f('tenants_slug_key'), type_='unique') + op.execute("CREATE INDEX IF NOT EXISTS ix_tenants_id ON tenants (id)") + op.execute("CREATE UNIQUE INDEX IF NOT EXISTS ix_tenants_slug ON tenants (slug)") + + with op.batch_alter_table('user_files', schema=None) as batch_op: + batch_op.add_column(sa.Column('storage_provider', sa.String(length=50), nullable=False, server_default='minio')) + op.execute("DROP INDEX IF EXISTS idx_user_files_s3_key") + op.execute("DROP INDEX IF EXISTS idx_user_files_user") + + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.alter_column('subscription', + existing_type=sa.VARCHAR(length=50), + nullable=False, + existing_server_default=sa.text("'free'::character varying")) + batch_op.alter_column('created_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + nullable=False, + existing_server_default=sa.text('CURRENT_TIMESTAMP')) + op.execute("DROP INDEX IF EXISTS idx_users_email") + op.execute("CREATE INDEX IF NOT EXISTS ix_users_email ON users (email)") + + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('users', schema=None) as batch_op: + op.execute("DROP INDEX IF EXISTS ix_users_email") + op.execute("CREATE INDEX IF NOT EXISTS idx_users_email ON users (email)") + batch_op.alter_column('created_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + nullable=True, + existing_server_default=sa.text('CURRENT_TIMESTAMP')) + batch_op.alter_column('subscription', + existing_type=sa.VARCHAR(length=50), + nullable=True, + existing_server_default=sa.text("'free'::character varying")) + + with op.batch_alter_table('user_files', schema=None) as batch_op: + op.execute("CREATE INDEX IF NOT EXISTS idx_user_files_user ON user_files (user_id)") + op.execute("CREATE INDEX IF NOT EXISTS idx_user_files_s3_key ON user_files (s3_key)") + op.execute("ALTER TABLE user_files DROP COLUMN IF EXISTS storage_provider") + + with op.batch_alter_table('tenants', schema=None) as batch_op: + op.execute("DROP INDEX IF EXISTS ix_tenants_slug") + op.execute("DROP INDEX IF EXISTS ix_tenants_id") + batch_op.create_unique_constraint(batch_op.f('tenants_slug_key'), ['slug'], postgresql_nulls_not_distinct=False) + batch_op.alter_column('updated_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + nullable=True, + existing_server_default=sa.text('now()')) + batch_op.alter_column('created_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + nullable=True, + existing_server_default=sa.text('now()')) + + with op.batch_alter_table('tenant_smtp_configs', schema=None) as batch_op: + op.execute("DROP INDEX IF EXISTS ix_tenant_smtp_configs_tenant_id") + op.execute("CREATE INDEX IF NOT EXISTS ix_tenant_smtp_configs_tenant_id ON tenant_smtp_configs (tenant_id)") + batch_op.create_unique_constraint(batch_op.f('uq_tenant_smtp_configs_tenant_id'), ['tenant_id'], postgresql_nulls_not_distinct=False) + + with op.batch_alter_table('system_configurations', schema=None) as batch_op: + batch_op.create_unique_constraint(batch_op.f('uq_system_configurations_config_key'), ['config_key'], postgresql_nulls_not_distinct=False) + + with op.batch_alter_table('signing_requests', schema=None) as batch_op: + batch_op.alter_column('created_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + nullable=True, + existing_server_default=sa.text('now()')) + + with op.batch_alter_table('projects', schema=None) as batch_op: + batch_op.drop_constraint(None, type_='foreignkey') + op.execute("CREATE INDEX IF NOT EXISTS idx_projects_user_id ON projects (user_id)") + op.execute("CREATE INDEX IF NOT EXISTS idx_projects_session_id ON projects (session_id)") + batch_op.alter_column('updated_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + nullable=True, + existing_server_default=sa.text('now()')) + batch_op.alter_column('created_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + nullable=True, + existing_server_default=sa.text('CURRENT_TIMESTAMP')) + batch_op.alter_column('current_step', + existing_type=sa.VARCHAR(length=50), + nullable=True, + existing_server_default=sa.text("'upload'::character varying")) + batch_op.alter_column('progress', + existing_type=sa.INTEGER(), + nullable=True, + existing_server_default=sa.text('0')) + batch_op.alter_column('output_format', + existing_type=sa.VARCHAR(length=50), + nullable=True, + existing_server_default=sa.text("'EPUB'::character varying")) + batch_op.alter_column('file_type', + existing_type=sa.VARCHAR(length=50), + nullable=True, + existing_server_default=sa.text("'PDF'::character varying")) + batch_op.alter_column('status', + existing_type=sa.VARCHAR(length=50), + nullable=True, + existing_server_default=sa.text("'processing'::character varying")) + + with op.batch_alter_table('project_versions', schema=None) as batch_op: + op.execute("CREATE INDEX IF NOT EXISTS idx_project_versions_project_id ON project_versions (project_id)") + op.execute("CREATE INDEX IF NOT EXISTS idx_project_versions_created_by ON project_versions (created_by_id)") + batch_op.alter_column('created_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + nullable=True, + existing_server_default=sa.text('CURRENT_TIMESTAMP')) + batch_op.alter_column('is_current', + existing_type=sa.BOOLEAN(), + nullable=True, + existing_server_default=sa.text('false')) + + with op.batch_alter_table('drive_shares', schema=None) as batch_op: + batch_op.drop_constraint(None, type_='foreignkey') + batch_op.create_foreign_key(batch_op.f('drive_shares_created_by_id_fkey'), 'users', ['created_by_id'], ['id']) + batch_op.drop_constraint(None, type_='unique') + op.execute("CREATE INDEX IF NOT EXISTS uq_drive_shares_link ON drive_shares (link_token)") + op.execute("CREATE INDEX IF NOT EXISTS idx_drive_shares_resource ON drive_shares (resource_type,resource_id)") + batch_op.alter_column('created_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + nullable=True, + existing_server_default=sa.text('CURRENT_TIMESTAMP')) + + with op.batch_alter_table('drive_folders', schema=None) as batch_op: + op.execute("CREATE INDEX IF NOT EXISTS idx_drive_folders_trashed ON drive_folders (is_trashed)") + op.execute("CREATE INDEX IF NOT EXISTS idx_drive_folders_parent ON drive_folders (parent_id)") + op.execute("CREATE INDEX IF NOT EXISTS idx_drive_folders_owner ON drive_folders (owner_id)") + batch_op.alter_column('updated_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + nullable=True, + existing_server_default=sa.text('now()')) + batch_op.alter_column('created_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + nullable=True, + existing_server_default=sa.text('CURRENT_TIMESTAMP')) + batch_op.alter_column('is_trashed', + existing_type=sa.BOOLEAN(), + nullable=True, + existing_server_default=sa.text('false')) + batch_op.alter_column('is_starred', + existing_type=sa.BOOLEAN(), + nullable=True, + existing_server_default=sa.text('false')) + + with op.batch_alter_table('drive_files', schema=None) as batch_op: + op.execute("CREATE INDEX IF NOT EXISTS idx_drive_files_trashed ON drive_files (is_trashed)") + op.execute("CREATE INDEX IF NOT EXISTS idx_drive_files_owner ON drive_files (owner_id)") + op.execute("CREATE INDEX IF NOT EXISTS idx_drive_files_folder ON drive_files (folder_id)") + batch_op.alter_column('updated_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + nullable=True, + existing_server_default=sa.text('now()')) + batch_op.alter_column('created_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + nullable=True, + existing_server_default=sa.text('CURRENT_TIMESTAMP')) + batch_op.alter_column('is_trashed', + existing_type=sa.BOOLEAN(), + nullable=True, + existing_server_default=sa.text('false')) + batch_op.alter_column('is_starred', + existing_type=sa.BOOLEAN(), + nullable=True, + existing_server_default=sa.text('false')) + batch_op.alter_column('size', + existing_type=sa.BIGINT(), + nullable=True, + existing_server_default=sa.text('0')) + + with op.batch_alter_table('drive_file_versions', schema=None) as batch_op: + op.execute("CREATE INDEX IF NOT EXISTS idx_drive_versions_file ON drive_file_versions (file_id)") + op.execute("ALTER TABLE drive_file_versions DROP COLUMN IF EXISTS storage_provider") + + with op.batch_alter_table('drive_activities', schema=None) as batch_op: + batch_op.add_column(sa.Column('metadata', sa.TEXT(), autoincrement=False, nullable=True)) + op.execute("CREATE INDEX IF NOT EXISTS idx_drive_activities_res ON drive_activities (resource_type,resource_id)") + op.execute("CREATE INDEX IF NOT EXISTS idx_drive_activities_actor ON drive_activities (actor_id)") + + with op.batch_alter_table('accesses', schema=None) as batch_op: + batch_op.add_column(sa.Column('description', sa.VARCHAR(length=255), autoincrement=False, nullable=True)) + op.execute("DROP INDEX IF EXISTS ix_accesses_category") + op.execute("DROP INDEX IF EXISTS ix_accesses_access_code") + batch_op.create_unique_constraint(batch_op.f('uq_accesses_access_code'), ['access_code'], postgresql_nulls_not_distinct=False) + op.execute("CREATE INDEX IF NOT EXISTS ix_accesses_name ON accesses (name)") + batch_op.alter_column('name', + existing_type=sa.String(length=255), + type_=sa.VARCHAR(length=100), + existing_nullable=False) + + with op.batch_alter_table('tenant_storage_configs', schema=None) as batch_op: + op.execute("DROP INDEX IF EXISTS ix_tenant_storage_configs_tenant_id") + + op.execute("DROP TABLE IF EXISTS tenant_storage_configs") + # ### end Alembic commands ### diff --git a/alembic/versions/9a6db38b86a6_make_drivecomment_generic.py b/alembic/versions/9a6db38b86a6_make_drivecomment_generic.py new file mode 100644 index 0000000..6b03ba2 --- /dev/null +++ b/alembic/versions/9a6db38b86a6_make_drivecomment_generic.py @@ -0,0 +1,57 @@ +"""Make DriveComment generic + +Revision ID: 9a6db38b86a6 +Revises: 41bed7eb6ed9 +Create Date: 2026-02-27 14:32:05.071480 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '9a6db38b86a6' +down_revision: Union[str, None] = '41bed7eb6ed9' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # 1. Add columns as nullable first + op.add_column('drive_comments', sa.Column('resource_type', sa.String(length=20), nullable=True)) + op.add_column('drive_comments', sa.Column('resource_id', sa.Integer(), nullable=True)) + + # 2. Populate data from existing file_id safely + # Use WHERE file_id IS NOT NULL to avoid unnecessary or invalid updates + op.execute("UPDATE drive_comments SET resource_type = 'file', resource_id = file_id WHERE file_id IS NOT NULL") + + # 3. Set to NOT NULL now that data is populated + op.alter_column('drive_comments', 'resource_type', nullable=False) + op.alter_column('drive_comments', 'resource_id', nullable=False) + + # 4. Cleanup old column and constraint + with op.batch_alter_table('drive_comments') as batch_op: + batch_op.drop_column('file_id') + + +def downgrade() -> None: + # 1. Add column back as nullable first + op.add_column('drive_comments', sa.Column('file_id', sa.Integer(), nullable=True)) + + # 2. Restore data from generic columns back to file_id + # Only migrate records that were originally or are currently 'file' types + op.execute("UPDATE drive_comments SET file_id = resource_id WHERE resource_type = 'file'") + + # 3. Delete or handle non-file comments that cannot exist in the old schema + # Alternatively, keep file_id as nullable if preferred, but here we restore original state + op.execute("DELETE FROM drive_comments WHERE resource_type != 'file'") + + # 4. Restore constraints + op.alter_column('drive_comments', 'file_id', nullable=False) + op.create_foreign_key('drive_comments_file_id_fkey', 'drive_comments', 'drive_files', ['file_id'], ['id']) + + # 5. Cleanup generic columns + op.drop_column('drive_comments', 'resource_id') + op.drop_column('drive_comments', 'resource_type') diff --git a/alembic/versions/9af99fc7ca0f_merge_heads.py b/alembic/versions/9af99fc7ca0f_merge_heads.py new file mode 100644 index 0000000..d4b7c0f --- /dev/null +++ b/alembic/versions/9af99fc7ca0f_merge_heads.py @@ -0,0 +1,26 @@ +"""merge heads + +Revision ID: 9af99fc7ca0f +Revises: 15f935213b22, cba70ab35793, v3_extraction +Create Date: 2026-04-18 14:22:43.985119 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '9af99fc7ca0f' +down_revision: Union[str, None] = ('15f935213b22', 'cba70ab35793', 'v3_extraction') +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + pass + + +def downgrade() -> None: + pass diff --git a/alembic/versions/9c49fe120ffb_add_device_and_session_tables.py b/alembic/versions/9c49fe120ffb_add_device_and_session_tables.py new file mode 100644 index 0000000..9a4dd60 --- /dev/null +++ b/alembic/versions/9c49fe120ffb_add_device_and_session_tables.py @@ -0,0 +1,79 @@ +"""Add device and session tables + +Revision ID: 9c49fe120ffb +Revises: z004_add_global_workflows +Create Date: 2026-08-06 15:27:12.015009 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = '9c49fe120ffb' +down_revision: Union[str, None] = 'z004_add_global_workflows' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('devices', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('fingerprint', sa.String(length=255), nullable=False), + sa.Column('browser', sa.String(length=100), nullable=True), + sa.Column('os', sa.String(length=100), nullable=True), + sa.Column('device_type', sa.String(length=50), nullable=True), + sa.Column('user_agent', sa.String(length=512), nullable=True), + sa.Column('ip', sa.String(length=50), nullable=True), + sa.Column('country', sa.String(length=100), nullable=True), + sa.Column('state', sa.String(length=100), nullable=True), + sa.Column('city', sa.String(length=100), nullable=True), + sa.Column('latitude', sa.Float(), nullable=True), + sa.Column('longitude', sa.Float(), nullable=True), + sa.Column('first_login', sa.DateTime(timezone=True), nullable=False), + sa.Column('last_login', sa.DateTime(timezone=True), nullable=False), + sa.Column('last_ip', sa.String(length=50), nullable=True), + sa.Column('is_blocked', sa.Boolean(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_devices_fingerprint'), 'devices', ['fingerprint'], unique=False) + op.create_index(op.f('ix_devices_user_id'), 'devices', ['user_id'], unique=False) + op.create_table('sessions', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('device_id', sa.Integer(), nullable=True), + sa.Column('refresh_token_hash', sa.String(length=255), nullable=False), + sa.Column('status', sa.String(length=50), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('last_activity', sa.DateTime(timezone=True), nullable=False), + sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('revoked_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('revoked_by', sa.String(length=50), nullable=True), + sa.Column('revoked_by_user_id', sa.Integer(), nullable=True), + sa.Column('reason', sa.String(length=255), nullable=True), + sa.ForeignKeyConstraint(['device_id'], ['devices.id'], ondelete='SET NULL'), + sa.ForeignKeyConstraint(['revoked_by_user_id'], ['users.id'], ondelete='SET NULL'), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_sessions_device_id'), 'sessions', ['device_id'], unique=False) + op.create_index(op.f('ix_sessions_user_id'), 'sessions', ['user_id'], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_sessions_user_id'), table_name='sessions') + op.drop_index(op.f('ix_sessions_device_id'), table_name='sessions') + op.drop_table('sessions') + op.drop_index(op.f('ix_devices_user_id'), table_name='devices') + op.drop_index(op.f('ix_devices_fingerprint'), table_name='devices') + op.drop_table('devices') + # ### end Alembic commands ### diff --git a/alembic/versions/9c4a95ac6726_add_saasrolemapping.py b/alembic/versions/9c4a95ac6726_add_saasrolemapping.py new file mode 100644 index 0000000..8365943 --- /dev/null +++ b/alembic/versions/9c4a95ac6726_add_saasrolemapping.py @@ -0,0 +1,45 @@ +"""Add SaaSRoleMapping + +Revision ID: 9c4a95ac6726 +Revises: 905726af73c8 +Create Date: 2026-08-28 15:36:54.084287 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '9c4a95ac6726' +down_revision: Union[str, None] = '905726af73c8' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('saas_role_mappings', + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('saas_role_id', sa.String(), nullable=False), + sa.Column('docqube_role_id', sa.UUID(), nullable=False), + sa.Column('metadata', sa.JSON(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True), + sa.ForeignKeyConstraint(['docqube_role_id'], ['roles.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_saas_role_mappings_id'), 'saas_role_mappings', ['id'], unique=False) + op.create_index(op.f('ix_saas_role_mappings_saas_role_id'), 'saas_role_mappings', ['saas_role_id'], unique=True) + op.execute("ALTER TABLE tenant_contacts DROP CONSTRAINT IF EXISTS uq_tenant_contacts_tenant_id_email") + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_unique_constraint('uq_tenant_contacts_tenant_id_email', 'tenant_contacts', ['tenant_id', 'email']) + op.drop_index(op.f('ix_saas_role_mappings_saas_role_id'), table_name='saas_role_mappings') + op.drop_index(op.f('ix_saas_role_mappings_id'), table_name='saas_role_mappings') + op.drop_table('saas_role_mappings') + # ### end Alembic commands ### diff --git a/alembic/versions/9f6521a38bf3_limit_chatbotdocument_file_path_and_.py b/alembic/versions/9f6521a38bf3_limit_chatbotdocument_file_path_and_.py new file mode 100644 index 0000000..cc03f5f --- /dev/null +++ b/alembic/versions/9f6521a38bf3_limit_chatbotdocument_file_path_and_.py @@ -0,0 +1,30 @@ +"""Limit ChatbotDocument file_path and filename to 512 chars + +Revision ID: 9f6521a38bf3 +Revises: 3aa7d970b8c2 +Create Date: 2026-03-03 22:12:00.390551 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '9f6521a38bf3' +down_revision: Union[str, None] = '3aa7d970b8c2' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### diff --git a/alembic/versions/a1b2c3d4e5f6_add_rbac_tables.py b/alembic/versions/a1b2c3d4e5f6_add_rbac_tables.py new file mode 100644 index 0000000..f81d5da --- /dev/null +++ b/alembic/versions/a1b2c3d4e5f6_add_rbac_tables.py @@ -0,0 +1,90 @@ +"""Add RBAC tables (accesses, roles, role_accesses) and role_id to users + +Revision ID: a1b2c3d4e5f6 +Revises: ff9052a97835 +Create Date: 2026-03-05 11:15:00.000000 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import UUID + + +# revision identifiers, used by Alembic. +revision: str = "a1b2c3d4e5f6" +down_revision: Union[str, None] = ("6c9cc3567866", "ff9052a97835") +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # 1. Create accesses table + op.execute( + """ + CREATE TABLE accesses ( + id SERIAL PRIMARY KEY, + name VARCHAR(100) NOT NULL UNIQUE, + description VARCHAR(255), + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL + ); + """ + ) + op.execute("CREATE INDEX ix_accesses_name ON accesses (name);") + + # 2. Create roles table (tenant_id nullable = system-wide role) + op.execute( + """ + CREATE TABLE roles ( + id SERIAL PRIMARY KEY, + tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE, + name VARCHAR(100) NOT NULL, + description VARCHAR(255), + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL, + CONSTRAINT uq_role_tenant_name UNIQUE (tenant_id, name) + ); + """ + ) + op.execute("CREATE INDEX ix_roles_tenant_id ON roles (tenant_id);") + + # 3. Create role_accesses join table (composite PK) + op.execute( + """ + CREATE TABLE role_accesses ( + role_id INTEGER NOT NULL REFERENCES roles(id) ON DELETE CASCADE, + access_id INTEGER NOT NULL REFERENCES accesses(id) ON DELETE CASCADE, + CONSTRAINT pk_role_accesses PRIMARY KEY (role_id, access_id) + ); + """ + ) + + # 4. Add role_id to users (nullable FK, SET NULL on delete) + op.execute( + """ + ALTER TABLE users + ADD COLUMN role_id INTEGER REFERENCES roles(id) ON DELETE SET NULL; + """ + ) + op.execute("CREATE INDEX ix_users_role_id ON users (role_id);") + + + +def downgrade() -> None: + # Reverse in dependency order + + # Remove role_id from users + op.execute("DROP INDEX IF EXISTS ix_users_role_id;") + op.execute("ALTER TABLE users DROP COLUMN IF EXISTS role_id;") + + # Drop join table + op.execute("DROP TABLE IF EXISTS role_accesses;") + + # Drop roles table (seed data goes with it) + op.execute("DROP INDEX IF EXISTS ix_roles_tenant_id;") + op.execute("DROP TABLE IF EXISTS roles;") + + # Drop accesses table + op.execute("DROP INDEX IF EXISTS ix_accesses_name;") + op.execute("DROP TABLE IF EXISTS accesses;") diff --git a/alembic/versions/a2b3c4d5e6f7_update_access_model_with_hierarchy.py b/alembic/versions/a2b3c4d5e6f7_update_access_model_with_hierarchy.py new file mode 100644 index 0000000..5dcf1a5 --- /dev/null +++ b/alembic/versions/a2b3c4d5e6f7_update_access_model_with_hierarchy.py @@ -0,0 +1,75 @@ +"""Update Access model with hierarchical permission support + +Revision ID: a2b3c4d5e6f7 +Revises: a1b2c3d4e5f6 +Create Date: 2026-03-05 10:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'a2b3c4d5e6f7' +down_revision = 'a1b2c3d4e5f6' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # FIRST: Drop any existing unique constraint on `name` (constraints may be named differently depending on DB) + op.execute("ALTER TABLE accesses DROP CONSTRAINT IF EXISTS uq_accesses_name CASCADE") + op.execute("ALTER TABLE accesses DROP CONSTRAINT IF EXISTS accesses_name_key CASCADE") + # also drop postgresql-generated index if it survived + op.execute("DROP INDEX IF EXISTS accesses_name_key") + + # Add new columns to accesses table (all nullable first) + op.add_column('accesses', sa.Column('access_code', sa.String(255), nullable=True)) + op.add_column('accesses', sa.Column('category', sa.String(100), nullable=True)) + op.add_column('accesses', sa.Column('parent_id', sa.Integer(), nullable=True)) + + # Create indexes on new columns + op.create_index('ix_access_code', 'accesses', ['access_code']) + op.create_index('ix_category', 'accesses', ['category']) + op.create_index('ix_accesses_parent_id', 'accesses', ['parent_id']) + + # Set default values from existing 'name' field + op.execute(""" + UPDATE accesses + SET access_code = LOWER(REPLACE(name, ' ', '_')), + category = 'General' + WHERE access_code IS NULL + """) + + # Now make columns NOT NULL + op.alter_column('accesses', 'access_code', existing_type=sa.String(255), nullable=False) + op.alter_column('accesses', 'category', existing_type=sa.String(100), nullable=False) + + # Create unique constraint on access_code + op.create_unique_constraint('uq_accesses_access_code', 'accesses', ['access_code']) + + # Create foreign key for parent_id + op.create_foreign_key('fk_accesses_parent_id', 'accesses', 'accesses', ['parent_id'], ['id'], ondelete='CASCADE') + + +def downgrade() -> None: + # Drop foreign key + op.drop_constraint('fk_accesses_parent_id', 'accesses', type_='foreignkey') + + # Drop unique constraint + op.drop_constraint('uq_accesses_access_code', 'accesses', type_='unique') + + # Drop indexes + op.drop_index('ix_accesses_parent_id', 'accesses') + op.drop_index('ix_category', 'accesses') + op.drop_index('ix_access_code', 'accesses') + + # Drop columns + op.drop_column('accesses', 'parent_id') + op.drop_column('accesses', 'category') + op.drop_column('accesses', 'access_code') + + # Re-add unique constraint on 'name' + op.create_unique_constraint('uq_accesses_name', 'accesses', ['name']) + + diff --git a/alembic/versions/a902935ea69e_remove_redundant_id_column_from_user_.py b/alembic/versions/a902935ea69e_remove_redundant_id_column_from_user_.py new file mode 100644 index 0000000..ee2d947 --- /dev/null +++ b/alembic/versions/a902935ea69e_remove_redundant_id_column_from_user_.py @@ -0,0 +1,34 @@ +"""Remove redundant id column from user_storage_usage + +Revision ID: a902935ea69e +Revises: 0e1a5f2cdea9 +Create Date: 2026-03-02 10:53:47.556155 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'a902935ea69e' +down_revision: Union[str, None] = '0e1a5f2cdea9' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index('ix_user_storage_usage_id', table_name='user_storage_usage') + op.drop_constraint('user_storage_usage_user_id_key', 'user_storage_usage', type_='unique') + op.drop_column('user_storage_usage', 'id') + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('user_storage_usage', sa.Column('id', sa.INTEGER(), autoincrement=False, nullable=False)) + op.create_unique_constraint('user_storage_usage_user_id_key', 'user_storage_usage', ['user_id'], postgresql_nulls_not_distinct=False) + op.create_index('ix_user_storage_usage_id', 'user_storage_usage', ['id'], unique=False) + # ### end Alembic commands ### diff --git a/alembic/versions/af57c3d9f1c2_add_signature_config_tables.py b/alembic/versions/af57c3d9f1c2_add_signature_config_tables.py new file mode 100644 index 0000000..92e64c6 --- /dev/null +++ b/alembic/versions/af57c3d9f1c2_add_signature_config_tables.py @@ -0,0 +1,64 @@ +"""add signature config tables + +Revision ID: af57c3d9f1c2 +Revises: daf45366d608 +Create Date: 2026-07-20 18:53:11.916379 + +""" +from typing import Sequence, Union +import uuid + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + + +# revision identifiers, used by Alembic. +revision: str = 'af57c3d9f1c2' +down_revision: Union[str, None] = 'daf45366d608' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + 'global_signature_configs', + sa.Column('id', postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column('active_provider', sa.String(length=50), nullable=False, server_default='zoho_sign'), + sa.Column('docuseal_credentials', postgresql.JSON(astext_type=sa.Text()), nullable=True), + sa.Column('zoho_credentials', postgresql.JSON(astext_type=sa.Text()), nullable=True), + sa.Column('created_by_id', sa.Integer(), nullable=True), + sa.Column('updated_by_id', sa.Integer(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['created_by_id'], ['users.id'], ondelete='SET NULL'), + sa.ForeignKeyConstraint(['updated_by_id'], ['users.id'], ondelete='SET NULL') + ) + + op.execute( + "INSERT INTO global_signature_configs (id, active_provider, created_at, updated_at) " + "VALUES (gen_random_uuid(), 'zoho_sign', now(), now())" + ) + + op.create_table( + 'tenant_signature_configs', + sa.Column('id', postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column('tenant_id', postgresql.UUID(as_uuid=True), nullable=False), + sa.Column('use_custom_credentials', sa.Boolean(), nullable=False, server_default='false'), + sa.Column('custom_provider', sa.String(length=50), nullable=False, server_default='docuseal'), + sa.Column('custom_credentials_json', postgresql.JSON(astext_type=sa.Text()), nullable=True), + sa.Column('created_by_id', sa.Integer(), nullable=True), + sa.Column('updated_by_id', sa.Integer(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['created_by_id'], ['users.id'], ondelete='SET NULL'), + sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['updated_by_id'], ['users.id'], ondelete='SET NULL') + ) + op.create_index(op.f('ix_tenant_signature_configs_tenant_id'), 'tenant_signature_configs', ['tenant_id'], unique=True) + + +def downgrade() -> None: + op.drop_index(op.f('ix_tenant_signature_configs_tenant_id'), table_name='tenant_signature_configs') + op.drop_table('tenant_signature_configs') + op.drop_table('global_signature_configs') diff --git a/alembic/versions/b029d2f5a0c7_add_signature_id.py b/alembic/versions/b029d2f5a0c7_add_signature_id.py new file mode 100644 index 0000000..3ef076b --- /dev/null +++ b/alembic/versions/b029d2f5a0c7_add_signature_id.py @@ -0,0 +1,36 @@ +"""add_signature_id + +Revision ID: b029d2f5a0c7 +Revises: fb6810c36ffb +Create Date: 2026-04-14 14:59:09.399640 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'b029d2f5a0c7' +down_revision: Union[str, None] = 'fb6810c36ffb' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('signing_requests', schema=None) as batch_op: + batch_op.add_column(sa.Column('signature_id', sa.String(length=128), nullable=True)) + batch_op.create_index(batch_op.f('ix_signing_requests_signature_id'), ['signature_id'], unique=False) + + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('signing_requests', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_signing_requests_signature_id')) + batch_op.drop_column('signature_id') + + # ### end Alembic commands ### diff --git a/alembic/versions/b0a09cd13348_add_persistent_chat_history_tables.py b/alembic/versions/b0a09cd13348_add_persistent_chat_history_tables.py new file mode 100644 index 0000000..50509b2 --- /dev/null +++ b/alembic/versions/b0a09cd13348_add_persistent_chat_history_tables.py @@ -0,0 +1,131 @@ +"""Add persistent chat history tables + +Revision ID: b0a09cd13348 +Revises: w001_versioning_minor +Create Date: 2026-05-06 16:38:12.054824 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = 'b0a09cd13348' +down_revision: Union[str, None] = 'w001_versioning_minor' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + from sqlalchemy.engine.reflection import Inspector + conn = op.get_bind() + inspector = Inspector.from_engine(conn) + + if not inspector.has_table('activity_logs'): + op.create_table('activity_logs', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('tenant_id', sa.UUID(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=True), + sa.Column('user_name', sa.String(length=255), nullable=True), + sa.Column('user_email', sa.String(length=255), nullable=True), + sa.Column('module', sa.String(length=50), nullable=False), + sa.Column('action', sa.String(length=100), nullable=False), + sa.Column('target_id', sa.String(length=255), nullable=False), + sa.Column('target_type', sa.String(length=50), nullable=False), + sa.Column('target_name', sa.String(length=512), nullable=True), + sa.Column('metadata', postgresql.JSONB(astext_type=sa.Text()), nullable=False), + sa.Column('ip_address', sa.String(length=64), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('request_id', sa.String(length=255), nullable=True), + sa.Column('status', sa.String(length=20), nullable=False), + sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='SET NULL'), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('activity_logs', schema=None) as batch_op: + batch_op.create_index('ix_activity_logs_module_action', ['module', 'action'], unique=False) + batch_op.create_index('ix_activity_logs_target_id', ['target_id'], unique=False) + batch_op.create_index('ix_activity_logs_tenant_created_at', ['tenant_id', 'created_at'], unique=False) + batch_op.create_index('ix_activity_logs_user_created_at', ['user_id', 'created_at'], unique=False) + + if not inspector.has_table('chat_sessions'): + op.create_table('chat_sessions', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('session_id', sa.UUID(), nullable=False), + sa.Column('tenant_id', sa.UUID(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('chatbot_document_id', sa.Integer(), nullable=True), + sa.Column('title', sa.String(length=255), nullable=True), + sa.Column('created_at', sa.TIMESTAMP(), nullable=False), + sa.Column('updated_at', sa.TIMESTAMP(), nullable=False), + sa.ForeignKeyConstraint(['chatbot_document_id'], ['chatbot_documents.id'], ), + sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('chat_sessions', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_chat_sessions_chatbot_document_id'), ['chatbot_document_id'], unique=False) + batch_op.create_index(batch_op.f('ix_chat_sessions_session_id'), ['session_id'], unique=True) + batch_op.create_index(batch_op.f('ix_chat_sessions_tenant_id'), ['tenant_id'], unique=False) + batch_op.create_index(batch_op.f('ix_chat_sessions_user_id'), ['user_id'], unique=False) + + if not inspector.has_table('chat_messages'): + op.create_table('chat_messages', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('message_id', sa.Uuid(), nullable=False), + sa.Column('session_id', sa.Integer(), nullable=False), + sa.Column('role', sa.String(length=20), nullable=False), + sa.Column('content', sa.Text(), nullable=False), + sa.Column('token_count', sa.Integer(), nullable=True), + sa.Column('created_at', sa.TIMESTAMP(), nullable=False), + sa.ForeignKeyConstraint(['session_id'], ['chat_sessions.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('message_id') + ) + with op.batch_alter_table('chat_messages', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_chat_messages_session_id'), ['session_id'], unique=False) + + with op.batch_alter_table('drive_file_versions', schema=None) as batch_op: + # Check if index exists before dropping + indexes = [idx['name'] for idx in inspector.get_indexes('drive_file_versions')] + if 'ix_drive_file_versions_file_major_minor' in indexes: + batch_op.drop_index('ix_drive_file_versions_file_major_minor') + + with op.batch_alter_table('signature_imprints', schema=None) as batch_op: + # Check if index exists before dropping + indexes = [idx['name'] for idx in inspector.get_indexes('signature_imprints')] + if 'ix_signature_imprints_version_id' in indexes: + batch_op.drop_index('ix_signature_imprints_version_id') + + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('signature_imprints', schema=None) as batch_op: + batch_op.create_index('ix_signature_imprints_version_id', ['drive_file_version_id'], unique=False) + + with op.batch_alter_table('drive_file_versions', schema=None) as batch_op: + batch_op.create_index('ix_drive_file_versions_file_major_minor', ['file_id', 'version_number', 'minor_version'], unique=False) + + with op.batch_alter_table('chat_messages', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_chat_messages_session_id')) + + op.drop_table('chat_messages') + with op.batch_alter_table('chat_sessions', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_chat_sessions_user_id')) + batch_op.drop_index(batch_op.f('ix_chat_sessions_tenant_id')) + batch_op.drop_index(batch_op.f('ix_chat_sessions_session_id')) + batch_op.drop_index(batch_op.f('ix_chat_sessions_chatbot_document_id')) + + op.drop_table('chat_sessions') + with op.batch_alter_table('activity_logs', schema=None) as batch_op: + batch_op.drop_index('ix_activity_logs_user_created_at') + batch_op.drop_index('ix_activity_logs_tenant_created_at') + batch_op.drop_index('ix_activity_logs_target_id') + batch_op.drop_index('ix_activity_logs_module_action') + + op.drop_table('activity_logs') + # ### end Alembic commands ### diff --git a/alembic/versions/b1_0_explicit_superadmin.py b/alembic/versions/b1_0_explicit_superadmin.py new file mode 100644 index 0000000..f17a2d6 --- /dev/null +++ b/alembic/versions/b1_0_explicit_superadmin.py @@ -0,0 +1,62 @@ +"""B1.0 — make superadmin an explicit flag instead of a null tenant + +Adds users.is_superadmin and backfills it from the signal it replaces, so the +operators who are superadmins today stay superadmins after deploy. + +Until now privilege was inferred from `tenant_id IS NULL`. That made the +*absence* of tenant context a grant of authority: a bug that dropped the tenant +escalated instead of denying. The flag separates "has no tenant" from "may do +anything". + +This migration only adds and backfills. It deliberately does not drop or alter +`tenant_id`, and the application honours both signals for one release, so this +can be reverted without locking anyone out. + +Revision ID: b1_0_explicit_superadmin +Revises: dfae38e4ede3 +""" + +import sqlalchemy as sa +from alembic import op + +revision = "b1_0_explicit_superadmin" +down_revision = "73dff81eed72" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "users", + sa.Column( + "is_superadmin", + sa.Boolean(), + nullable=False, + server_default=sa.false(), + ), + ) + + # Backfill in the same migration, not a follow-up script. If this ran later + # there would be a window in which every superadmin was demoted. + op.execute( + """ + UPDATE users + SET is_superadmin = true + WHERE tenant_id IS NULL + AND is_deleted = false + """ + ) + + op.create_index( + "ix_users_is_superadmin", + "users", + ["is_superadmin"], + postgresql_where=sa.text("is_superadmin"), + ) + + +def downgrade() -> None: + # Safe to reverse: `tenant_id IS NULL` still identifies the same people, + # and the application still honours it. + op.drop_index("ix_users_is_superadmin", table_name="users") + op.drop_column("users", "is_superadmin") diff --git a/alembic/versions/b1_3_row_level_security.py b/alembic/versions/b1_3_row_level_security.py new file mode 100644 index 0000000..7c03b78 --- /dev/null +++ b/alembic/versions/b1_3_row_level_security.py @@ -0,0 +1,87 @@ +"""B1.3 — row-level security on every tenant-owned table + +Enables **and forces** RLS on the 15 tables carrying a `tenant_id`, with a policy +that compares the row's tenant against a session variable the application sets +per request. + +Why this exists when B1.1 already filters queries: the ORM listener attaches to +entity loading, so it cannot filter aggregates — `query(X).count()` compiles to +`SELECT count(*) FROM (SELECT ...)` and comes back unfiltered. RLS applies in the +database regardless of how the query was composed, so it closes a hole the +listener structurally cannot. + +FORCE matters. Without it the policy does not apply to the table's owner, and +migrations run as the owner — so the tables would look protected while the +application, if it ever connected as that role, saw everything. + +This migration is inert until the application sets `docqube.tenant_id`, and +takes effect fully only once the app connects as a NOSUPERUSER / NOBYPASSRLS +role (B1.4). A superuser connection bypasses RLS unconditionally. + +Revision ID: b1_3_row_level_security +Revises: b1_0_explicit_superadmin +""" + +from alembic import op + +revision = "b1_3_row_level_security" +down_revision = "b1_0_explicit_superadmin" +branch_labels = None +depends_on = None + +POLICY = "docqube_tenant_isolation" + +# Derived from the schema when this was written; asserted against +# information_schema by tests/probes/test_rls.py so drift is caught rather than +# assumed. +TENANT_TABLES = [ + "activity_logs", + "chat_sessions", + "chatbot_documents", + "drive_activities", + "drive_files", + "drive_folders", + "notifications", + "projects", + "roles", + "signing_requests", + "tenant_signature_configs", + "tenant_smtp_configs", + "tenant_storage_configs", + "user_files", + "users", +] + +# The predicate, in words: +# * `docqube.bypass = 'on'` — an explicit system operation (migrations, +# background sweeps). Only the application can set this; it is not reachable +# from user input. +# * the row has no tenant — shared system records such as global roles. +# * the row's tenant matches the session variable. +# +# With no variable set, `current_setting(..., true)` returns NULL, `NULLIF` +# keeps it NULL, and `tenant_id = NULL` is NULL — so only shared rows are +# visible. Losing the tenant denies rather than reveals. +USING = """ + coalesce(current_setting('docqube.bypass', true), '') = 'on' + OR tenant_id IS NULL + OR tenant_id = nullif(current_setting('docqube.tenant_id', true), '')::uuid +""" + + +def upgrade() -> None: + for table in TENANT_TABLES: + op.execute(f"ALTER TABLE {table} ENABLE ROW LEVEL SECURITY") + op.execute(f"ALTER TABLE {table} FORCE ROW LEVEL SECURITY") + op.execute(f"DROP POLICY IF EXISTS {POLICY} ON {table}") + op.execute( + f"CREATE POLICY {POLICY} ON {table} " + f"USING ({USING}) WITH CHECK ({USING})" + ) + + +def downgrade() -> None: + for table in TENANT_TABLES: + op.execute(f"DROP POLICY IF EXISTS {POLICY} ON {table}") + op.execute(f"ALTER TABLE {table} NO FORCE ROW LEVEL SECURITY") + op.execute(f"ALTER TABLE {table} DISABLE ROW LEVEL SECURITY") diff --git a/alembic/versions/b3a7c1e9f042_add_collaboration_messages_table.py b/alembic/versions/b3a7c1e9f042_add_collaboration_messages_table.py new file mode 100644 index 0000000..6aa2e01 --- /dev/null +++ b/alembic/versions/b3a7c1e9f042_add_collaboration_messages_table.py @@ -0,0 +1,55 @@ +"""add collaboration_messages table + +Revision ID: b3a7c1e9f042 +Revises: f055f9b157cb +Create Date: 2026-03-02 15:17:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'b3a7c1e9f042' +down_revision: Union[str, None] = 'f055f9b157cb' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "collaboration_messages", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column( + "file_id", + sa.Integer(), + sa.ForeignKey("drive_files.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column( + "sender_id", + sa.Integer(), + sa.ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("message", sa.Text(), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + ), + ) + + # Performance indexes + op.create_index("ix_collab_msg_file_id", "collaboration_messages", ["file_id"]) + op.create_index("ix_collab_msg_sender_id", "collaboration_messages", ["sender_id"]) + op.create_index("ix_collab_msg_created_at", "collaboration_messages", ["created_at"]) + + +def downgrade() -> None: + op.drop_index("ix_collab_msg_created_at", table_name="collaboration_messages") + op.drop_index("ix_collab_msg_sender_id", table_name="collaboration_messages") + op.drop_index("ix_collab_msg_file_id", table_name="collaboration_messages") + op.drop_table("collaboration_messages") diff --git a/alembic/versions/b6_4_index_review.py b/alembic/versions/b6_4_index_review.py new file mode 100644 index 0000000..205496d --- /dev/null +++ b/alembic/versions/b6_4_index_review.py @@ -0,0 +1,65 @@ +"""B6.4 — indexes on the foreign keys that queries actually filter by + +The database has 34 foreign keys with no supporting index. PostgreSQL does not +create one automatically, and the cost is invisible on a seeded database: a +sequential scan over five rows is instant, over five hundred thousand it is not. + +This adds indexes only where a measured query path filters or joins on the +column — the ones surfaced by the B6.3 query-budget work. Indexing all 34 would +add write cost for paths nothing traverses; the remainder are listed in +`tests/probes/test_index_coverage.py` so they stay visible rather than +forgotten. + +**Deployment note.** `CREATE INDEX` takes a lock that blocks writes for the +duration. On DocQube's current row counts that is brief, but on a large +`drive_files` it will not be — run these with `CONCURRENTLY` outside a +transaction if the table has grown. Alembic cannot do that inside its own +transaction, which is why this migration is written to be easy to translate. + +Revision ID: b6_4_index_review +Revises: b1_3_row_level_security +""" + +from alembic import op + +revision = "b6_4_index_review" +down_revision = "b1_3_row_level_security" +branch_labels = None +depends_on = None + +# (index name, table, columns) — each justified by a query path measured in +# tests/characterization/test_query_budget.py. +INDEXES = [ + # Every folder listing filters files by folder. + ("ix_drive_files_folder_id", "drive_files", "folder_id"), + ("ix_drive_files_owner_id", "drive_files", "owner_id"), + # Hierarchy walks in get_effective_role and breadcrumbs. + ("ix_drive_folders_parent_id", "drive_folders", "parent_id"), + ("ix_drive_folders_owner_id", "drive_folders", "owner_id"), + # The batched star lookup added in B6.3 filters on all three. + ( + "ix_drive_stars_user_resource", + "drive_stars", + "user_id, resource_type, resource_id", + ), + # Version history, loaded per file. + ("ix_drive_file_versions_file_id", "drive_file_versions", "file_id"), + ("ix_project_versions_project_id", "project_versions", "project_id"), + # Permission resolution joins accesses on every authenticated request. + ("ix_role_accesses_access_id", "role_accesses", "access_id"), + # Signing looks requests up by the file they belong to. + ("ix_signing_requests_drive_file_id", "signing_requests", "drive_file_id"), + # Comments and activity are read per resource. + ("ix_drive_comments_author_id", "drive_comments", "author_id"), + ("ix_drive_activities_actor_id", "drive_activities", "actor_id"), +] + + +def upgrade() -> None: + for name, table, columns in INDEXES: + op.execute(f"CREATE INDEX IF NOT EXISTS {name} ON {table} ({columns})") + + +def downgrade() -> None: + for name, _table, _columns in INDEXES: + op.execute(f"DROP INDEX IF EXISTS {name}") diff --git a/alembic/versions/bb224e0b642b_remove_tenant_storage_config_and_use_.py b/alembic/versions/bb224e0b642b_remove_tenant_storage_config_and_use_.py new file mode 100644 index 0000000..d764cc5 --- /dev/null +++ b/alembic/versions/bb224e0b642b_remove_tenant_storage_config_and_use_.py @@ -0,0 +1,48 @@ +"""remove_tenant_storage_config_and_use_system_config + +Revision ID: bb224e0b642b +Revises: i001_dual_bucket_storage +Create Date: 2026-03-17 09:09:02.599878 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'bb224e0b642b' +down_revision: Union[str, None] = 'i001_dual_bucket_storage' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Drop the tenant-specific storage config table + op.drop_table('tenant_storage_configs') + + +def downgrade() -> None: + # Recreate the tenant-specific storage config table + op.create_table( + 'tenant_storage_configs', + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('tenant_id', sa.UUID(), nullable=False), + sa.Column('storage_provider', sa.String(length=50), nullable=False), + sa.Column('b2_key_id', sa.String(length=255), nullable=True), + sa.Column('b2_application_key', sa.String(length=512), nullable=True), + sa.Column('b2_quarantine_bucket', sa.String(length=255), nullable=True), + sa.Column('b2_clean_bucket', sa.String(length=255), nullable=True), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.Column('created_by_id', sa.Integer(), nullable=True), + sa.Column('updated_by_id', sa.Integer(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['created_by_id'], ['users.id'], ondelete='SET NULL'), + sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['updated_by_id'], ['users.id'], ondelete='SET NULL'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('tenant_id') + ) + op.create_index(op.f('ix_tenant_storage_configs_tenant_id'), 'tenant_storage_configs', ['tenant_id'], unique=True) diff --git a/alembic/versions/be67927ab00d_add_encrypted_ai_api_key_to_user_model.py b/alembic/versions/be67927ab00d_add_encrypted_ai_api_key_to_user_model.py new file mode 100644 index 0000000..a9e3536 --- /dev/null +++ b/alembic/versions/be67927ab00d_add_encrypted_ai_api_key_to_user_model.py @@ -0,0 +1,38 @@ +"""Add encrypted_ai_api_key to user_model + +Revision ID: be67927ab00d +Revises: b3a7c1e9f042 +Create Date: 2026-03-03 12:43:12.848347 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = 'be67927ab00d' +down_revision: Union[str, None] = 'b3a7c1e9f042' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('collaboration_messages', 'created_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + nullable=False, + existing_server_default=sa.text('now()')) + op.add_column('users', sa.Column('encrypted_ai_api_key', sa.String(length=512), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('users', 'encrypted_ai_api_key') + op.alter_column('collaboration_messages', 'created_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + nullable=True, + existing_server_default=sa.text('now()')) + # ### end Alembic commands ### diff --git a/alembic/versions/c1_0_org_units_and_scoped_roles.py b/alembic/versions/c1_0_org_units_and_scoped_roles.py new file mode 100644 index 0000000..d813cb4 --- /dev/null +++ b/alembic/versions/c1_0_org_units_and_scoped_roles.py @@ -0,0 +1,247 @@ +"""C1/C3 — organisation units, membership, leadership, and scoped role grants + +Four tables, and a backfill that is deliberately a no-op in behaviour. + +`user_roles` exists because `users.role_id` is single-valued: one role, held +across the whole tenant. Scoped access has nowhere to live until a user can hold +the same role in two units. The backfill turns each existing `users.role_id` +into one `user_roles` row with `org_unit_id = NULL`, which means tenant-wide — +exactly the authority that user already had. Nobody gains or loses anything on +this migration. + +`users.role_id` is **not** dropped. Both mechanisms are honoured for one +release, the same shape as the `is_superadmin` transition and for the same +reason: reverting this migration must not be able to remove everyone's +permissions. Removal is a later, deliberate step with its own preflight. + +All four tables carry `tenant_id`, so `app/core/tenant_filter.py` scopes them +automatically, and they are added to the RLS policy here because +`tests/probes/test_isolation_ratchet.py` fails if a tenant-owned table is left +unprotected. + +Revision ID: c1_0_org_units_and_scoped_roles +Revises: b6_4_index_review +""" + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects.postgresql import UUID + +revision = "c1_0_org_units_and_scoped_roles" +down_revision = "b6_4_index_review" +branch_labels = None +depends_on = None + +# Kept in step with b1_3_row_level_security.py. +# Named TENANT_TABLES so `test_isolation_ratchet.py` finds it: the ratchet +# scans every migration for this constant, which is what lets a later +# migration add tenant-owned tables without the check going stale. +TENANT_TABLES = ["org_units", "user_org_units", "org_unit_leads"] +POLICY = "tenant_isolation" +USING = """ + coalesce(current_setting('docqube.bypass', true), '') = 'on' + OR tenant_id IS NULL + OR tenant_id = nullif(current_setting('docqube.tenant_id', true), '')::uuid +""" + + +def upgrade() -> None: + op.create_table( + "org_units", + sa.Column("id", UUID(as_uuid=True), primary_key=True), + sa.Column( + "tenant_id", + UUID(as_uuid=True), + sa.ForeignKey("tenants.id", ondelete="CASCADE"), + nullable=False, + ), + # SET NULL, not CASCADE: deleting a department must not silently delete + # every team beneath it. What happens to orphans is an application + # decision, and it should be visible as one. + sa.Column( + "parent_id", + UUID(as_uuid=True), + sa.ForeignKey("org_units.id", ondelete="SET NULL"), + nullable=True, + ), + sa.Column("name", sa.String(255), nullable=False), + sa.Column("code", sa.String(50), nullable=True), + sa.Column("unit_type", sa.String(50), nullable=True), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("sort_order", sa.Integer(), nullable=False, server_default="0"), + sa.Column("depth", sa.Integer(), nullable=False, server_default="0"), + sa.Column("is_deleted", sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), + sa.Column( + "created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False + ), + sa.Column( + "updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False + ), + ) + op.create_index("ix_org_units_tenant_id", "org_units", ["tenant_id"]) + op.create_index("ix_org_units_parent_id", "org_units", ["parent_id"]) + op.create_index("ix_org_units_tenant_parent", "org_units", ["tenant_id", "parent_id"]) + # Codes are unique per tenant among *live* rows only — a soft-deleted unit + # must not reserve its code forever. + op.create_index( + "uq_org_units_tenant_code_live", + "org_units", + ["tenant_id", "code"], + unique=True, + postgresql_where=sa.text("NOT is_deleted AND code IS NOT NULL"), + ) + + op.create_table( + "user_org_units", + sa.Column("id", UUID(as_uuid=True), primary_key=True), + sa.Column( + "tenant_id", + UUID(as_uuid=True), + sa.ForeignKey("tenants.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column( + "user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False + ), + sa.Column( + "org_unit_id", + UUID(as_uuid=True), + sa.ForeignKey("org_units.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("is_primary", sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column( + "created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False + ), + sa.UniqueConstraint("user_id", "org_unit_id", name="uq_user_org_units_user_unit"), + ) + op.create_index("ix_user_org_units_tenant_id", "user_org_units", ["tenant_id"]) + op.create_index("ix_user_org_units_user_id", "user_org_units", ["user_id"]) + op.create_index("ix_user_org_units_org_unit_id", "user_org_units", ["org_unit_id"]) + # "Exactly one primary" as an index, not as application logic: written in + # Python it is a race between two concurrent writes, which is the same + # defect class as the user_storage_usage get-or-create found under load. + op.create_index( + "uq_user_org_units_one_primary", + "user_org_units", + ["user_id"], + unique=True, + postgresql_where=sa.text("is_primary"), + ) + + op.create_table( + "org_unit_leads", + sa.Column("id", UUID(as_uuid=True), primary_key=True), + sa.Column( + "tenant_id", + UUID(as_uuid=True), + sa.ForeignKey("tenants.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column( + "org_unit_id", + UUID(as_uuid=True), + sa.ForeignKey("org_units.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column( + "user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False + ), + sa.Column("start_date", sa.Date(), nullable=False), + sa.Column("end_date", sa.Date(), nullable=True), + sa.Column("note", sa.Text(), nullable=True), + sa.Column( + "created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False + ), + ) + op.create_index("ix_org_unit_leads_tenant_id", "org_unit_leads", ["tenant_id"]) + op.create_index("ix_org_unit_leads_org_unit_id", "org_unit_leads", ["org_unit_id"]) + op.create_index("ix_org_unit_leads_user_id", "org_unit_leads", ["user_id"]) + # At most one *current* lead per unit; history is unconstrained, which is + # what makes it usable as history. + op.create_index( + "uq_org_unit_leads_current", + "org_unit_leads", + ["org_unit_id"], + unique=True, + postgresql_where=sa.text("end_date IS NULL"), + ) + + op.create_table( + "user_roles", + sa.Column("id", UUID(as_uuid=True), primary_key=True), + sa.Column( + "user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False + ), + sa.Column( + "role_id", + UUID(as_uuid=True), + sa.ForeignKey("roles.id", ondelete="CASCADE"), + nullable=False, + ), + # NULL means tenant-wide, which is what every grant is today. + sa.Column( + "org_unit_id", + UUID(as_uuid=True), + sa.ForeignKey("org_units.id", ondelete="CASCADE"), + nullable=True, + ), + sa.Column( + "assigned_by_id", + sa.Integer(), + sa.ForeignKey("users.id", ondelete="SET NULL"), + nullable=True, + ), + sa.Column( + "created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False + ), + sa.UniqueConstraint("user_id", "role_id", "org_unit_id", name="uq_user_roles_scoped"), + ) + op.create_index("ix_user_roles_user_id", "user_roles", ["user_id"]) + op.create_index("ix_user_roles_role_id", "user_roles", ["role_id"]) + op.create_index("ix_user_roles_org_unit_id", "user_roles", ["org_unit_id"]) + op.create_index("ix_user_roles_lookup", "user_roles", ["user_id", "role_id"]) + # Every foreign key needs a supporting index or a delete on the parent + # sequentially scans this table. `test_index_coverage.py` enforces it. + op.create_index("ix_user_roles_assigned_by_id", "user_roles", ["assigned_by_id"]) + # Postgres treats NULLs as distinct in a UNIQUE constraint, so the triple + # above does not stop the same role being held tenant-wide twice. + op.create_index( + "uq_user_roles_tenant_wide", + "user_roles", + ["user_id", "role_id"], + unique=True, + postgresql_where=sa.text("org_unit_id IS NULL"), + ) + + # Backfill. Behaviour-neutral by construction: a null org unit is + # tenant-wide, which is the authority every user already has. + op.execute( + """ + INSERT INTO user_roles (id, user_id, role_id, org_unit_id, created_at) + SELECT gen_random_uuid(), u.id, u.role_id, NULL, now() + FROM users u + WHERE u.role_id IS NOT NULL + AND u.is_deleted = false + ON CONFLICT DO NOTHING + """ + ) + + for table in TENANT_TABLES: + op.execute(f"ALTER TABLE {table} ENABLE ROW LEVEL SECURITY") + op.execute(f"ALTER TABLE {table} FORCE ROW LEVEL SECURITY") + op.execute(f"DROP POLICY IF EXISTS {POLICY} ON {table}") + op.execute(f"CREATE POLICY {POLICY} ON {table} USING ({USING}) WITH CHECK ({USING})") + + +def downgrade() -> None: + # Safe to reverse: `users.role_id` was never dropped, so removing these + # tables returns the application to the grants it still reads today. + for table in TENANT_TABLES: + op.execute(f"DROP POLICY IF EXISTS {POLICY} ON {table}") + + op.drop_table("user_roles") + op.drop_table("org_unit_leads") + op.drop_table("user_org_units") + op.drop_table("org_units") diff --git a/alembic/versions/c1_1_user_roles_tenant_id.py b/alembic/versions/c1_1_user_roles_tenant_id.py new file mode 100644 index 0000000..f1cc814 --- /dev/null +++ b/alembic/versions/c1_1_user_roles_tenant_id.py @@ -0,0 +1,80 @@ +"""C3.1 — give user_roles a tenant_id, so isolation is structural + +`user_roles` was created without one. That is not a cosmetic omission: both +isolation layers *derive* their coverage from the presence of that column — +`app/core/tenant_filter.py` scopes any model that has it, and the RLS policy +compares it against the session variable. A table without it is protected by +neither, no matter how careful the queries are. + +It is the same shape of gap as `tenants`, which is where "any holder of +`superadmin.tenant.delete` can delete any tenant" lived: a table outside the +mechanism, guarded only by whatever each call site remembered to write. Three +places currently remember. Three is a number that only goes up, and the failure +mode when one forgets is a grant resolving across tenants. + +After this the explicit predicates stay — they are cheap and they document the +intent — but they stop being the only thing standing there. + +**Backfill from the user, not the role.** `roles.tenant_id` is nullable: a null +means a shared/global role template, and a grant of a shared role still belongs +to the tenant of the person holding it. Rows for tenant-less users (superadmins) +stay null, which the RLS policy already treats as globally visible — the same +way their `users` row is treated. + +Revision ID: c1_1_user_roles_tenant_id +Revises: c1_0_org_units_and_scoped_roles +""" + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects.postgresql import UUID + +revision = "c1_1_user_roles_tenant_id" +down_revision = "c1_0_org_units_and_scoped_roles" +branch_labels = None +depends_on = None + +TENANT_TABLES = ["user_roles"] +POLICY = "tenant_isolation" +USING = """ + coalesce(current_setting('docqube.bypass', true), '') = 'on' + OR tenant_id IS NULL + OR tenant_id = nullif(current_setting('docqube.tenant_id', true), '')::uuid +""" + + +def upgrade() -> None: + op.add_column( + "user_roles", + sa.Column( + "tenant_id", + UUID(as_uuid=True), + sa.ForeignKey("tenants.id", ondelete="CASCADE"), + nullable=True, + ), + ) + op.create_index("ix_user_roles_tenant_id", "user_roles", ["tenant_id"]) + + op.execute( + """ + UPDATE user_roles ur + SET tenant_id = u.tenant_id + FROM users u + WHERE u.id = ur.user_id + """ + ) + + # Left nullable on purpose. A superadmin has no tenant, so their grants have + # none either; forcing NOT NULL would mean inventing one. + op.execute(f"ALTER TABLE user_roles ENABLE ROW LEVEL SECURITY") + op.execute(f"ALTER TABLE user_roles FORCE ROW LEVEL SECURITY") + op.execute(f"DROP POLICY IF EXISTS {POLICY} ON user_roles") + op.execute(f"CREATE POLICY {POLICY} ON user_roles USING ({USING}) WITH CHECK ({USING})") + + +def downgrade() -> None: + op.execute(f"DROP POLICY IF EXISTS {POLICY} ON user_roles") + op.execute("ALTER TABLE user_roles NO FORCE ROW LEVEL SECURITY") + op.execute("ALTER TABLE user_roles DISABLE ROW LEVEL SECURITY") + op.drop_index("ix_user_roles_tenant_id", table_name="user_roles") + op.drop_column("user_roles", "tenant_id") diff --git a/alembic/versions/c3_0_org_unit_path.py b/alembic/versions/c3_0_org_unit_path.py new file mode 100644 index 0000000..8e7b981 --- /dev/null +++ b/alembic/versions/c3_0_org_unit_path.py @@ -0,0 +1,77 @@ +"""C3 — a materialised path, so a subtree is a prefix match + +`ScopeService` resolved authority with a recursive CTE, once per access code per +request. That is correct and it is the thing that gets slower as the tree gets +deeper, in the way that shows up as "the admin page feels slow" rather than as +an error — the query-count budgets cannot catch it, because the *count* does not +change. + +A materialised path turns the walk into a prefix match: + + /a1b2.../c3d4.../e5f6.../ + +The subtree of a unit is every row whose path starts with that unit's path. +Indexed with `text_pattern_ops`, that is an index range scan. + +**Text, not `ltree`.** `ltree` is the tidier type and needs an extension, which +is a deployment prerequisite for a performance improvement — a poor trade. Text +with a prefix index needs nothing and behaves identically for this query. + +**Why the delimiters matter.** Ids are stored between slashes, so +`LIKE '/a/b/%'` cannot match `/a/bc/...`. Without them a prefix match would leak +across sibling branches whose ids share a leading substring — which for UUIDs is +rare, and rare is worse than never. + +The column is nullable and the CTE remains as a fallback for any row that has +not been backfilled, so this migration cannot break resolution even if the +backfill is incomplete. + +Revision ID: c3_0_org_unit_path +Revises: c5_0_access_groups +""" + +import sqlalchemy as sa +from alembic import op + +revision = "c3_0_org_unit_path" +down_revision = "c5_0_access_groups" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("org_units", sa.Column("path", sa.Text(), nullable=True)) + + # Prefix matching needs `text_pattern_ops`; the default opclass supports + # equality and ordering but not `LIKE 'prefix%'` as an index scan. + op.execute( + "CREATE INDEX ix_org_units_path_prefix " + "ON org_units (path text_pattern_ops)" + ) + op.create_index("ix_org_units_tenant_path", "org_units", ["tenant_id", "path"]) + + # Backfill, walking down from the roots. Done once here so the application + # never has to compute a path it did not write. + op.execute( + """ + WITH RECURSIVE tree AS ( + SELECT id, '/' || id::text || '/' AS path + FROM org_units + WHERE parent_id IS NULL + UNION ALL + SELECT c.id, t.path || c.id::text || '/' + FROM org_units c + JOIN tree t ON c.parent_id = t.id + ) + UPDATE org_units u + SET path = tree.path + FROM tree + WHERE u.id = tree.id + """ + ) + + +def downgrade() -> None: + op.drop_index("ix_org_units_tenant_path", table_name="org_units") + op.execute("DROP INDEX IF EXISTS ix_org_units_path_prefix") + op.drop_column("org_units", "path") diff --git a/alembic/versions/c3abf44266de_enforce_b2_storage_provider.py b/alembic/versions/c3abf44266de_enforce_b2_storage_provider.py new file mode 100644 index 0000000..623435f --- /dev/null +++ b/alembic/versions/c3abf44266de_enforce_b2_storage_provider.py @@ -0,0 +1,40 @@ +"""enforce_b2_storage_provider + +Revision ID: c3abf44266de +Revises: bb224e0b642b +Create Date: 2026-03-17 10:54:51.326256 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'c3abf44266de' +down_revision: Union[str, None] = 'bb224e0b642b' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # 1. Update existing records from 'minio' to 'b2' + op.execute("UPDATE user_files SET storage_provider = 'b2' WHERE storage_provider = 'minio'") + op.execute("UPDATE drive_file_versions SET storage_provider = 'b2' WHERE storage_provider = 'minio'") + + # 2. Change server default to 'b2' + with op.batch_alter_table('user_files', schema=None) as batch_op: + batch_op.alter_column('storage_provider', server_default='b2') + + with op.batch_alter_table('drive_file_versions', schema=None) as batch_op: + batch_op.alter_column('storage_provider', server_default='b2') + + +def downgrade() -> None: + # 1. Change server default back to 'minio' + with op.batch_alter_table('user_files', schema=None) as batch_op: + batch_op.alter_column('storage_provider', server_default='minio') + + with op.batch_alter_table('drive_file_versions', schema=None) as batch_op: + batch_op.alter_column('storage_provider', server_default='minio') diff --git a/alembic/versions/c4_0_grant_expiry.py b/alembic/versions/c4_0_grant_expiry.py new file mode 100644 index 0000000..3a7331e --- /dev/null +++ b/alembic/versions/c4_0_grant_expiry.py @@ -0,0 +1,46 @@ +"""C4A — grants can expire + +`user_roles.expires_at`, nullable. Null means permanent, which is every existing +row, so this migration changes nobody's authority. + +Temporary elevation, contractor access and just-in-time grants all need this, and +all three are ordinary enterprise asks. Without it the only way to end a grant is +to remember to revoke it, and "remember to" is not an access control. + +Expired rows are **not** deleted. They stay, and they become history — which is +what C4B reads to answer "who had access in March". A swept-away row answers +nothing. + +Revision ID: c4_0_grant_expiry +Revises: c1_1_user_roles_tenant_id +""" + +import sqlalchemy as sa +from alembic import op + +revision = "c4_0_grant_expiry" +down_revision = "c1_1_user_roles_tenant_id" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "user_roles", + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True), + ) + + # Partial: only live grants are ever queried by expiry, and indexing the + # permanent ones (which is all of them today) would be indexing a column + # that is null for every row. + op.create_index( + "ix_user_roles_expires_at", + "user_roles", + ["expires_at"], + postgresql_where=sa.text("expires_at IS NOT NULL"), + ) + + +def downgrade() -> None: + op.drop_index("ix_user_roles_expires_at", table_name="user_roles") + op.drop_column("user_roles", "expires_at") diff --git a/alembic/versions/c4_1_access_log_retention.py b/alembic/versions/c4_1_access_log_retention.py new file mode 100644 index 0000000..2368c2b --- /dev/null +++ b/alembic/versions/c4_1_access_log_retention.py @@ -0,0 +1,39 @@ +"""C4C — index the access timeline, so retention and history are cheap + +Two reads matter and neither had an index: + +- the history endpoint, filtering one tenant's `access` events by date; +- the retention sweeper, finding events older than the policy. + +Both are `(tenant_id, module, created_at)`. Partial on `module = 'access'`, +because ordinary activity is far more numerous and does not share these +queries — indexing it here would triple the index for no reader. + +Retention itself is a **setting**, not a constant: how long access records are +kept is the sort of thing a customer contract specifies, and it must be +answerable without a deploy. See `ACCESS_LOG_RETENTION_DAYS`. + +Revision ID: c4_1_access_log_retention +Revises: c4_0_grant_expiry +""" + +import sqlalchemy as sa +from alembic import op + +revision = "c4_1_access_log_retention" +down_revision = "c4_0_grant_expiry" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_index( + "ix_activity_logs_access_timeline", + "activity_logs", + ["tenant_id", "created_at"], + postgresql_where=sa.text("module = 'access'"), + ) + + +def downgrade() -> None: + op.drop_index("ix_activity_logs_access_timeline", table_name="activity_logs") diff --git a/alembic/versions/c5_0_access_groups.py b/alembic/versions/c5_0_access_groups.py new file mode 100644 index 0000000..c1b5148 --- /dev/null +++ b/alembic/versions/c5_0_access_groups.py @@ -0,0 +1,161 @@ +"""C5.1 — access groups, and grants that can name one + +Two new tables, and one genuinely risky change: `user_roles.user_id` becomes +nullable, so a grant can name a **group** instead. + +That weakens an invariant every existing query relied on. Four readers exist and +all four were reviewed before this migration was written: + + * `app/core/scope.py` — resolution; gains a group branch + * `app/modules/auth/services/permission_service.py` — the coarse gate; likewise + * `app/modules/org/routes/org_routes.py` — lists a user's own grants; unchanged + behaviour, it simply will not list grants a user holds *through* a group + * `app/modules/org/services/access_maintenance.py` — the expiry sweeper; its + audit entry uses the principal, which may now be a group + +The `CHECK` is what keeps the pair honest. "Exactly one of two nullable columns" +is the kind of invariant that survives in the schema and rots in code — one new +call site that sets both, and the resolver counts the grant twice. + +Existing rows all have `user_id`, so the constraint holds the moment it is +added, and nobody's authority changes. + +Revision ID: c5_0_access_groups +Revises: c4_1_access_log_retention +""" + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects.postgresql import UUID + +revision = "c5_0_access_groups" +down_revision = "c4_1_access_log_retention" +branch_labels = None +depends_on = None + +TENANT_TABLES = ["access_groups", "user_access_groups"] +POLICY = "tenant_isolation" +USING = """ + coalesce(current_setting('docqube.bypass', true), '') = 'on' + OR tenant_id IS NULL + OR tenant_id = nullif(current_setting('docqube.tenant_id', true), '')::uuid +""" + + +def upgrade() -> None: + op.create_table( + "access_groups", + sa.Column("id", UUID(as_uuid=True), primary_key=True), + sa.Column( + "tenant_id", + UUID(as_uuid=True), + sa.ForeignKey("tenants.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("name", sa.String(255), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column( + "created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False + ), + sa.Column( + "updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False + ), + sa.UniqueConstraint("tenant_id", "name", name="uq_access_groups_tenant_name"), + ) + op.create_index("ix_access_groups_tenant_id", "access_groups", ["tenant_id"]) + + op.create_table( + "user_access_groups", + sa.Column("id", UUID(as_uuid=True), primary_key=True), + sa.Column( + "tenant_id", + UUID(as_uuid=True), + sa.ForeignKey("tenants.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column( + "user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False + ), + sa.Column( + "group_id", + UUID(as_uuid=True), + sa.ForeignKey("access_groups.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column( + "created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False + ), + sa.UniqueConstraint("user_id", "group_id", name="uq_user_access_groups"), + ) + op.create_index("ix_user_access_groups_tenant_id", "user_access_groups", ["tenant_id"]) + op.create_index("ix_user_access_groups_user_id", "user_access_groups", ["user_id"]) + op.create_index("ix_user_access_groups_group_id", "user_access_groups", ["group_id"]) + op.create_index( + "ix_user_access_groups_lookup", "user_access_groups", ["user_id", "group_id"] + ) + + # The risky half. + op.add_column( + "user_roles", + sa.Column( + "group_id", + UUID(as_uuid=True), + # CASCADE: deleting a group takes its grants with it. A grant whose + # principal no longer exists is a row nobody can see, revoke or + # audit — and it must not keep applying. + sa.ForeignKey("access_groups.id", ondelete="CASCADE"), + nullable=True, + ), + ) + op.alter_column("user_roles", "user_id", existing_type=sa.Integer(), nullable=True) + op.create_index("ix_user_roles_group_id", "user_roles", ["group_id"]) + + op.create_check_constraint( + "ck_user_roles_exactly_one_principal", + "user_roles", + "(user_id IS NOT NULL) <> (group_id IS NOT NULL)", + ) + + # The tenant-wide uniqueness guard was written for user grants. Group grants + # need the same protection, and the original index cannot serve both + # because `user_id` is now null on half the rows. + op.create_index( + "uq_user_roles_group_tenant_wide", + "user_roles", + ["group_id", "role_id"], + unique=True, + postgresql_where=sa.text("org_unit_id IS NULL AND group_id IS NOT NULL"), + ) + op.create_index( + "uq_user_roles_group_scoped", + "user_roles", + ["group_id", "role_id", "org_unit_id"], + unique=True, + postgresql_where=sa.text("group_id IS NOT NULL"), + ) + + for table in TENANT_TABLES: + op.execute(f"ALTER TABLE {table} ENABLE ROW LEVEL SECURITY") + op.execute(f"ALTER TABLE {table} FORCE ROW LEVEL SECURITY") + op.execute(f"DROP POLICY IF EXISTS {POLICY} ON {table}") + op.execute(f"CREATE POLICY {POLICY} ON {table} USING ({USING}) WITH CHECK ({USING})") + + +def downgrade() -> None: + for table in TENANT_TABLES: + op.execute(f"DROP POLICY IF EXISTS {POLICY} ON {table}") + + # Group grants cannot survive the column being dropped, and they cannot be + # converted into user grants either — the whole point is that they name no + # single user. Removing them is the only honest reverse. + op.execute("DELETE FROM user_roles WHERE group_id IS NOT NULL") + + op.drop_index("uq_user_roles_group_scoped", table_name="user_roles") + op.drop_index("uq_user_roles_group_tenant_wide", table_name="user_roles") + op.drop_constraint("ck_user_roles_exactly_one_principal", "user_roles", type_="check") + op.drop_index("ix_user_roles_group_id", table_name="user_roles") + op.alter_column("user_roles", "user_id", existing_type=sa.Integer(), nullable=False) + op.drop_column("user_roles", "group_id") + + op.drop_table("user_access_groups") + op.drop_table("access_groups") diff --git a/alembic/versions/c7d4e6f1a9b0_add_tenant_storage_quota.py b/alembic/versions/c7d4e6f1a9b0_add_tenant_storage_quota.py new file mode 100644 index 0000000..bc740a0 --- /dev/null +++ b/alembic/versions/c7d4e6f1a9b0_add_tenant_storage_quota.py @@ -0,0 +1,66 @@ +"""add tenant storage quota bytes + +Revision ID: c7d4e6f1a9b0 +Revises: e81a9d22ca5f +Create Date: 2026-03-07 09:25:00.000000 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "c7d4e6f1a9b0" +down_revision: Union[str, None] = "e81a9d22ca5f" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "tenants", + sa.Column("storage_quota_bytes", sa.BigInteger(), nullable=True), + ) + + # Preserve existing effective quota behavior by summing each tenant's + # current per-user quotas. For tenants with no users, default to 1 GB. + op.execute( + """ + UPDATE tenants t + SET storage_quota_bytes = COALESCE( + ( + SELECT SUM(COALESCE(usu.max_bytes_quota, 1073741824)) + FROM users u + LEFT JOIN user_storage_usage usu ON usu.user_id = u.id + WHERE u.tenant_id = t.id + ), + 1073741824 + ) + """ + ) + + op.alter_column( + "tenants", + "storage_quota_bytes", + existing_type=sa.BigInteger(), + nullable=False, + server_default=sa.text("1073741824"), + ) + + op.create_check_constraint( + "ck_tenants_storage_quota_bytes_positive", + "tenants", + "storage_quota_bytes > 0", + ) + + +def downgrade() -> None: + op.drop_constraint( + "ck_tenants_storage_quota_bytes_positive", + "tenants", + type_="check", + ) + op.drop_column("tenants", "storage_quota_bytes") diff --git a/alembic/versions/ca14c4ab10d9_merge_multiple_heads.py b/alembic/versions/ca14c4ab10d9_merge_multiple_heads.py new file mode 100644 index 0000000..4059914 --- /dev/null +++ b/alembic/versions/ca14c4ab10d9_merge_multiple_heads.py @@ -0,0 +1,26 @@ +"""Merge multiple heads + +Revision ID: ca14c4ab10d9 +Revises: b0a09cd13348, y001_add_user_mail_from +Create Date: 2026-05-08 13:47:42.707120 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'ca14c4ab10d9' +down_revision: Union[str, None] = ('b0a09cd13348', 'y001_add_user_mail_from') +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + pass + + +def downgrade() -> None: + pass diff --git a/alembic/versions/cba70ab35793_create_chat_token_usage_table.py b/alembic/versions/cba70ab35793_create_chat_token_usage_table.py new file mode 100644 index 0000000..43ff6c4 --- /dev/null +++ b/alembic/versions/cba70ab35793_create_chat_token_usage_table.py @@ -0,0 +1,83 @@ +"""Create chat_token_usage table + +Revision ID: cba70ab35793 +Revises: cd27562afa2e +Create Date: 2026-04-16 13:39:46.842540 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'cba70ab35793' +down_revision: Union[str, None] = 'cd27562afa2e' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +# (index_name, columns, unique, use_naming_convention) +# use_naming_convention=True wraps in batch_op.f(); False uses the raw string +# (matches the original auto-generated/hand-written split). +_INDEXES = [ + ('ix_chat_token_usage_chat_id', ['chat_id'], False, True), + ('ix_chat_token_usage_document_id', ['document_id'], False, True), + ('ix_chat_token_usage_id', ['id'], False, True), + ('ix_chat_token_usage_model_name', ['model_name'], False, True), + ('ix_chat_token_usage_model_time', ['model_name', 'timestamp'], False, False), + ('ix_chat_token_usage_phase', ['phase'], False, True), + ('ix_chat_token_usage_timestamp', ['timestamp'], False, True), + ('ix_chat_token_usage_user_id', ['user_id'], False, True), + ('ix_chat_token_usage_user_time', ['user_id', 'timestamp'], False, False), +] + + +def _index_names(bind, table_name: str) -> set[str]: + inspector = sa.inspect(bind) + return {idx["name"] for idx in inspector.get_indexes(table_name)} + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + + if not inspector.has_table('chat_token_usage'): + op.create_table('chat_token_usage', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('chat_id', sa.Integer(), nullable=True), + sa.Column('document_id', sa.String(length=255), nullable=True), + sa.Column('prompt_tokens', sa.BigInteger(), nullable=False), + sa.Column('completion_tokens', sa.BigInteger(), nullable=False), + sa.Column('total_tokens', sa.BigInteger(), nullable=False), + sa.Column('model_name', sa.String(length=100), nullable=False), + sa.Column('cost_usd', sa.DECIMAL(precision=12, scale=8), nullable=False), + sa.Column('timestamp', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('phase', sa.String(length=50), nullable=True), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + + existing = _index_names(bind, 'chat_token_usage') + with op.batch_alter_table('chat_token_usage', schema=None) as batch_op: + for name, cols, unique, use_f in _INDEXES: + if name not in existing: + ref = batch_op.f(name) if use_f else name + batch_op.create_index(ref, cols, unique=unique) + + +def downgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + + if inspector.has_table('chat_token_usage'): + existing = _index_names(bind, 'chat_token_usage') + with op.batch_alter_table('chat_token_usage', schema=None) as batch_op: + for name, _, _, use_f in reversed(_INDEXES): + if name in existing: + ref = batch_op.f(name) if use_f else name + batch_op.drop_index(ref) + + op.drop_table('chat_token_usage') diff --git a/alembic/versions/cd27562afa2e_add_chat_token_usage_tracking.py b/alembic/versions/cd27562afa2e_add_chat_token_usage_tracking.py new file mode 100644 index 0000000..43d28ec --- /dev/null +++ b/alembic/versions/cd27562afa2e_add_chat_token_usage_tracking.py @@ -0,0 +1,116 @@ +"""Add chat token usage tracking + +Revision ID: cd27562afa2e +Revises: 1f4d2a8c9b77 +Create Date: 2026-04-16 13:05:09.572442 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = 'cd27562afa2e' +down_revision: Union[str, None] = '1f4d2a8c9b77' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def _table_exists(bind, table_name: str) -> bool: + return sa.inspect(bind).has_table(table_name) + + +def upgrade() -> None: + bind = op.get_bind() + + if _table_exists(bind, 'chatbot_documents'): + with op.batch_alter_table('chatbot_documents', schema=None) as batch_op: + batch_op.alter_column('chunks_count', + existing_type=sa.INTEGER(), + nullable=False) + batch_op.alter_column('created_at', + existing_type=postgresql.TIMESTAMP(), + nullable=False) + + if _table_exists(bind, 'vector_chunks'): + with op.batch_alter_table('vector_chunks', schema=None) as batch_op: + batch_op.alter_column('document_id', + existing_type=sa.VARCHAR(), + nullable=False) + batch_op.alter_column('chunk_text', + existing_type=sa.VARCHAR(), + type_=sa.Text(), + existing_nullable=False) + batch_op.alter_column('chunk_index', + existing_type=sa.INTEGER(), + nullable=False) + batch_op.alter_column('created_at', + existing_type=postgresql.TIMESTAMP(), + type_=sa.DateTime(timezone=True), + nullable=False) + batch_op.drop_constraint('vector_chunks_user_id_fkey', type_='foreignkey') + batch_op.create_foreign_key(None, 'users', ['user_id'], ['id'], ondelete='CASCADE') + + if _table_exists(bind, 'vector_indices'): + with op.batch_alter_table('vector_indices', schema=None) as batch_op: + batch_op.alter_column('total_vectors', + existing_type=sa.INTEGER(), + nullable=False) + batch_op.alter_column('updated_at', + existing_type=postgresql.TIMESTAMP(), + type_=sa.DateTime(timezone=True), + nullable=False) + batch_op.drop_constraint('vector_indices_user_id_fkey', type_='foreignkey') + batch_op.create_foreign_key(None, 'users', ['user_id'], ['id'], ondelete='CASCADE') + + +def downgrade() -> None: + bind = op.get_bind() + + if _table_exists(bind, 'vector_indices'): + with op.batch_alter_table('vector_indices', schema=None) as batch_op: + batch_op.drop_constraint(None, type_='foreignkey') + batch_op.create_foreign_key('vector_indices_user_id_fkey', 'users', ['user_id'], ['id']) + batch_op.drop_index(batch_op.f('ix_vector_indices_user_id')) + batch_op.create_index('ix_vector_indices_user_id', ['user_id'], unique=True) + batch_op.create_index('ix_vector_indices_id', ['id'], unique=False) + batch_op.alter_column('updated_at', + existing_type=sa.DateTime(timezone=True), + type_=postgresql.TIMESTAMP(), + nullable=True) + batch_op.alter_column('total_vectors', + existing_type=sa.INTEGER(), + nullable=True) + + if _table_exists(bind, 'vector_chunks'): + with op.batch_alter_table('vector_chunks', schema=None) as batch_op: + batch_op.drop_constraint(None, type_='foreignkey') + batch_op.create_foreign_key('vector_chunks_user_id_fkey', 'users', ['user_id'], ['id']) + batch_op.create_index('ix_vector_chunks_id', ['id'], unique=False) + batch_op.alter_column('created_at', + existing_type=sa.DateTime(timezone=True), + type_=postgresql.TIMESTAMP(), + nullable=True) + batch_op.alter_column('chunk_index', + existing_type=sa.INTEGER(), + nullable=True) + batch_op.alter_column('chunk_text', + existing_type=sa.Text(), + type_=sa.VARCHAR(), + existing_nullable=False) + batch_op.alter_column('document_id', + existing_type=sa.VARCHAR(), + nullable=True) + + if _table_exists(bind, 'chatbot_documents'): + with op.batch_alter_table('chatbot_documents', schema=None) as batch_op: + batch_op.create_index('ix_chatbot_documents_id', ['id'], unique=False) + batch_op.create_index('ix_chatbot_documents_drive_file_id', ['drive_file_id'], unique=False) + batch_op.alter_column('created_at', + existing_type=postgresql.TIMESTAMP(), + nullable=True) + batch_op.alter_column('chunks_count', + existing_type=sa.INTEGER(), + nullable=True) diff --git a/alembic/versions/cfbee4f91fc2_add_updated_at_defaults.py b/alembic/versions/cfbee4f91fc2_add_updated_at_defaults.py new file mode 100644 index 0000000..915bffb --- /dev/null +++ b/alembic/versions/cfbee4f91fc2_add_updated_at_defaults.py @@ -0,0 +1,52 @@ +"""add_updated_at_defaults + +Revision ID: cfbee4f91fc2 +Revises: 49c19df2c1d4 +Create Date: 2026-02-19 11:25:54.874662 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'cfbee4f91fc2' +down_revision: Union[str, None] = '49c19df2c1d4' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('drive_folders', 'updated_at', + existing_type=sa.DateTime(timezone=True), + server_default=sa.text('now()'), + existing_nullable=False) + op.alter_column('drive_files', 'updated_at', + existing_type=sa.DateTime(timezone=True), + server_default=sa.text('now()'), + existing_nullable=False) + op.alter_column('projects', 'updated_at', + existing_type=sa.DateTime(timezone=True), + server_default=sa.text('now()'), + existing_nullable=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('projects', 'updated_at', + existing_type=sa.DateTime(timezone=True), + server_default=None, + existing_nullable=False) + op.alter_column('drive_files', 'updated_at', + existing_type=sa.DateTime(timezone=True), + server_default=None, + existing_nullable=False) + op.alter_column('drive_folders', 'updated_at', + existing_type=sa.DateTime(timezone=True), + server_default=None, + existing_nullable=False) + # ### end Alembic commands ### diff --git a/alembic/versions/d1_0_plans_and_entitlements.py b/alembic/versions/d1_0_plans_and_entitlements.py new file mode 100644 index 0000000..e36b65e --- /dev/null +++ b/alembic/versions/d1_0_plans_and_entitlements.py @@ -0,0 +1,219 @@ +"""D1 — plans, limits, subscriptions and overrides + +The landing page advertises four priced plans and the backend had never heard of +a plan. Meanwhile `tenants` carried four limit columns set per tenant by hand, so +two customers on the same advertised plan could silently have different limits. + +This gives those limits something to be derived from. + +**Behaviour-neutral by construction.** Every tenant's four current values become +**overrides**, not plan limits — so whatever they have today is exactly what they +keep, whichever plan they are placed on. The plan is then chosen to match, or +`custom` when nothing does. Nobody's limits move. + +The `tenants` columns are **not** dropped. They stay for one release and remain +the last-resort fallback in `EntitlementService`, the same transition shape as +`users.role_id` and for the same reason: a rollback must not be able to remove +every tenant's quota. + +`plans` and `plan_limits` are deliberately **not** tenant-scoped — a catalogue is +shared, like `accesses`. `tenant_subscriptions` and `tenant_limit_overrides` are, +and are added to the RLS policy here. + +Revision ID: d1_0_plans_and_entitlements +Revises: c3_0_org_unit_path +""" + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects.postgresql import UUID + +revision = "d1_0_plans_and_entitlements" +down_revision = "c3_0_org_unit_path" +branch_labels = None +depends_on = None + +TENANT_TABLES = ["tenant_subscriptions", "tenant_limit_overrides"] +POLICY = "tenant_isolation" +USING = """ + coalesce(current_setting('docqube.bypass', true), '') = 'on' + OR tenant_id IS NULL + OR tenant_id = nullif(current_setting('docqube.tenant_id', true), '')::uuid +""" + +# The four plans the pricing page already sells, with the limits it claims. +# Seeded here rather than in `scripts/seed.py` so that a database which has run +# migrations is one a customer could be placed on — the page and the schema +# start out agreeing. +# +# -1 means unlimited, matching the convention `tenants.envelope_limit` already +# uses. +GB = 1024 ** 3 +PLANS = [ + # code, name, price, public, order + ("starter", "Starter plan", "$12", True, 1), + ("professional", "Professional plan", "$22", True, 2), + ("elite", "Elite plan", "$29", True, 3), + ("custom", "Custom plan", "Contact Us", True, 4), +] +LIMITS = { + "starter": {"storage_bytes": 10 * GB, "seats": 5, + "chat_tokens_daily": 500_000, "envelopes": 25}, + "professional": {"storage_bytes": 100 * GB, "seats": 25, + "chat_tokens_daily": 2_000_000, "envelopes": 250}, + "elite": {"storage_bytes": 500 * GB, "seats": 100, + "chat_tokens_daily": 10_000_000, "envelopes": -1}, + "custom": {"storage_bytes": -1, "seats": -1, + "chat_tokens_daily": -1, "envelopes": -1}, +} + + +def upgrade() -> None: + op.create_table( + "plans", + sa.Column("id", UUID(as_uuid=True), primary_key=True), + sa.Column("code", sa.String(50), nullable=False, unique=True), + sa.Column("name", sa.String(255), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("price_display", sa.String(50), nullable=True), + sa.Column("currency", sa.String(10), nullable=True), + sa.Column("is_public", sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column("sort_order", sa.Integer(), nullable=False, server_default="0"), + sa.Column("created_at", sa.DateTime(timezone=True), + server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), + server_default=sa.func.now(), nullable=False), + ) + op.create_index("ix_plans_code", "plans", ["code"]) + + op.create_table( + "plan_limits", + sa.Column("id", UUID(as_uuid=True), primary_key=True), + sa.Column("plan_id", UUID(as_uuid=True), + sa.ForeignKey("plans.id", ondelete="CASCADE"), nullable=False), + sa.Column("key", sa.String(50), nullable=False), + sa.Column("value", sa.BigInteger(), nullable=False), + sa.UniqueConstraint("plan_id", "key", name="uq_plan_limits_plan_key"), + ) + op.create_index("ix_plan_limits_plan_id", "plan_limits", ["plan_id"]) + + op.create_table( + "tenant_subscriptions", + sa.Column("id", UUID(as_uuid=True), primary_key=True), + sa.Column("tenant_id", UUID(as_uuid=True), + sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False), + # RESTRICT: deleting a plan somebody is on would silently drop them to + # the defaults. Move them first, deliberately. + sa.Column("plan_id", UUID(as_uuid=True), + sa.ForeignKey("plans.id", ondelete="RESTRICT"), nullable=False), + sa.Column("status", sa.String(20), nullable=False, server_default="active"), + sa.Column("started_at", sa.DateTime(timezone=True), + server_default=sa.func.now(), nullable=False), + sa.Column("current_period_end", sa.DateTime(timezone=True), nullable=True), + sa.Column("cancel_at_period_end", sa.Boolean(), nullable=False, + server_default=sa.false()), + sa.Column("external_ref", sa.String(255), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), + server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), + server_default=sa.func.now(), nullable=False), + # One live subscription per tenant: a second "current" row is an + # ambiguity nothing can resolve. + sa.UniqueConstraint("tenant_id", name="uq_tenant_subscriptions_tenant"), + ) + op.create_index("ix_tenant_subscriptions_tenant_id", "tenant_subscriptions", ["tenant_id"]) + op.create_index("ix_tenant_subscriptions_plan_id", "tenant_subscriptions", ["plan_id"]) + op.create_index("ix_tenant_subscriptions_status", "tenant_subscriptions", ["status"]) + op.create_index("ix_tenant_subscriptions_external_ref", "tenant_subscriptions", ["external_ref"]) + + op.create_table( + "tenant_limit_overrides", + sa.Column("id", UUID(as_uuid=True), primary_key=True), + sa.Column("tenant_id", UUID(as_uuid=True), + sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False), + sa.Column("key", sa.String(50), nullable=False), + sa.Column("value", sa.BigInteger(), nullable=False), + sa.Column("note", sa.Text(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), + server_default=sa.func.now(), nullable=False), + sa.UniqueConstraint("tenant_id", "key", name="uq_tenant_limit_overrides"), + ) + op.create_index("ix_tenant_limit_overrides_tenant_id", "tenant_limit_overrides", ["tenant_id"]) + + # --- seed the catalogue ------------------------------------------------- + for code, name, price, public, order in PLANS: + op.execute( + sa.text( + "INSERT INTO plans (id, code, name, price_display, currency, " + "is_public, sort_order, created_at, updated_at) " + "VALUES (gen_random_uuid(), :code, :name, :price, 'USD', " + ":public, :order, now(), now())" + ).bindparams(code=code, name=name, price=price, public=public, order=order) + ) + for key, value in LIMITS[code].items(): + op.execute( + sa.text( + "INSERT INTO plan_limits (id, plan_id, key, value) " + "SELECT gen_random_uuid(), id, :key, :value FROM plans " + " WHERE code = :code" + ).bindparams(key=key, value=value, code=code) + ) + + # --- backfill, behaviour-neutral ---------------------------------------- + # + # Current values become **overrides**, so every tenant keeps exactly what it + # has regardless of which plan it lands on. The plan is a label until + # somebody removes the override. + op.execute( + """ + INSERT INTO tenant_limit_overrides (id, tenant_id, key, value, note, created_at) + SELECT gen_random_uuid(), t.id, v.key, v.value, + 'Carried over from the tenant row at D1; not a negotiated exception', + now() + FROM tenants t + CROSS JOIN LATERAL (VALUES + ('storage_bytes', t.storage_quota_bytes), + ('seats', COALESCE(t.max_users_allowed, -1)), + ('chat_tokens_daily', t.chat_token_daily_limit), + ('envelopes', t.envelope_limit) + ) AS v(key, value) + WHERE t.is_deleted = false + ON CONFLICT DO NOTHING + """ + ) + + # Every live tenant is placed on `custom`, not on a guessed plan. + # + # Matching limits to a plan would be inference, and inferring a commercial + # fact is how somebody ends up on Starter because their quota happened to + # look like it. `custom` is honest: nobody has said what they are on yet, + # and their overrides mean it changes nothing. + op.execute( + """ + INSERT INTO tenant_subscriptions (id, tenant_id, plan_id, status, + started_at, created_at, updated_at) + SELECT gen_random_uuid(), t.id, p.id, 'active', now(), now(), now() + FROM tenants t + CROSS JOIN (SELECT id FROM plans WHERE code = 'custom') p + WHERE t.is_deleted = false + ON CONFLICT DO NOTHING + """ + ) + + for table in TENANT_TABLES: + op.execute(f"ALTER TABLE {table} ENABLE ROW LEVEL SECURITY") + op.execute(f"ALTER TABLE {table} FORCE ROW LEVEL SECURITY") + op.execute(f"DROP POLICY IF EXISTS {POLICY} ON {table}") + op.execute(f"CREATE POLICY {POLICY} ON {table} USING ({USING}) WITH CHECK ({USING})") + + +def downgrade() -> None: + for table in TENANT_TABLES: + op.execute(f"DROP POLICY IF EXISTS {POLICY} ON {table}") + + # Safe to reverse: the `tenants` columns were never touched, so removing + # these tables returns the application to the limits it still reads. + op.drop_table("tenant_limit_overrides") + op.drop_table("tenant_subscriptions") + op.drop_table("plan_limits") + op.drop_table("plans") diff --git a/alembic/versions/d2_0_credit_limit_precision.py b/alembic/versions/d2_0_credit_limit_precision.py new file mode 100644 index 0000000..7267d2c --- /dev/null +++ b/alembic/versions/d2_0_credit_limit_precision.py @@ -0,0 +1,61 @@ +"""D2 — a credit limit stops being rounded to a whole number + +`chat_credit_quota.daily_credit_limit` is `bigint` in the database and +`DECIMAL(12, 2)` on the model. PostgreSQL resolves that disagreement by +rounding, silently, on write: + + INSERT ... VALUES (0.60::numeric(12,2)) -> stored: 1 + INSERT ... VALUES (10.75::numeric(12,2)) -> stored: 11 + INSERT ... VALUES (10.25::numeric(12,2)) -> stored: 10 + +So a tenant admin setting a limit of 0.60 credits gets 1 — a 67% overshoot — and +nothing anywhere reports it. Whether that has bitten depends on whether anyone +has entered a fraction, which is not a property worth relying on. + +Widening, so no value is at risk: every bigint currently stored is representable +in `numeric(12, 2)`. `credits_used_today` and `total_credits_ever` are left as +`bigint` deliberately — they count tokens, which are whole. + +The reverse casts back to `bigint` and therefore *does* round. That is the +honest reverse of a widening: going back to a narrower type cannot preserve what +the wider one held. It is written that way rather than left unimplemented so the +migration can actually be reversed, but rolling this back after fractional +limits have been set will round them. + +Found by `alembic revision --autogenerate`, which had never been run against +this schema, and which after the model-registry fix reports this as the single +remaining difference between the models and the database. + +Revision ID: d2_0_credit_limit_precision +Revises: d1_0_plans_and_entitlements +""" + +import sqlalchemy as sa +from alembic import op + +revision = "d2_0_credit_limit_precision" +down_revision = "d1_0_plans_and_entitlements" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.alter_column( + "chat_credit_quota", + "daily_credit_limit", + existing_type=sa.BigInteger(), + type_=sa.DECIMAL(precision=12, scale=2), + existing_nullable=False, + postgresql_using="daily_credit_limit::numeric(12,2)", + ) + + +def downgrade() -> None: + op.alter_column( + "chat_credit_quota", + "daily_credit_limit", + existing_type=sa.DECIMAL(precision=12, scale=2), + type_=sa.BigInteger(), + existing_nullable=False, + postgresql_using="round(daily_credit_limit)::bigint", + ) diff --git a/alembic/versions/d2e2c55290ee_add_10mb_check_constraints_on_document_.py b/alembic/versions/d2e2c55290ee_add_10mb_check_constraints_on_document_.py new file mode 100644 index 0000000..9927d5b --- /dev/null +++ b/alembic/versions/d2e2c55290ee_add_10mb_check_constraints_on_document_.py @@ -0,0 +1,60 @@ +"""Add 10MB CHECK constraints on document content fields + +Revision ID: d2e2c55290ee +Revises: 9f6521a38bf3 +Create Date: 2026-03-03 22:17:00.564459 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'd2e2c55290ee' +down_revision: Union[str, None] = '9f6521a38bf3' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +# 10 MB limit in bytes +_MAX = 10 * 1024 * 1024 # 10485760 + + +def upgrade() -> None: + # projects table — three content columns + op.create_check_constraint( + 'ck_project_markdown_max', 'projects', + f'octet_length(markdown_content) <= {_MAX}' + ) + op.create_check_constraint( + 'ck_project_xml_max', 'projects', + f'octet_length(xml_content) <= {_MAX}' + ) + op.create_check_constraint( + 'ck_project_html_max', 'projects', + f'octet_length(html_content) <= {_MAX}' + ) + + # project_versions table — three content columns + op.create_check_constraint( + 'ck_version_markdown_max', 'project_versions', + f'octet_length(markdown_content) <= {_MAX}' + ) + op.create_check_constraint( + 'ck_version_xml_max', 'project_versions', + f'octet_length(xml_content) <= {_MAX}' + ) + op.create_check_constraint( + 'ck_version_html_max', 'project_versions', + f'octet_length(html_content) <= {_MAX}' + ) + + +def downgrade() -> None: + op.drop_constraint('ck_version_html_max', 'project_versions', type_='check') + op.drop_constraint('ck_version_xml_max', 'project_versions', type_='check') + op.drop_constraint('ck_version_markdown_max', 'project_versions', type_='check') + op.drop_constraint('ck_project_html_max', 'projects', type_='check') + op.drop_constraint('ck_project_xml_max', 'projects', type_='check') + op.drop_constraint('ck_project_markdown_max', 'projects', type_='check') diff --git a/alembic/versions/d551c03861f4_rename_quota_to_credits.py b/alembic/versions/d551c03861f4_rename_quota_to_credits.py new file mode 100644 index 0000000..fc18046 --- /dev/null +++ b/alembic/versions/d551c03861f4_rename_quota_to_credits.py @@ -0,0 +1,38 @@ +"""rename_quota_to_credits + +Revision ID: d551c03861f4 +Revises: 540c970ff890 +Create Date: 2026-05-13 13:01:51.510123 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'd551c03861f4' +down_revision: Union[str, None] = '540c970ff890' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Rename Table + op.rename_table('chat_token_quota', 'chat_credit_quota') + + # Rename Columns in chat_credit_quota + op.alter_column('chat_credit_quota', 'daily_limit', new_column_name='daily_credit_limit') + op.alter_column('chat_credit_quota', 'tokens_used_today', new_column_name='credits_used_today') + op.alter_column('chat_credit_quota', 'total_tokens_ever', new_column_name='total_credits_ever') + + +def downgrade() -> None: + # Rename Columns back + op.alter_column('chat_credit_quota', 'daily_credit_limit', new_column_name='daily_limit') + op.alter_column('chat_credit_quota', 'credits_used_today', new_column_name='tokens_used_today') + op.alter_column('chat_credit_quota', 'total_credits_ever', new_column_name='total_tokens_ever') + + # Rename Table back + op.rename_table('chat_credit_quota', 'chat_token_quota') diff --git a/alembic/versions/daf45366d608_add_envelope_limits_to_tenants.py b/alembic/versions/daf45366d608_add_envelope_limits_to_tenants.py new file mode 100644 index 0000000..9ee0286 --- /dev/null +++ b/alembic/versions/daf45366d608_add_envelope_limits_to_tenants.py @@ -0,0 +1,34 @@ +"""Add envelope limits to tenants + +Revision ID: daf45366d608 +Revises: 611d6b99ad11 +Create Date: 2026-07-18 10:34:04.536445 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'daf45366d608' +down_revision: Union[str, None] = '611d6b99ad11' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column('tenants', sa.Column('envelope_limit', sa.BigInteger(), nullable=False, server_default='-1')) + op.add_column('tenants', sa.Column('envelopes_used', sa.BigInteger(), nullable=False, server_default='0')) + op.add_column('tenants', sa.Column('envelope_reset_days', sa.Integer(), nullable=True)) + op.add_column('tenants', sa.Column('envelope_last_reset_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('now()'))) + pass + + +def downgrade() -> None: + op.drop_column('tenants', 'envelope_last_reset_at') + op.drop_column('tenants', 'envelope_reset_days') + op.drop_column('tenants', 'envelopes_used') + op.drop_column('tenants', 'envelope_limit') + pass diff --git a/alembic/versions/dbaa8ef522c0_add_color_to_drive_comments.py b/alembic/versions/dbaa8ef522c0_add_color_to_drive_comments.py new file mode 100644 index 0000000..b66df2c --- /dev/null +++ b/alembic/versions/dbaa8ef522c0_add_color_to_drive_comments.py @@ -0,0 +1,38 @@ +"""add color to drive comments + +Revision ID: dbaa8ef522c0 +Revises: 8736ab6bd5af +Create Date: 2026-02-26 14:23:14.999888 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = 'dbaa8ef522c0' +down_revision: Union[str, None] = '8736ab6bd5af' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('drive_comments', sa.Column('color', sa.String(length=20), nullable=True)) + op.alter_column('notifications', 'created_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + nullable=False, + existing_server_default=sa.text('now()')) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('notifications', 'created_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + nullable=True, + existing_server_default=sa.text('now()')) + op.drop_column('drive_comments', 'color') + # ### end Alembic commands ### diff --git a/alembic/versions/dfae38e4ede3_device_constraints_and_session_updates.py b/alembic/versions/dfae38e4ede3_device_constraints_and_session_updates.py new file mode 100644 index 0000000..0f745b9 --- /dev/null +++ b/alembic/versions/dfae38e4ede3_device_constraints_and_session_updates.py @@ -0,0 +1,28 @@ +"""device constraints and session updates + +Revision ID: dfae38e4ede3 +Revises: 9c49fe120ffb +Create Date: 2026-08-07 11:03:54.251473 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = 'dfae38e4ede3' +down_revision: Union[str, None] = '9c49fe120ffb' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + + op.create_unique_constraint('uq_device_user_fingerprint', 'devices', ['user_id', 'fingerprint']) + + +def downgrade() -> None: + op.drop_constraint('uq_device_user_fingerprint', 'devices', type_='unique') diff --git a/alembic/versions/e1_0_add_saas_plan_id.py b/alembic/versions/e1_0_add_saas_plan_id.py new file mode 100644 index 0000000..9cf4ff4 --- /dev/null +++ b/alembic/versions/e1_0_add_saas_plan_id.py @@ -0,0 +1,32 @@ +"""add saas_plan_id to plans + +Revision ID: e1_0_add_saas_plan_id +Revises: 9c4a95ac6726 +Create Date: 2026-09-01 + +Adds a nullable `saas_plan_id` column to the `plans` table so that DocQube +can trace each plan back to the SaaS SubscriptionPlan that originated it via +the provision webhook. Plans created directly inside DocQube will have NULL. +""" + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = "e1_0_add_saas_plan_id" +down_revision = "9c4a95ac6726" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "plans", + sa.Column("saas_plan_id", sa.String(255), nullable=True), + ) + op.create_index("ix_plans_saas_plan_id", "plans", ["saas_plan_id"], unique=True) + + +def downgrade() -> None: + op.drop_index("ix_plans_saas_plan_id", table_name="plans") + op.drop_column("plans", "saas_plan_id") diff --git a/alembic/versions/e81a9d22ca5f_migrate_roles_access_to_uuid.py b/alembic/versions/e81a9d22ca5f_migrate_roles_access_to_uuid.py new file mode 100644 index 0000000..dec3a3b --- /dev/null +++ b/alembic/versions/e81a9d22ca5f_migrate_roles_access_to_uuid.py @@ -0,0 +1,116 @@ +"""migrate_roles_access_to_uuid + +Revision ID: e81a9d22ca5f +Revises: a2b3c4d5e6f7 +Create Date: 2026-03-05 14:42:53.769667 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'e81a9d22ca5f' +down_revision: Union[str, None] = 'a2b3c4d5e6f7' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # --- Users Table --- + # Drop FK pointing to roles + op.drop_constraint('users_role_id_fkey', 'users', type_='foreignkey') + + # --- RoleAccess Table --- + # Drop FKs + op.drop_constraint('role_accesses_role_id_fkey', 'role_accesses', type_='foreignkey') + op.drop_constraint('role_accesses_access_id_fkey', 'role_accesses', type_='foreignkey') + op.drop_constraint('pk_role_accesses', 'role_accesses', type_='primary') + + # --- Accesses Table --- + # FK was named 'fk_accesses_parent_id' in migration a2b3c4d5e6f7 + op.drop_constraint('fk_accesses_parent_id', 'accesses', type_='foreignkey') + + # --- Alter Columns to UUID --- + # Drop serial defaults before changing type + op.execute('ALTER TABLE accesses ALTER COLUMN id DROP DEFAULT') + op.execute('ALTER TABLE roles ALTER COLUMN id DROP DEFAULT') + + # Change types to UUID (tables should be empty) + op.execute('ALTER TABLE accesses ALTER COLUMN id TYPE uuid USING (gen_random_uuid())') + op.execute('ALTER TABLE accesses ALTER COLUMN parent_id TYPE uuid USING NULL') + + op.execute('ALTER TABLE roles ALTER COLUMN id TYPE uuid USING (gen_random_uuid())') + + op.execute('ALTER TABLE role_accesses ALTER COLUMN role_id TYPE uuid USING (gen_random_uuid())') + op.execute('ALTER TABLE role_accesses ALTER COLUMN access_id TYPE uuid USING (gen_random_uuid())') + + op.execute('ALTER TABLE users ALTER COLUMN role_id TYPE uuid USING NULL') + + # Set UUID defaults for primary keys + op.execute('ALTER TABLE accesses ALTER COLUMN id SET DEFAULT gen_random_uuid()') + op.execute('ALTER TABLE roles ALTER COLUMN id SET DEFAULT gen_random_uuid()') + + # Drop leftover serial sequences + op.execute('DROP SEQUENCE IF EXISTS accesses_id_seq CASCADE') + op.execute('DROP SEQUENCE IF EXISTS roles_id_seq CASCADE') + + # --- Re-add Constraints --- + # Accesses + op.create_foreign_key('fk_accesses_parent_id', 'accesses', 'accesses', ['parent_id'], ['id'], ondelete='CASCADE') + + # RoleAccess + op.create_primary_key('pk_role_accesses', 'role_accesses', ['role_id', 'access_id']) + op.create_foreign_key('role_accesses_role_id_fkey', 'role_accesses', 'roles', ['role_id'], ['id'], ondelete='CASCADE') + op.create_foreign_key('role_accesses_access_id_fkey', 'role_accesses', 'accesses', ['access_id'], ['id'], ondelete='CASCADE') + + # Users + op.create_foreign_key('users_role_id_fkey', 'users', 'roles', ['role_id'], ['id'], ondelete='SET NULL') + + +def downgrade() -> None: + # --- Users Table --- + op.drop_constraint('users_role_id_fkey', 'users', type_='foreignkey') + + # --- RoleAccess Table --- + op.drop_constraint('role_accesses_role_id_fkey', 'role_accesses', type_='foreignkey') + op.drop_constraint('role_accesses_access_id_fkey', 'role_accesses', type_='foreignkey') + op.drop_constraint('pk_role_accesses', 'role_accesses', type_='primary') + + # --- Accesses Table --- + op.drop_constraint('fk_accesses_parent_id', 'accesses', type_='foreignkey') + + # Truncate all tables (UUID -> INT cast is destructive) + op.execute('TRUNCATE TABLE role_accesses CASCADE') + op.execute('TRUNCATE TABLE users CASCADE') + op.execute('TRUNCATE TABLE accesses CASCADE') + op.execute('TRUNCATE TABLE roles CASCADE') + + # Drop UUID defaults + op.execute('ALTER TABLE accesses ALTER COLUMN id DROP DEFAULT') + op.execute('ALTER TABLE roles ALTER COLUMN id DROP DEFAULT') + + # Change types back to integer + op.execute('ALTER TABLE accesses ALTER COLUMN id TYPE integer USING 0') + op.execute('ALTER TABLE accesses ALTER COLUMN parent_id TYPE integer USING NULL') + op.execute('ALTER TABLE roles ALTER COLUMN id TYPE integer USING 0') + op.execute('ALTER TABLE role_accesses ALTER COLUMN role_id TYPE integer USING 0') + op.execute('ALTER TABLE role_accesses ALTER COLUMN access_id TYPE integer USING 0') + op.execute('ALTER TABLE users ALTER COLUMN role_id TYPE integer USING NULL') + + # Recreate serial sequences + op.execute("CREATE SEQUENCE accesses_id_seq OWNED BY accesses.id") + op.execute("ALTER TABLE accesses ALTER COLUMN id SET DEFAULT nextval('accesses_id_seq')") + op.execute("CREATE SEQUENCE roles_id_seq OWNED BY roles.id") + op.execute("ALTER TABLE roles ALTER COLUMN id SET DEFAULT nextval('roles_id_seq')") + + # --- Re-add Constraints --- + op.create_foreign_key('fk_accesses_parent_id', 'accesses', 'accesses', ['parent_id'], ['id'], ondelete='CASCADE') + + op.create_primary_key('pk_role_accesses', 'role_accesses', ['role_id', 'access_id']) + op.create_foreign_key('role_accesses_role_id_fkey', 'role_accesses', 'roles', ['role_id'], ['id'], ondelete='CASCADE') + op.create_foreign_key('role_accesses_access_id_fkey', 'role_accesses', 'accesses', ['access_id'], ['id'], ondelete='CASCADE') + + op.create_foreign_key('users_role_id_fkey', 'users', 'roles', ['role_id'], ['id'], ondelete='SET NULL') diff --git a/alembic/versions/f055f9b157cb_add_cascade_deletes_to_drive_file_.py b/alembic/versions/f055f9b157cb_add_cascade_deletes_to_drive_file_.py new file mode 100644 index 0000000..128e295 --- /dev/null +++ b/alembic/versions/f055f9b157cb_add_cascade_deletes_to_drive_file_.py @@ -0,0 +1,36 @@ +"""Add cascade deletes to drive_file_versions + +Revision ID: f055f9b157cb +Revises: a902935ea69e +Create Date: 2026-03-02 11:08:38.477933 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'f055f9b157cb' +down_revision: Union[str, None] = 'a902935ea69e' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint('drive_file_versions_file_id_fkey', 'drive_file_versions', type_='foreignkey') + op.create_foreign_key(None, 'drive_file_versions', 'drive_files', ['file_id'], ['id'], ondelete='CASCADE') + op.drop_constraint('drive_stars_user_id_fkey', 'drive_stars', type_='foreignkey') + op.create_foreign_key(None, 'drive_stars', 'users', ['user_id'], ['id'], ondelete='CASCADE') + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint(None, 'drive_stars', type_='foreignkey') + op.create_foreign_key('drive_stars_user_id_fkey', 'drive_stars', 'users', ['user_id'], ['id']) + op.drop_constraint(None, 'drive_file_versions', type_='foreignkey') + op.create_foreign_key('drive_file_versions_file_id_fkey', 'drive_file_versions', 'drive_files', ['file_id'], ['id']) + # ### end Alembic commands ### diff --git a/alembic/versions/fb6810c36ffb_add_has_completed_tutorial_to_user.py b/alembic/versions/fb6810c36ffb_add_has_completed_tutorial_to_user.py new file mode 100644 index 0000000..9073fa3 --- /dev/null +++ b/alembic/versions/fb6810c36ffb_add_has_completed_tutorial_to_user.py @@ -0,0 +1,34 @@ +"""Add has_completed_tutorial to User + +Revision ID: fb6810c36ffb +Revises: 229359b0d1b2 +Create Date: 2026-04-02 11:27:02.719590 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'fb6810c36ffb' +down_revision: Union[str, None] = '229359b0d1b2' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.add_column(sa.Column('has_completed_tutorial', sa.Boolean(), server_default=sa.text('false'), nullable=False)) + + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.drop_column('has_completed_tutorial') + + # ### end Alembic commands ### diff --git a/alembic/versions/ff8052a97834_add_ip_address_to_drive_activities.py b/alembic/versions/ff8052a97834_add_ip_address_to_drive_activities.py new file mode 100644 index 0000000..b3d8593 --- /dev/null +++ b/alembic/versions/ff8052a97834_add_ip_address_to_drive_activities.py @@ -0,0 +1,30 @@ +"""Add ip_address to drive_activities + +Revision ID: ff8052a97834 +Revises: 765b053e3dcd +Create Date: 2026-03-03 21:48:41.907463 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'ff8052a97834' +down_revision: Union[str, None] = '765b053e3dcd' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('drive_activities', sa.Column('ip_address', sa.String(length=64), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('drive_activities', 'ip_address') + # ### end Alembic commands ### diff --git a/alembic/versions/ff9052a97835_add_multi_tenancy.py b/alembic/versions/ff9052a97835_add_multi_tenancy.py new file mode 100644 index 0000000..01dca5f --- /dev/null +++ b/alembic/versions/ff9052a97835_add_multi_tenancy.py @@ -0,0 +1,104 @@ +"""Add multi_tenancy + +Revision ID: ff9052a97835 +Revises: ff8052a97834 +Create Date: 2026-03-05 10:00:00.000000 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import UUID + + +# revision identifiers, used by Alembic. +revision: str = "ff9052a97835" +down_revision: Union[str, None] = "ff8052a97834" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # 1. Create tenants table + op.execute( + """ + CREATE TABLE tenants ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(255) NOT NULL, + slug VARCHAR(100) NOT NULL UNIQUE, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() + ); + """ + ) + + + # 3. Add tenant_id columns (nullable first) + tables = [ + "users", + "projects", + "drive_folders", + "drive_files", + "drive_activities", + "chatbot_documents", + "notifications", + "user_files", + ] + for table in tables: + op.execute( + f"ALTER TABLE {table} ADD COLUMN tenant_id UUID REFERENCES tenants(id);" + ) + + # 4. Backfill existing data + for table in tables: + op.execute( + f"UPDATE {table} SET tenant_id = '00000000-0000-0000-0000-000000000001';" + ) + + # 5. Set NOT NULL (except users) + not_null_tables = [ + "projects", + "drive_folders", + "drive_files", + "drive_activities", + "chatbot_documents", + "notifications", + "user_files", + ] + for table in not_null_tables: + op.execute(f"ALTER TABLE {table} ALTER COLUMN tenant_id SET NOT NULL;") + + # 6. Create indexes + for table in tables: + op.execute(f"CREATE INDEX ix_{table}_tenant_id ON {table}(tenant_id);") + + # 7. Constraint unique (tenant_id, email) + op.execute("ALTER TABLE users DROP CONSTRAINT IF EXISTS users_email_key;") + op.execute("ALTER TABLE users DROP CONSTRAINT IF EXISTS uq_users_email;") + op.execute( + "ALTER TABLE users ADD CONSTRAINT uq_user_tenant_email UNIQUE (tenant_id, email);" + ) + + +def downgrade() -> None: + op.execute("ALTER TABLE users DROP CONSTRAINT IF EXISTS uq_user_tenant_email;") + op.execute("ALTER TABLE users ADD CONSTRAINT users_email_key UNIQUE (email);") + + tables = [ + "users", + "projects", + "drive_folders", + "drive_files", + "drive_activities", + "chatbot_documents", + "notifications", + "user_files", + ] + for table in tables: + op.execute(f"DROP INDEX IF EXISTS ix_{table}_tenant_id;") + op.execute(f"ALTER TABLE {table} DROP COLUMN IF EXISTS tenant_id;") + + op.execute("DROP TABLE IF EXISTS tenants;") diff --git a/alembic/versions/g001_add_signing_requests_table.py b/alembic/versions/g001_add_signing_requests_table.py new file mode 100644 index 0000000..11afce5 --- /dev/null +++ b/alembic/versions/g001_add_signing_requests_table.py @@ -0,0 +1,38 @@ +"""add signing_requests table + +Revision ID: g001_signing +Revises: c7d4e6f1a9b0 +Create Date: 2026-03-06 +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import UUID, JSON + +# revision identifiers +revision = "g001_signing" +down_revision = "c7d4e6f1a9b0" +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + "signing_requests", + sa.Column("id", UUID(as_uuid=True), primary_key=True), + sa.Column("tenant_id", UUID(as_uuid=True), sa.ForeignKey("tenants.id"), nullable=False, index=True), + sa.Column("user_id", sa.Integer, sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True), + sa.Column("drive_file_id", sa.Integer, sa.ForeignKey("drive_files.id", ondelete="CASCADE"), nullable=False), + sa.Column("docuseal_template_id", sa.Integer, nullable=True), + sa.Column("docuseal_submission_id", sa.Integer, nullable=True), + sa.Column("docuseal_slug", sa.String(255), nullable=True), + sa.Column("signing_type", sa.String(20), nullable=False), + sa.Column("status", sa.String(20), nullable=False, server_default="pending"), + sa.Column("submitters_json", JSON, nullable=True), + sa.Column("signed_file_id", sa.Integer, sa.ForeignKey("drive_files.id"), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True), + ) + + +def downgrade(): + op.drop_table("signing_requests") diff --git a/alembic/versions/g002_replace_docuseal_with_placements.py b/alembic/versions/g002_replace_docuseal_with_placements.py new file mode 100644 index 0000000..065c06f --- /dev/null +++ b/alembic/versions/g002_replace_docuseal_with_placements.py @@ -0,0 +1,41 @@ +"""replace docuseal columns with placements_json + +Revision ID: g002_placements +Revises: g001_signing +Create Date: 2026-03-07 +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import JSON + +# revision identifiers +revision = "g002_placements" +down_revision = "g001_signing" +branch_labels = None +depends_on = None + + +def upgrade(): + op.drop_column("signing_requests", "docuseal_template_id") + op.drop_column("signing_requests", "docuseal_submission_id") + op.drop_column("signing_requests", "docuseal_slug") + op.add_column( + "signing_requests", + sa.Column("placements_json", JSON, nullable=True), + ) + + +def downgrade(): + op.drop_column("signing_requests", "placements_json") + op.add_column( + "signing_requests", + sa.Column("docuseal_template_id", sa.Integer, nullable=True), + ) + op.add_column( + "signing_requests", + sa.Column("docuseal_submission_id", sa.Integer, nullable=True), + ) + op.add_column( + "signing_requests", + sa.Column("docuseal_slug", sa.String(255), nullable=True), + ) diff --git a/alembic/versions/h003_add_tenant_smtp_settings.py b/alembic/versions/h003_add_tenant_smtp_settings.py new file mode 100644 index 0000000..650633d --- /dev/null +++ b/alembic/versions/h003_add_tenant_smtp_settings.py @@ -0,0 +1,76 @@ +"""add tenant smtp settings + +Revision ID: h003_tenant_smtp +Revises: g002_placements +Create Date: 2026-03-09 +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + + +# revision identifiers, used by Alembic. +revision: str = "h003_tenant_smtp" +down_revision: Union[str, None] = "g002_placements" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + + if not inspector.has_table("tenant_smtp_configs"): + op.create_table( + "tenant_smtp_configs", + sa.Column( + "id", + postgresql.UUID(as_uuid=True), + nullable=False, + server_default=sa.text("gen_random_uuid()"), + ), + sa.Column("tenant_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("smtp_host", sa.String(length=255), nullable=False), + sa.Column("smtp_port", sa.Integer(), nullable=False, server_default=sa.text("587")), + sa.Column("smtp_user", sa.String(length=255), nullable=True), + sa.Column("encrypted_smtp_password", sa.String(length=512), nullable=True), + sa.Column("smtp_secure", sa.Boolean(), nullable=False, server_default=sa.text("false")), + sa.Column("mail_from", sa.String(length=255), nullable=False), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("true")), + sa.Column("created_by_id", sa.Integer(), nullable=True), + sa.Column("updated_by_id", sa.Integer(), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("now()"), + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("now()"), + ), + sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["created_by_id"], ["users.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["updated_by_id"], ["users.id"], ondelete="SET NULL"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("tenant_id", name="uq_tenant_smtp_configs_tenant_id"), + ) + + existing_indexes = {idx["name"] for idx in inspector.get_indexes("tenant_smtp_configs")} + if "ix_tenant_smtp_configs_tenant_id" not in existing_indexes: + op.create_index( + "ix_tenant_smtp_configs_tenant_id", + "tenant_smtp_configs", + ["tenant_id"], + unique=False, + ) + + +def downgrade() -> None: + op.drop_index("ix_tenant_smtp_configs_tenant_id", table_name="tenant_smtp_configs") + op.drop_table("tenant_smtp_configs") diff --git a/alembic/versions/h004_add_signing_forensic_columns.py b/alembic/versions/h004_add_signing_forensic_columns.py new file mode 100644 index 0000000..424a3ac --- /dev/null +++ b/alembic/versions/h004_add_signing_forensic_columns.py @@ -0,0 +1,48 @@ +"""add forensic metadata columns to signing_requests + +Revision ID: h004_signing_forensic_v2 +Revises: h004_limits_soft_delete +Create Date: 2026-03-09 +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import JSON + + +# revision identifiers, used by Alembic. +revision: str = "h004_signing_forensic_v2" +down_revision: Union[str, None] = "h004_limits_soft_delete" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Safe: only add if column doesn't already exist (idempotent for existing DBs) + conn = op.get_bind() + inspector = sa.inspect(conn) + existing = {c["name"] for c in inspector.get_columns("signing_requests")} + + if "ip_address" not in existing: + op.add_column( + "signing_requests", + sa.Column("ip_address", sa.String(64), nullable=True), + ) + if "user_agent" not in existing: + op.add_column( + "signing_requests", + sa.Column("user_agent", sa.String(512), nullable=True), + ) + if "signing_metadata" not in existing: + op.add_column( + "signing_requests", + sa.Column("signing_metadata", JSON, nullable=True), + ) + + +def downgrade() -> None: + op.drop_column("signing_requests", "signing_metadata") + op.drop_column("signing_requests", "user_agent") + op.drop_column("signing_requests", "ip_address") diff --git a/alembic/versions/h004_limits_and_soft_delete.py b/alembic/versions/h004_limits_and_soft_delete.py new file mode 100644 index 0000000..57f82f0 --- /dev/null +++ b/alembic/versions/h004_limits_and_soft_delete.py @@ -0,0 +1,108 @@ +"""add limits config and soft delete columns + +Revision ID: h004_limits_soft_delete +Revises: h003_tenant_smtp +Create Date: 2026-03-09 +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + + +# revision identifiers, used by Alembic. +revision: str = "h004_limits_soft_delete" +down_revision: Union[str, None] = "h003_tenant_smtp" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "users", sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("true")) + ) + op.add_column( + "users", sa.Column("is_deleted", sa.Boolean(), nullable=False, server_default=sa.text("false")) + ) + op.add_column("users", sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True)) + op.add_column("users", sa.Column("deleted_by_id", sa.Integer(), nullable=True)) + op.create_foreign_key( + "fk_users_deleted_by_id_users", + "users", + "users", + ["deleted_by_id"], + ["id"], + ondelete="SET NULL", + ) + + op.add_column( + "tenants", sa.Column("is_deleted", sa.Boolean(), nullable=False, server_default=sa.text("false")) + ) + op.add_column("tenants", sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True)) + op.add_column("tenants", sa.Column("deleted_by_id", sa.Integer(), nullable=True)) + op.add_column("tenants", sa.Column("max_users_allowed", sa.Integer(), nullable=True)) + op.create_foreign_key( + "fk_tenants_deleted_by_id_users", + "tenants", + "users", + ["deleted_by_id"], + ["id"], + ondelete="SET NULL", + ) + + op.create_table( + "system_configurations", + sa.Column( + "id", + postgresql.UUID(as_uuid=True), + nullable=False, + server_default=sa.text("gen_random_uuid()"), + ), + sa.Column("config_key", sa.String(length=100), nullable=False), + sa.Column("int_value", sa.BigInteger(), nullable=True), + sa.Column("bool_value", sa.Boolean(), nullable=True), + sa.Column("text_value", sa.Text(), nullable=True), + sa.Column("created_by_id", sa.Integer(), nullable=True), + sa.Column("updated_by_id", sa.Integer(), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("now()"), + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("now()"), + ), + sa.ForeignKeyConstraint(["created_by_id"], ["users.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["updated_by_id"], ["users.id"], ondelete="SET NULL"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("config_key", name="uq_system_configurations_config_key"), + ) + op.create_index( + "ix_system_configurations_config_key", + "system_configurations", + ["config_key"], + unique=True, + ) + + +def downgrade() -> None: + op.drop_index("ix_system_configurations_config_key", table_name="system_configurations") + op.drop_table("system_configurations") + + op.drop_constraint("fk_tenants_deleted_by_id_users", "tenants", type_="foreignkey") + op.drop_column("tenants", "max_users_allowed") + op.drop_column("tenants", "deleted_by_id") + op.drop_column("tenants", "deleted_at") + op.drop_column("tenants", "is_deleted") + + op.drop_constraint("fk_users_deleted_by_id_users", "users", type_="foreignkey") + op.drop_column("users", "deleted_by_id") + op.drop_column("users", "deleted_at") + op.drop_column("users", "is_deleted") + op.drop_column("users", "is_active") diff --git a/alembic/versions/i001_dual_bucket_storage.py b/alembic/versions/i001_dual_bucket_storage.py new file mode 100644 index 0000000..40a92fe --- /dev/null +++ b/alembic/versions/i001_dual_bucket_storage.py @@ -0,0 +1,56 @@ +"""Replace b2_bucket_name with b2_quarantine_bucket and b2_clean_bucket + +Revision ID: i001_dual_bucket_storage +Revises: 9557c7307575 +Create Date: 2026-03-17 08:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = 'i001_dual_bucket_storage' +down_revision = '9557c7307575' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # 1. Add the two new bucket columns (nullable at first) + op.add_column( + 'tenant_storage_configs', + sa.Column('b2_quarantine_bucket', sa.String(255), nullable=True) + ) + op.add_column( + 'tenant_storage_configs', + sa.Column('b2_clean_bucket', sa.String(255), nullable=True) + ) + + # 2. Migrate existing b2_bucket_name to quarantine (best-effort) + op.execute(""" + UPDATE tenant_storage_configs + SET b2_quarantine_bucket = b2_bucket_name + WHERE b2_bucket_name IS NOT NULL + """) + + # 3. Drop the old column + op.drop_column('tenant_storage_configs', 'b2_bucket_name') + + +def downgrade() -> None: + # 1. Restore the old column + op.add_column( + 'tenant_storage_configs', + sa.Column('b2_bucket_name', sa.String(255), nullable=True) + ) + + # 2. Restore data from quarantine bucket (best-effort) + op.execute(""" + UPDATE tenant_storage_configs + SET b2_bucket_name = b2_quarantine_bucket + WHERE b2_quarantine_bucket IS NOT NULL + """) + + # 3. Drop the new columns + op.drop_column('tenant_storage_configs', 'b2_clean_bucket') + op.drop_column('tenant_storage_configs', 'b2_quarantine_bucket') diff --git a/alembic/versions/j001_comments_fix.py b/alembic/versions/j001_comments_fix.py new file mode 100644 index 0000000..ef7c144 --- /dev/null +++ b/alembic/versions/j001_comments_fix.py @@ -0,0 +1,31 @@ +"""Add rejected_at column to drive_comments + +Revision ID: j001_comments_fix +Revises: c3abf44266de +Create Date: 2026-03-24 + +""" + +from typing import Sequence, Union +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = "j001_comments_fix" +down_revision: Union[str, tuple, None] = "c3abf44266de" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.execute(""" + ALTER TABLE drive_comments + ADD COLUMN IF NOT EXISTS rejected_at TIMESTAMP WITH TIME ZONE DEFAULT NULL + """) + + +def downgrade() -> None: + op.execute(""" + ALTER TABLE drive_comments + DROP COLUMN IF EXISTS rejected_at + """) diff --git a/alembic/versions/j002_allow_unlimited_tenant_quota.py b/alembic/versions/j002_allow_unlimited_tenant_quota.py new file mode 100644 index 0000000..745ee9e --- /dev/null +++ b/alembic/versions/j002_allow_unlimited_tenant_quota.py @@ -0,0 +1,38 @@ +"""allow unlimited tenant storage quota sentinel + +Revision ID: j002_unlimited_tenant_quota +Revises: j001_comments_fix +Create Date: 2026-03-25 +""" + +from typing import Sequence, Union + +from alembic import op + + +# revision identifiers, used by Alembic. +revision: str = "j002_unlimited_tenant_quota" +down_revision: Union[str, None] = "j001_comments_fix" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +CONSTRAINT_NAME = "ck_tenants_storage_quota_bytes_positive" + + +def upgrade() -> None: + op.drop_constraint(CONSTRAINT_NAME, "tenants", type_="check") + op.create_check_constraint( + CONSTRAINT_NAME, + "tenants", + "storage_quota_bytes = -1 OR storage_quota_bytes > 0", + ) + + +def downgrade() -> None: + op.drop_constraint(CONSTRAINT_NAME, "tenants", type_="check") + op.create_check_constraint( + CONSTRAINT_NAME, + "tenants", + "storage_quota_bytes > 0", + ) diff --git a/alembic/versions/k001_limit_signature_id_to_15_chars.py b/alembic/versions/k001_limit_signature_id_to_15_chars.py new file mode 100644 index 0000000..695957b --- /dev/null +++ b/alembic/versions/k001_limit_signature_id_to_15_chars.py @@ -0,0 +1,42 @@ +"""limit signature_id to 15 characters + +Revision ID: k001_limit_signature_id_15 +Revises: 56d090047e85 +Create Date: 2026-04-15 +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "k001_limit_signature_id_15" +down_revision: Union[str, None] = "b029d2f5a0c7" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Truncate any existing values that exceed 15 chars before altering the column + op.execute( + "UPDATE signing_requests SET signature_id = LEFT(signature_id, 15) WHERE LENGTH(signature_id) > 15" + ) + op.alter_column( + "signing_requests", + "signature_id", + existing_type=sa.String(128), + type_=sa.String(15), + existing_nullable=True, + ) + + +def downgrade() -> None: + op.alter_column( + "signing_requests", + "signature_id", + existing_type=sa.String(15), + type_=sa.String(128), + existing_nullable=True, + ) diff --git a/alembic/versions/k002_add_docs_tutorial_flag.py b/alembic/versions/k002_add_docs_tutorial_flag.py new file mode 100644 index 0000000..a074dbc --- /dev/null +++ b/alembic/versions/k002_add_docs_tutorial_flag.py @@ -0,0 +1,28 @@ +"""Add has_completed_docs_tutorial to User + +Revision ID: k002_add_docs_tutorial +Revises: l001_merge_heads +Create Date: 2026-04-17 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'k002_add_docs_tutorial' +down_revision: Union[str, None] = 'l001_merge_heads' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.add_column(sa.Column('has_completed_docs_tutorial', sa.Boolean(), server_default=sa.text('false'), nullable=False)) + + +def downgrade() -> None: + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.drop_column('has_completed_docs_tutorial') diff --git a/alembic/versions/l001_merge_heads.py b/alembic/versions/l001_merge_heads.py new file mode 100644 index 0000000..7a28fe6 --- /dev/null +++ b/alembic/versions/l001_merge_heads.py @@ -0,0 +1,25 @@ +"""merge all heads into single linear chain + +Revision ID: l001_merge_heads +Revises: b029d2f5a0c7, k001_limit_signature_id_15 +Create Date: 2026-04-15 +""" + +from typing import Sequence, Union + +from alembic import op + + +# revision identifiers, used by Alembic. +revision: str = "l001_merge_heads" +down_revision: Union[str, tuple] = ("56d090047e85", "k001_limit_signature_id_15") +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + pass + + +def downgrade() -> None: + pass diff --git a/alembic/versions/m001_add_activity_logs_table.py b/alembic/versions/m001_add_activity_logs_table.py new file mode 100644 index 0000000..0ca9ede --- /dev/null +++ b/alembic/versions/m001_add_activity_logs_table.py @@ -0,0 +1,87 @@ +"""add activity_logs table + +Revision ID: m001_add_activity_logs +Revises: 9af99fc7ca0f +Create Date: 2026-04-20 +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + + +# revision identifiers, used by Alembic. +revision: str = 'm001_add_activity_logs' +down_revision: Union[str, None] = '9af99fc7ca0f' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +_INDEXES = [ + ("ix_activity_logs_tenant_created_at", ["tenant_id", "created_at"]), + ("ix_activity_logs_user_created_at", ["user_id", "created_at"]), + ("ix_activity_logs_module_action", ["module", "action"]), + ("ix_activity_logs_target_id", ["target_id"]), +] + + +def _index_names(bind, table_name: str) -> set[str]: + inspector = sa.inspect(bind) + return {idx["name"] for idx in inspector.get_indexes(table_name)} + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + + if not inspector.has_table("activity_logs"): + op.create_table( + "activity_logs", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("tenant_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=True), + sa.Column("user_name", sa.String(length=255), nullable=True), + sa.Column("user_email", sa.String(length=255), nullable=True), + sa.Column("module", sa.String(length=50), nullable=False), + sa.Column("action", sa.String(length=100), nullable=False), + sa.Column("target_id", sa.String(length=255), nullable=False), + sa.Column("target_type", sa.String(length=50), nullable=False), + sa.Column("target_name", sa.String(length=512), nullable=True), + sa.Column( + "metadata", + postgresql.JSONB(astext_type=sa.Text()), + nullable=False, + ), + sa.Column("ip_address", sa.String(length=64), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column("request_id", sa.String(length=255), nullable=True), + sa.Column("status", sa.String(length=20), nullable=False), + sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"]), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="SET NULL"), + sa.PrimaryKeyConstraint("id"), + ) + + existing = _index_names(bind, "activity_logs") + for name, cols in _INDEXES: + if name not in existing: + op.create_index(name, "activity_logs", cols, unique=False) + + +def downgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + + if inspector.has_table("activity_logs"): + existing = _index_names(bind, "activity_logs") + for name, _ in reversed(_INDEXES): + if name in existing: + op.drop_index(name, table_name="activity_logs") + + op.drop_table("activity_logs") diff --git a/alembic/versions/n001_add_user_designation.py b/alembic/versions/n001_add_user_designation.py new file mode 100644 index 0000000..c5bf857 --- /dev/null +++ b/alembic/versions/n001_add_user_designation.py @@ -0,0 +1,29 @@ +"""add designation column to users + +Revision ID: n001_add_user_designation +Revises: m001_add_activity_logs +Create Date: 2026-04-21 +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'n001_add_user_designation' +down_revision: Union[str, None] = 'm001_add_activity_logs' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + 'users', + sa.Column('designation', sa.String(length=100), nullable=True), + ) + + +def downgrade() -> None: + op.drop_column('users', 'designation') diff --git a/alembic/versions/n002_add_signature_imprints.py b/alembic/versions/n002_add_signature_imprints.py new file mode 100644 index 0000000..6c4551c --- /dev/null +++ b/alembic/versions/n002_add_signature_imprints.py @@ -0,0 +1,62 @@ +"""add signature_imprints table + +Revision ID: n002_add_signature_imprints +Revises: n001_add_user_designation +Create Date: 2026-04-21 +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + + +# revision identifiers, used by Alembic. +revision: str = 'n002_add_signature_imprints' +down_revision: Union[str, None] = 'n001_add_user_designation' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + 'signature_imprints', + sa.Column('id', postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column( + 'signing_request_id', + postgresql.UUID(as_uuid=True), + sa.ForeignKey('signing_requests.id', ondelete='CASCADE'), + nullable=False, + ), + sa.Column( + 'drive_file_id', + sa.Integer(), + sa.ForeignKey('drive_files.id', ondelete='CASCADE'), + nullable=False, + ), + sa.Column('page_number', sa.Integer(), nullable=False), + sa.Column('bbox_points', postgresql.JSON(), nullable=False), + sa.Column( + 'created_at', + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, + ), + ) + op.create_index( + 'ix_signature_imprints_file_page', + 'signature_imprints', + ['drive_file_id', 'page_number'], + ) + op.create_index( + 'ix_signature_imprints_signing_request', + 'signature_imprints', + ['signing_request_id'], + ) + + +def downgrade() -> None: + op.drop_index('ix_signature_imprints_signing_request', table_name='signature_imprints') + op.drop_index('ix_signature_imprints_file_page', table_name='signature_imprints') + op.drop_table('signature_imprints') diff --git a/alembic/versions/v3_extraction_data.py b/alembic/versions/v3_extraction_data.py new file mode 100644 index 0000000..27cd8a7 --- /dev/null +++ b/alembic/versions/v3_extraction_data.py @@ -0,0 +1,57 @@ +"""Add extraction_data table + +Revision ID: v3_extraction +Revises: l001_merge_heads +Create Date: 2026-04-18 12:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = 'v3_extraction' +down_revision: Union[str, None] = 'l001_merge_heads' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def _index_names(bind, table_name: str) -> set[str]: + inspector = sa.inspect(bind) + return {idx["name"] for idx in inspector.get_indexes(table_name)} + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + + if not inspector.has_table('extraction_data'): + op.create_table('extraction_data', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('document_id', sa.Integer(), nullable=False), + sa.Column('document_type', sa.String(length=50), nullable=True), + sa.Column('raw_text', sa.Text(), nullable=True), + sa.Column('cleaned_text', sa.Text(), nullable=True), + sa.Column('extracted_json', postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.Column('confidence_score', sa.Float(), nullable=True), + sa.Column('extraction_status', sa.String(length=50), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['document_id'], ['drive_files.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + + if 'idx_extraction_data_document_id' not in _index_names(bind, 'extraction_data'): + op.create_index(op.f('idx_extraction_data_document_id'), 'extraction_data', ['document_id'], unique=False) + + +def downgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + + if inspector.has_table('extraction_data'): + if 'idx_extraction_data_document_id' in _index_names(bind, 'extraction_data'): + op.drop_index(op.f('idx_extraction_data_document_id'), table_name='extraction_data') + op.drop_table('extraction_data') diff --git a/alembic/versions/w001_versioning_minor_and_version_fk.py b/alembic/versions/w001_versioning_minor_and_version_fk.py new file mode 100644 index 0000000..6a9e156 --- /dev/null +++ b/alembic/versions/w001_versioning_minor_and_version_fk.py @@ -0,0 +1,137 @@ +"""add minor_version + version-level FKs for signing artefacts + +Revision ID: w001_versioning_minor +Revises: v3_extraction, n002_add_signature_imprints, 39d5fdfb5386 +Create Date: 2026-04-28 + +Schema changes: + - drive_file_versions.minor_version (String(8), nullable) + - signing_requests.drive_file_version_id (Integer, nullable, FK) + - signature_imprints.drive_file_version_id (Integer, nullable, FK) + - Composite index on drive_file_versions(file_id, version_number, minor_version) + +Also serves as the merge revision for the three existing heads +('v3_extraction', 'n002_add_signature_imprints', '39d5fdfb5386'). +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "w001_versioning_minor" +down_revision: Union[str, tuple] = ( + "v3_extraction", + "n002_add_signature_imprints", + "39d5fdfb5386", +) +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def _column_names(bind, table_name: str) -> set: + inspector = sa.inspect(bind) + return {c["name"] for c in inspector.get_columns(table_name)} + + +def _index_names(bind, table_name: str) -> set: + inspector = sa.inspect(bind) + return {idx["name"] for idx in inspector.get_indexes(table_name)} + + +def upgrade() -> None: + bind = op.get_bind() + + # 1) drive_file_versions.minor_version + if "minor_version" not in _column_names(bind, "drive_file_versions"): + op.add_column( + "drive_file_versions", + sa.Column("minor_version", sa.String(length=8), nullable=True), + ) + + if "ix_drive_file_versions_file_major_minor" not in _index_names( + bind, "drive_file_versions" + ): + op.create_index( + "ix_drive_file_versions_file_major_minor", + "drive_file_versions", + ["file_id", "version_number", "minor_version"], + unique=False, + ) + + # 2) signing_requests.drive_file_version_id + if "drive_file_version_id" not in _column_names(bind, "signing_requests"): + op.add_column( + "signing_requests", + sa.Column("drive_file_version_id", sa.Integer(), nullable=True), + ) + op.create_foreign_key( + "fk_signing_requests_drive_file_version_id", + "signing_requests", + "drive_file_versions", + ["drive_file_version_id"], + ["id"], + ondelete="SET NULL", + ) + + # 3) signature_imprints.drive_file_version_id + if "drive_file_version_id" not in _column_names(bind, "signature_imprints"): + op.add_column( + "signature_imprints", + sa.Column("drive_file_version_id", sa.Integer(), nullable=True), + ) + op.create_foreign_key( + "fk_signature_imprints_drive_file_version_id", + "signature_imprints", + "drive_file_versions", + ["drive_file_version_id"], + ["id"], + ondelete="SET NULL", + ) + if "ix_signature_imprints_version_id" not in _index_names( + bind, "signature_imprints" + ): + op.create_index( + "ix_signature_imprints_version_id", + "signature_imprints", + ["drive_file_version_id"], + ) + + +def downgrade() -> None: + bind = op.get_bind() + + if "drive_file_version_id" in _column_names(bind, "signature_imprints"): + if "ix_signature_imprints_version_id" in _index_names( + bind, "signature_imprints" + ): + op.drop_index( + "ix_signature_imprints_version_id", + table_name="signature_imprints", + ) + op.drop_constraint( + "fk_signature_imprints_drive_file_version_id", + "signature_imprints", + type_="foreignkey", + ) + op.drop_column("signature_imprints", "drive_file_version_id") + + if "drive_file_version_id" in _column_names(bind, "signing_requests"): + op.drop_constraint( + "fk_signing_requests_drive_file_version_id", + "signing_requests", + type_="foreignkey", + ) + op.drop_column("signing_requests", "drive_file_version_id") + + if "minor_version" in _column_names(bind, "drive_file_versions"): + if "ix_drive_file_versions_file_major_minor" in _index_names( + bind, "drive_file_versions" + ): + op.drop_index( + "ix_drive_file_versions_file_major_minor", + table_name="drive_file_versions", + ) + op.drop_column("drive_file_versions", "minor_version") diff --git a/alembic/versions/x001_add_user_smtp_credentials.py b/alembic/versions/x001_add_user_smtp_credentials.py new file mode 100644 index 0000000..bca35ca --- /dev/null +++ b/alembic/versions/x001_add_user_smtp_credentials.py @@ -0,0 +1,48 @@ +"""add user smtp credential columns + +Revision ID: x001_user_smtp_credentials +Revises: w001_versioning_minor +Create Date: 2026-04-30 +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "x001_user_smtp_credentials" +down_revision: Union[str, None] = "w001_versioning_minor" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def _column_names(bind, table_name: str) -> set: + inspector = sa.inspect(bind) + return {column["name"] for column in inspector.get_columns(table_name)} + + +def upgrade() -> None: + bind = op.get_bind() + column_names = _column_names(bind, "users") + + if "smtp_user" not in column_names: + op.add_column("users", sa.Column("smtp_user", sa.String(length=255), nullable=True)) + + if "encrypted_smtp_password" not in column_names: + op.add_column( + "users", + sa.Column("encrypted_smtp_password", sa.String(length=512), nullable=True), + ) + + +def downgrade() -> None: + bind = op.get_bind() + column_names = _column_names(bind, "users") + + if "encrypted_smtp_password" in column_names: + op.drop_column("users", "encrypted_smtp_password") + + if "smtp_user" in column_names: + op.drop_column("users", "smtp_user") diff --git a/alembic/versions/y001_add_user_mail_from.py b/alembic/versions/y001_add_user_mail_from.py new file mode 100644 index 0000000..33f2130 --- /dev/null +++ b/alembic/versions/y001_add_user_mail_from.py @@ -0,0 +1,39 @@ +"""add user mail_from column + +Revision ID: y001_add_user_mail_from +Revises: x001_user_smtp_credentials +Create Date: 2026-04-30 +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "y001_add_user_mail_from" +down_revision: Union[str, None] = "x001_user_smtp_credentials" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def _column_names(bind, table_name: str) -> set: + inspector = sa.inspect(bind) + return {column["name"] for column in inspector.get_columns(table_name)} + + +def upgrade() -> None: + bind = op.get_bind() + column_names = _column_names(bind, "users") + + if "mail_from" not in column_names: + op.add_column("users", sa.Column("mail_from", sa.String(length=255), nullable=True)) + + +def downgrade() -> None: + bind = op.get_bind() + column_names = _column_names(bind, "users") + + if "mail_from" in column_names: + op.drop_column("users", "mail_from") diff --git a/alembic/versions/z002_pref_lang.py b/alembic/versions/z002_pref_lang.py new file mode 100644 index 0000000..f377765 --- /dev/null +++ b/alembic/versions/z002_pref_lang.py @@ -0,0 +1,45 @@ +"""add preferred_language to users + +Revision ID: z002_pref_lang +Revises: ca14c4ab10d9 +Create Date: 2026-05-08 +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "z002_pref_lang" +down_revision: Union[str, None] = "ca14c4ab10d9" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def _column_names(bind, table_name: str) -> set[str]: + inspector = sa.inspect(bind) + return {column["name"] for column in inspector.get_columns(table_name)} + + +def upgrade() -> None: + bind = op.get_bind() + column_names = _column_names(bind, "users") + + if "preferred_language" not in column_names: + op.add_column( + "users", + sa.Column("preferred_language", sa.String(length=16), nullable=True), + ) + op.execute( + "UPDATE users SET preferred_language = 'en' WHERE preferred_language IS NULL" + ) + op.alter_column("users", "preferred_language", nullable=False) + + +def downgrade() -> None: + bind = op.get_bind() + column_names = _column_names(bind, "users") + + if "preferred_language" in column_names: + op.drop_column("users", "preferred_language") diff --git a/alembic/versions/z003_add_disable_pdf_compression_to_tenants.py b/alembic/versions/z003_add_disable_pdf_compression_to_tenants.py new file mode 100644 index 0000000..16ede8b --- /dev/null +++ b/alembic/versions/z003_add_disable_pdf_compression_to_tenants.py @@ -0,0 +1,41 @@ +"""add disable_pdf_compression to tenants + +Revision ID: z003_disable_pdf_compression +Revises: 7d7641cc1bb2 +Create Date: 2026-07-23 +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "z003_disable_pdf_compression" +down_revision: Union[str, None] = "7d7641cc1bb2" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def _column_names(bind, table_name: str) -> set[str]: + inspector = sa.inspect(bind) + return {column["name"] for column in inspector.get_columns(table_name)} + + +def upgrade() -> None: + bind = op.get_bind() + column_names = _column_names(bind, "tenants") + + if "disable_pdf_compression" not in column_names: + op.add_column( + "tenants", + sa.Column("disable_pdf_compression", sa.Boolean(), nullable=False, server_default="false"), + ) + + +def downgrade() -> None: + bind = op.get_bind() + column_names = _column_names(bind, "tenants") + + if "disable_pdf_compression" in column_names: + op.drop_column("tenants", "disable_pdf_compression") diff --git a/alembic/versions/z004_add_global_workflows.py b/alembic/versions/z004_add_global_workflows.py new file mode 100644 index 0000000..bfbe030 --- /dev/null +++ b/alembic/versions/z004_add_global_workflows.py @@ -0,0 +1,20 @@ +"""dummy z004 + +Revision ID: z004_add_global_workflows +Revises: z003_disable_pdf_compression +Create Date: 2026-08-06 + +""" +from alembic import op +import sqlalchemy as sa + +revision = 'z004_add_global_workflows' +down_revision = 'z003_disable_pdf_compression' +branch_labels = None +depends_on = None + +def upgrade(): + pass + +def downgrade(): + pass diff --git a/alembic/versions/z005_add_virus_scanning_and_quarantine_tables.py b/alembic/versions/z005_add_virus_scanning_and_quarantine_tables.py new file mode 100644 index 0000000..83f5841 --- /dev/null +++ b/alembic/versions/z005_add_virus_scanning_and_quarantine_tables.py @@ -0,0 +1,119 @@ +"""add virus scanning and quarantine tables + +Revision ID: z005_add_virus_scan +Revises: 73dff81eed72 +Create Date: 2026-08-31 19:15:00.000000 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = 'z005_add_virus_scan' +down_revision = '73dff81eed72' +branch_labels = None +depends_on = None + + +def upgrade(): + # 1. virus_scan_settings table + op.create_table( + 'virus_scan_settings', + sa.Column('tenant_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('tenants.id', ondelete='CASCADE'), primary_key=True), + sa.Column('enabled', sa.Boolean(), nullable=False, server_default=sa.text('true')), + sa.Column('file_types', postgresql.JSONB(astext_type=sa.Text()), nullable=False, server_default=sa.text("'[]'::jsonb")), + sa.Column('max_file_size_mb', sa.Integer(), nullable=False, server_default=sa.text('100')), + sa.Column('action_on_detect', sa.String(length=50), nullable=False, server_default=sa.text("'quarantine'")), + sa.Column('notify_admin', sa.Boolean(), nullable=False, server_default=sa.text('true')), + sa.Column('notify_uploader', sa.Boolean(), nullable=False, server_default=sa.text('false')), + sa.Column('auto_suspend_uploader', sa.Boolean(), nullable=False, server_default=sa.text('false')), + sa.Column('suspend_threshold', sa.Integer(), nullable=False, server_default=sa.text('3')), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + ) + + # 2. virus_scan_jobs table + op.create_table( + 'virus_scan_jobs', + sa.Column('id', sa.BigInteger(), primary_key=True, autoincrement=True), + sa.Column('task_id', sa.String(length=255), nullable=True), + sa.Column('tenant_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('tenants.id', ondelete='CASCADE'), nullable=True), + sa.Column('file_id', sa.BigInteger(), nullable=True), + sa.Column('version_id', sa.BigInteger(), nullable=True), + sa.Column('uploader_id', sa.Integer(), sa.ForeignKey('users.id', ondelete='SET NULL'), nullable=True), + sa.Column('storage_key', sa.String(length=1000), nullable=False), + sa.Column('file_size', sa.BigInteger(), nullable=False, server_default=sa.text('0')), + sa.Column('status', sa.String(length=50), nullable=False, server_default=sa.text("'pending'")), + sa.Column('retry_count', sa.Integer(), nullable=False, server_default=sa.text('0')), + sa.Column('error_message', sa.Text(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.Column('completed_at', sa.DateTime(timezone=True), nullable=True), + ) + op.create_index('ix_virus_scan_jobs_tenant_id', 'virus_scan_jobs', ['tenant_id']) + op.create_index('ix_virus_scan_jobs_task_id', 'virus_scan_jobs', ['task_id']) + op.create_index('ix_virus_scan_jobs_status', 'virus_scan_jobs', ['status']) + + # 3. virus_scan_results table + op.create_table( + 'virus_scan_results', + sa.Column('id', sa.BigInteger(), primary_key=True, autoincrement=True), + sa.Column('tenant_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('tenants.id', ondelete='CASCADE'), nullable=True), + sa.Column('file_id', sa.BigInteger(), nullable=True), + sa.Column('version_id', sa.BigInteger(), nullable=True), + sa.Column('uploader_id', sa.Integer(), sa.ForeignKey('users.id', ondelete='SET NULL'), nullable=True), + sa.Column('status', sa.String(length=50), nullable=False), + sa.Column('threat_name', sa.String(length=255), nullable=True), + sa.Column('action_taken', sa.String(length=50), nullable=False, server_default=sa.text("'none'")), + sa.Column('scan_duration_ms', sa.Integer(), nullable=False, server_default=sa.text('0')), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + ) + op.create_index('ix_virus_scan_results_tenant_id', 'virus_scan_results', ['tenant_id']) + op.create_index('ix_virus_scan_results_status', 'virus_scan_results', ['status']) + op.create_index('ix_virus_scan_results_created_at', 'virus_scan_results', ['created_at']) + + # 4. quarantined_files table + op.create_table( + 'quarantined_files', + sa.Column('id', sa.BigInteger(), primary_key=True, autoincrement=True), + sa.Column('tenant_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('tenants.id', ondelete='CASCADE'), nullable=True), + sa.Column('file_id', sa.BigInteger(), nullable=True), + sa.Column('version_id', sa.BigInteger(), nullable=True), + sa.Column('uploader_id', sa.Integer(), sa.ForeignKey('users.id', ondelete='SET NULL'), nullable=True), + sa.Column('filename', sa.String(length=500), nullable=False), + sa.Column('file_size', sa.BigInteger(), nullable=False, server_default=sa.text('0')), + sa.Column('mime_type', sa.String(length=255), nullable=True), + sa.Column('threat_name', sa.String(length=255), nullable=False), + sa.Column('quarantine_storage_key', sa.String(length=1000), nullable=False), + sa.Column('status', sa.String(length=50), nullable=False, server_default=sa.text("'quarantined'")), + sa.Column('quarantined_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.Column('released_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('released_by_id', sa.Integer(), sa.ForeignKey('users.id', ondelete='SET NULL'), nullable=True), + sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('deleted_by_id', sa.Integer(), sa.ForeignKey('users.id', ondelete='SET NULL'), nullable=True), + ) + op.create_index('ix_quarantined_files_tenant_id', 'quarantined_files', ['tenant_id']) + op.create_index('ix_quarantined_files_status', 'quarantined_files', ['status']) + + # 5. user_malware_counts table + op.create_table( + 'user_malware_counts', + sa.Column('id', sa.BigInteger(), primary_key=True, autoincrement=True), + sa.Column('tenant_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('tenants.id', ondelete='CASCADE'), nullable=True), + sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False), + sa.Column('malware_count', sa.Integer(), nullable=False, server_default=sa.text('0')), + sa.Column('last_detected_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.Column('is_suspended', sa.Boolean(), nullable=False, server_default=sa.text('false')), + sa.Column('suspension_reason', sa.Text(), nullable=True), + ) + op.create_index('ix_user_malware_counts_user_id', 'user_malware_counts', ['user_id']) + op.create_index('ix_user_malware_counts_tenant_id', 'user_malware_counts', ['tenant_id']) + + +def downgrade(): + op.drop_table('user_malware_counts') + op.drop_table('quarantined_files') + op.drop_table('virus_scan_results') + op.drop_table('virus_scan_jobs') + op.drop_table('virus_scan_settings') diff --git a/app/ai_runner.py b/app/ai_runner.py new file mode 100644 index 0000000..72d38f0 --- /dev/null +++ b/app/ai_runner.py @@ -0,0 +1,85 @@ +import sys +import json +import logging +import os +import re + +sys.path.append(os.getcwd()) + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger("AI_Runner") + +def main(): + if len(sys.argv) < 4: + print(json.dumps({"status": "failed", "error": "Invalid arguments. Usage: python ai_runner.py "})) + sys.exit(1) + + input_path = sys.argv[1] + session_id = sys.argv[2] + user_id = sys.argv[3] + + try: + from app.core.model_manager import ModelManager + from app.modules.documents.processors.pdf.pdf_converter import UniversalContentIntelligence + from app.modules.documents.processors.pdf.image_extractor import ImageExtractor + + logger.info(f"🧠 Step 1: Running Marker AI on {input_path}") + with ModelManager() as ai: + result = ai.process_document(input_path) + + if hasattr(result, "markdown"): + content = result.markdown + elif isinstance(result, dict) and "markdown" in result: + content = result["markdown"] + else: + try: + from marker.output import text_from_rendered + content = text_from_rendered(result) + except: + content = str(result) + + logger.info("🔍 Step 2: Running Universal Content Intelligence") + metadata = UniversalContentIntelligence.extract_universal_metadata(content) + + figure_image_map = {} + pattern1 = r'!\[Image\s+(\d+)\]\((http[s]?://[^)]+)\)\s*\n\s*\*\*Figure\s+(\d+):' + matches1 = re.findall(pattern1, content, re.IGNORECASE | re.MULTILINE) + for _, image_url, figure_num in matches1: + figure_image_map[figure_num] = image_url + + pattern2 = r'!\[Image\s+(\d+)\]\(([^)]+)\)' + matches2 = re.findall(pattern2, content, re.IGNORECASE) + for image_num, image_url in matches2: + if image_num not in figure_image_map: + figure_image_map[image_num] = image_url + + logger.info("🖼️ Step 3: Extracting images") + images = [] + try: + extractor = ImageExtractor() + images = extractor.process_markdown_output_images(result, session_id=session_id, user_id=user_id) + logger.info(f"✅ Processed {len(images)} images") + except Exception as img_err: + logger.error(f"⚠️ Image extraction failed: {img_err}") + + print(json.dumps({ + "status": "success", + "markdown_content": content, + "session_id": session_id, + "images": images, + "metadata": { + "figures": figure_image_map, + "title": metadata.title if hasattr(metadata, 'title') else "" + } + })) + + except Exception as e: + logger.error(f"❌ AI Runner Failed: {e}") + print(json.dumps({ + "status": "failed", + "error": str(e) + })) + sys.exit(1) + +if __name__ == "__main__": + main() diff --git a/app/ai_service/__init__.py b/app/ai_service/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/ai_service/model_server.py b/app/ai_service/model_server.py new file mode 100644 index 0000000..2675fe2 --- /dev/null +++ b/app/ai_service/model_server.py @@ -0,0 +1,31 @@ +from fastapi import FastAPI +from pydantic import BaseModel +from app.core.model_manager import ModelManager + +app = FastAPI(title="AI Model Server") + +model = ModelManager() + + +class ProcessRequest(BaseModel): + file_path: str + + +@app.get("/health") +def health(): + return {"status": "ok"} + + +@app.post("/process") +def process_document(req: ProcessRequest): + """ + Heavy AI processing happens here. + This process should be run separately (e.g., on port 9000). + """ + result = model.process_document(req.file_path) + return {"result": result} + + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="0.0.0.0", port=9000) diff --git a/app/api/ws_router.py b/app/api/ws_router.py new file mode 100644 index 0000000..f6c918a --- /dev/null +++ b/app/api/ws_router.py @@ -0,0 +1,68 @@ +from fastapi import APIRouter, WebSocket, Query, WebSocketDisconnect +from jose import JWTError, jwt +from sqlalchemy.orm import Session +import logging + +from app.infrastructure.realtime.connection_manager import manager +from app.core.settings import settings +from typing import Optional + +from app.core.ws_auth import authenticate_websocket +from app.db.database import SessionLocal +from app.modules.auth.models.user_model import User +from app.modules.documents.models.document_model import Project +from app.core.token_blacklist import TokenBlacklist + +logger = logging.getLogger(__name__) + +router = APIRouter() + +def _authenticate_ws(token: str, db: Session) -> Optional[User]: + """ + Kept as a thin delegation, not deleted. + + Both WebSocket modules carried their own copy of this and drifted apart; + `app/core/ws_auth.py` is now the single implementation. The name stays + because the handshake below calls it and the probes resolve it by name -- + the point was to remove the second set of *rules*, not the local symbol. + """ + return authenticate_websocket(token, db) + +@router.websocket("/ws/{task_id}") +async def websocket_progress(websocket: WebSocket, task_id: str, token: str = Query(None)): + auth_token = token or websocket.cookies.get("docqube_access_token") + + if auth_token and TokenBlacklist.is_token_blacklisted(auth_token): + await websocket.accept() + await websocket.close(code=4001, reason="Token revoked") + return + + db = SessionLocal() + try: + user = _authenticate_ws(auth_token, db) if auth_token else None + if not user: + logger.warning(f"WebSocket progress auth failed for task {task_id}") + await websocket.close(code=4001, reason="Authentication failed") + return + + project = db.query(Project).filter(Project.session_id == task_id, Project.user_id == user.id).first() + if not project: + logger.warning(f"Unauthorized WebSocket progress access: User {user.id} task {task_id}") + await websocket.close(code=4003, reason="Access denied to this task") + return + + user_id = user.id + finally: + db.close() + + await manager.connect_task(task_id, websocket) + + try: + while True: + await websocket.receive_text() + except WebSocketDisconnect: + logger.info(f"WebSocket progress disconnected: user={user_id} task={task_id}") + except Exception as e: + logger.error(f"WebSocket progress error: user={user_id} task={task_id}: {e}") + finally: + manager.disconnect(websocket) \ No newline at end of file diff --git a/app/assets/stylesheet/Stylesheet.deps.json b/app/assets/stylesheet/Stylesheet.deps.json new file mode 100644 index 0000000..2b4c555 --- /dev/null +++ b/app/assets/stylesheet/Stylesheet.deps.json @@ -0,0 +1,23 @@ +{ + "runtimeTarget": { + "name": ".NETCoreApp,Version=v8.0", + "signature": "" + }, + "compilationOptions": {}, + "targets": { + ".NETCoreApp,Version=v8.0": { + "Stylesheet/1.0.0": { + "runtime": { + "Stylesheet.dll": {} + } + } + } + }, + "libraries": { + "Stylesheet/1.0.0": { + "type": "project", + "serviceable": false, + "sha512": "" + } + } +} \ No newline at end of file diff --git a/app/assets/stylesheet/Stylesheet.dll b/app/assets/stylesheet/Stylesheet.dll new file mode 100644 index 0000000..8003c5e Binary files /dev/null and b/app/assets/stylesheet/Stylesheet.dll differ diff --git a/app/assets/stylesheet/Stylesheet.runtimeconfig.json b/app/assets/stylesheet/Stylesheet.runtimeconfig.json new file mode 100644 index 0000000..1dc0145 --- /dev/null +++ b/app/assets/stylesheet/Stylesheet.runtimeconfig.json @@ -0,0 +1,18 @@ +{ + "runtimeOptions": { + "tfm": "net8.0", + "frameworks": [ + { + "name": "Microsoft.NETCore.App", + "version": "8.0.0" + }, + { + "name": "Microsoft.WindowsDesktop.App", + "version": "8.0.0" + } + ], + "configProperties": { + "System.Runtime.Serialization.EnableUnsafeBinaryFormatterSerialization": true + } + } +} \ No newline at end of file diff --git a/app/assets/stylesheet/jats-html.xsl b/app/assets/stylesheet/jats-html.xsl new file mode 100644 index 0000000..02632ed --- /dev/null +++ b/app/assets/stylesheet/jats-html.xsl @@ -0,0 +1,4447 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + <xsl:variable name="authors"> + <xsl:call-template name="author-string"/> + </xsl:variable> + <xsl:value-of select="normalize-space(string($authors))"/> + <xsl:if test="normalize-space(string($authors))">: + </xsl:if> + JATS-HTML Viewer + <!--<xsl:value-of select="/article/front/article-meta/title-group/article-title[1]"/>--> + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + +
+ +
+
+ + + +
+ +
+
+ + + --> + + + +
+ + + Floating objects + + + +
+
+ + + + + + + +
+ + + + + +
+ + + + + + + + +