latest code

This commit is contained in:
2026-09-08 11:00:05 +05:30
commit b14c6b508a
418 changed files with 86016 additions and 0 deletions
+54
View File
@@ -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
+68
View File
@@ -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
+132
View File
@@ -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
+97
View File
@@ -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"
+68
View File
@@ -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
+68
View File
@@ -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
+79
View File
@@ -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/
+58
View File
@@ -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.
+1
View File
@@ -0,0 +1 @@
3.11.8
+80
View File
@@ -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"]
+426
View File
@@ -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 <repository-url>
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 <package>
```
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 <package>
uv sync
```
### Upgrade dependencies
**Single package:**
```bash
uv lock --upgrade-package <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 <package>` | Add a production dependency |
| `uv add --dev <package>` | Add a development dependency |
| `uv remove <package>` | Remove a dependency |
| `uv lock` | Update the lockfile |
| `uv lock --upgrade-package <pkg>` | Upgrade one package |
| `uv lock --upgrade` | Upgrade all packages |
| `uv tree` | View dependency tree |
| `uv pip show <package>` | 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 <package>
# Remove a package
uv remove <package>
# Upgrade one package
uv lock --upgrade-package <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.*` |
+116
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
Generic single-database configuration.
+43
View File
@@ -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()
+26
View File
@@ -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"}
@@ -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
@@ -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")
@@ -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 ###
@@ -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
@@ -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')
@@ -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')
@@ -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")
@@ -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 ###
@@ -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 ###
@@ -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 ###
@@ -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 ###
@@ -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 ###
@@ -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")
@@ -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 ###
@@ -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 ###
@@ -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 ###
@@ -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 ###
@@ -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 ###
@@ -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')
@@ -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 ###
@@ -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')
@@ -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
@@ -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 ###
@@ -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 ###
@@ -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')
@@ -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')
@@ -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 ###
@@ -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')
@@ -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')
@@ -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 ###
@@ -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 ###
@@ -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 ###
@@ -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')
@@ -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 ###
@@ -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')
@@ -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
@@ -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 ###
@@ -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 ###
@@ -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 ###
@@ -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;")
@@ -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'])
@@ -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 ###
@@ -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')
@@ -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 ###
@@ -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 ###
@@ -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")
@@ -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")
@@ -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")
+65
View File
@@ -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}")
@@ -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)
@@ -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 ###
@@ -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")
@@ -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")
+77
View File
@@ -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")
@@ -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')
+46
View File
@@ -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")
@@ -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")
+161
View File
@@ -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")
@@ -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")
@@ -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
@@ -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')
@@ -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)
@@ -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 ###
@@ -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")
@@ -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",
)
@@ -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')
@@ -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')
@@ -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
@@ -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 ###
@@ -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')
+32
View File
@@ -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")
@@ -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')
@@ -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 ###
@@ -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 ###
@@ -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 ###
@@ -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;")
@@ -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")
@@ -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),
)
@@ -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")
@@ -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")
@@ -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")
@@ -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')
+31
View File
@@ -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
""")
@@ -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",
)
@@ -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,
)

Some files were not shown because too many files have changed in this diff Show More