DealDocumentScreening/migrations/versions/0002_api_keys.py

112 lines
4 KiB
Python

"""api_keys + api_key_requests tables for B2B API (Stage 3).
Revision ID: 0002
Revises: 0001
Create Date: 2026-08-07
Hand-written (docs/ARCHITECTURE.md §7 + docs/TICKETS.md T-E3-001). status/rate columns are
TEXT+CHECK / INTEGER so later migrations remain additive.
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = "0002"
down_revision: str | None = "0001"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
# ── api_keys ─────────────────────────────────────────────────────────────
op.create_table(
"api_keys",
sa.Column(
"id",
postgresql.UUID(as_uuid=True),
primary_key=True,
server_default=sa.text("gen_random_uuid()"),
),
sa.Column(
"user_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("name", sa.Text, nullable=False),
sa.Column("key_hash", sa.Text, nullable=False),
sa.Column("rate_limit_rps", sa.Integer, nullable=False, server_default=sa.text("3")),
sa.Column("monthly_quota", sa.Integer, nullable=True),
sa.Column("monthly_used", sa.Integer, nullable=False, server_default=sa.text("0")),
sa.Column(
"resets_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("(now() + interval '1 month')"),
),
sa.Column("revoked", sa.Boolean, nullable=False, server_default=sa.text("false")),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.func.now(),
),
sa.Column("last_used_at", sa.DateTime(timezone=True)),
sa.CheckConstraint("rate_limit_rps > 0", name="api_keys_rate_limit_positive"),
sa.CheckConstraint("monthly_used >= 0", name="api_keys_monthly_used_nonneg"),
sa.CheckConstraint(
"monthly_quota IS NULL OR monthly_quota >= 0",
name="api_keys_monthly_quota_nonneg",
),
sa.UniqueConstraint("user_id", "name", name="api_keys_user_name_unique"),
)
op.create_index("api_keys_user_idx", "api_keys", ["user_id"])
op.create_index("api_keys_hash_idx", "api_keys", ["key_hash"])
# ── api_key_requests ─────────────────────────────────────────────────────
op.create_table(
"api_key_requests",
sa.Column(
"id",
postgresql.UUID(as_uuid=True),
primary_key=True,
server_default=sa.text("gen_random_uuid()"),
),
sa.Column(
"api_key_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("api_keys.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"document_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("documents.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.func.now(),
),
)
op.create_index(
"api_key_requests_key_created_idx",
"api_key_requests",
["api_key_id", sa.text("created_at DESC")],
)
def downgrade() -> None:
op.drop_index("api_key_requests_key_created_idx", table_name="api_key_requests")
op.drop_table("api_key_requests")
op.drop_index("api_keys_hash_idx", table_name="api_keys")
op.drop_index("api_keys_user_idx", table_name="api_keys")
op.drop_table("api_keys")