From e90637c227c5bc7d20825ba4c6f4975fbc9c64cb Mon Sep 17 00:00:00 2001 From: "Pawla Abdul (Bot)" Date: Sat, 4 Apr 2026 16:18:32 +0000 Subject: [PATCH 1/2] fix(api): make alembic migrations idempotent for fresh databases - 001: guard has_table check; skip if session_data already TEXT - 002: guard each ADD COLUMN / CREATE TABLE; guard password migration - 003: guard has_table; guard nullable check - 004: guard has_table; skip if users.id already TEXT - env.py: add Base.metadata.create_all after run_migrations to bootstrap fresh DBs - api/user.py: make hashed_password nullable; add email_verified, image, email_inbound_token fields Co-Authored-By: Paperclip --- api/Dockerfile | 4 +- api/alembic/env.py | 6 +- .../versions/001_encrypt_session_data.py | 16 +- .../versions/002_better_auth_tables.py | 145 ++++++++++-------- ...003_make_users_hashed_password_nullable.py | 21 ++- api/alembic/versions/004_fix_user_id_text.py | 16 +- api/src/cartsnitch_api/models/user.py | 12 +- 7 files changed, 147 insertions(+), 73 deletions(-) diff --git a/api/Dockerfile b/api/Dockerfile index 23166b8..e3b4bbf 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -12,6 +12,8 @@ RUN pip install --no-cache-dir --prefix=/install . FROM python:3.12-slim AS prod +RUN apt-get update && apt-get install -y --no-install-recommends libpq5 && rm -rf /var/lib/apt/lists/* + WORKDIR /app RUN adduser --system --group --uid 1000 app COPY --from=build /install /usr/local @@ -25,4 +27,4 @@ EXPOSE 8000 HEALTHCHECK --interval=30s --timeout=3s \ CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" -CMD ["uvicorn", "cartsnitch_api.main:app", "--host", "0.0.0.0", "--port", "8000"] +CMD ["sh", "-c", "python -m alembic upgrade head && uvicorn cartsnitch_api.main:app --host 0.0.0.0 --port 8000"] diff --git a/api/alembic/env.py b/api/alembic/env.py index 3e563e1..5104dca 100644 --- a/api/alembic/env.py +++ b/api/alembic/env.py @@ -6,7 +6,7 @@ from logging.config import fileConfig from sqlalchemy import engine_from_config, pool from alembic import context -from cartsnitch_api.models import Base # noqa: F401 — imports all models for autogenerate +from cartsnitch_api.models.base import Base # noqa: F401 — imports all models for autogenerate config = context.config if config.config_file_name is not None: @@ -47,6 +47,10 @@ def run_migrations_online() -> None: context.configure(connection=connection, target_metadata=target_metadata) with context.begin_transaction(): context.run_migrations() + # Create any tables defined in models but not yet created by migrations. + # This bootstraps fresh databases that have no legacy schema. + # checkfirst=True ensures this is a no-op on existing databases. + Base.metadata.create_all(bind=connection, checkfirst=True) if context.is_offline_mode(): diff --git a/api/alembic/versions/001_encrypt_session_data.py b/api/alembic/versions/001_encrypt_session_data.py index 4932231..20c70ac 100644 --- a/api/alembic/versions/001_encrypt_session_data.py +++ b/api/alembic/versions/001_encrypt_session_data.py @@ -33,6 +33,21 @@ def _is_fernet_token(value: str) -> bool: def upgrade() -> None: + conn = op.get_bind() + inspector = sa.inspect(conn) + + # Fresh DB — table created by Base.metadata.create_all with correct TEXT type + if not inspector.has_table("user_store_accounts"): + return + + # Already migrated? Skip if session_data is already TEXT (not JSON) + cols = {c["name"]: c for c in inspector.get_columns("user_store_accounts")} + if "session_data" not in cols: + return + col_type = str(cols["session_data"]["type"]).lower() + if "text" in col_type and "json" not in col_type: + return # already TEXT — nothing to do + # Change column type from JSON to TEXT to hold Fernet ciphertext op.alter_column( "user_store_accounts", @@ -43,7 +58,6 @@ def upgrade() -> None: postgresql_using="session_data::text", ) - conn = op.get_bind() rows = conn.execute( text("SELECT id, session_data FROM user_store_accounts WHERE session_data IS NOT NULL") ).fetchall() diff --git a/api/alembic/versions/002_better_auth_tables.py b/api/alembic/versions/002_better_auth_tables.py index aa5dd93..169ed38 100644 --- a/api/alembic/versions/002_better_auth_tables.py +++ b/api/alembic/versions/002_better_auth_tables.py @@ -21,81 +21,96 @@ depends_on = None def upgrade() -> None: + conn = op.get_bind() + inspector = sa.inspect(conn) + # --- Extend users table for Better-Auth compatibility --- - op.add_column("users", sa.Column("email_verified", sa.Boolean(), nullable=False, server_default="false")) - op.add_column("users", sa.Column("image", sa.Text(), nullable=True)) + existing_user_cols = ( + [c["name"] for c in inspector.get_columns("users")] + if inspector.has_table("users") + else [] + ) + + if "email_verified" not in existing_user_cols: + op.add_column("users", sa.Column("email_verified", sa.Boolean(), nullable=False, server_default="false")) + if "image" not in existing_user_cols: + op.add_column("users", sa.Column("image", sa.Text(), nullable=True)) # --- Create sessions table --- - op.create_table( - "sessions", - sa.Column("id", sa.Text(), nullable=False), - sa.Column("token", sa.Text(), nullable=False), - sa.Column("user_id", sa.Text(), nullable=False), - sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), - sa.Column("ip_address", sa.Text(), nullable=True), - sa.Column("user_agent", 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.PrimaryKeyConstraint("id"), - ) - op.create_index("ix_sessions_token", "sessions", ["token"], unique=True) - op.create_index("ix_sessions_user_id", "sessions", ["user_id"]) + if not inspector.has_table("sessions"): + op.create_table( + "sessions", + sa.Column("id", sa.Text(), nullable=False), + sa.Column("token", sa.Text(), nullable=False), + sa.Column("user_id", sa.Text(), nullable=False), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("ip_address", sa.Text(), nullable=True), + sa.Column("user_agent", 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.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_sessions_token", "sessions", ["token"], unique=True) + op.create_index("ix_sessions_user_id", "sessions", ["user_id"]) # --- Create accounts table --- - op.create_table( - "accounts", - sa.Column("id", sa.Text(), nullable=False), - sa.Column("user_id", sa.Text(), nullable=False), - sa.Column("account_id", sa.Text(), nullable=False), - sa.Column("provider_id", sa.Text(), nullable=False), - sa.Column("access_token", sa.Text(), nullable=True), - sa.Column("refresh_token", sa.Text(), nullable=True), - sa.Column("access_token_expires_at", sa.DateTime(timezone=True), nullable=True), - sa.Column("refresh_token_expires_at", sa.DateTime(timezone=True), nullable=True), - sa.Column("scope", sa.Text(), nullable=True), - sa.Column("id_token", sa.Text(), nullable=True), - sa.Column("password", 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.PrimaryKeyConstraint("id"), - ) - op.create_index("ix_accounts_user_id", "accounts", ["user_id"]) + if not inspector.has_table("accounts"): + op.create_table( + "accounts", + sa.Column("id", sa.Text(), nullable=False), + sa.Column("user_id", sa.Text(), nullable=False), + sa.Column("account_id", sa.Text(), nullable=False), + sa.Column("provider_id", sa.Text(), nullable=False), + sa.Column("access_token", sa.Text(), nullable=True), + sa.Column("refresh_token", sa.Text(), nullable=True), + sa.Column("access_token_expires_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("refresh_token_expires_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("scope", sa.Text(), nullable=True), + sa.Column("id_token", sa.Text(), nullable=True), + sa.Column("password", 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.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_accounts_user_id", "accounts", ["user_id"]) # --- Create verifications table --- - op.create_table( - "verifications", - sa.Column("id", sa.Text(), nullable=False), - sa.Column("identifier", sa.Text(), nullable=False), - sa.Column("value", sa.Text(), nullable=False), - sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), - 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.PrimaryKeyConstraint("id"), - ) + if not inspector.has_table("verifications"): + op.create_table( + "verifications", + sa.Column("id", sa.Text(), nullable=False), + sa.Column("identifier", sa.Text(), nullable=False), + sa.Column("value", sa.Text(), nullable=False), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), + 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.PrimaryKeyConstraint("id"), + ) # --- Migrate existing password hashes to accounts table --- - # For each user with a hashed_password, create a 'credential' account row - conn = op.get_bind() - users = conn.execute( - text("SELECT id, hashed_password FROM users WHERE hashed_password IS NOT NULL") - ).fetchall() + # Only run on existing (non-fresh) DBs that already have users table with data + if inspector.has_table("users"): + users = conn.execute( + text("SELECT id, hashed_password FROM users WHERE hashed_password IS NOT NULL") + ).fetchall() - for user_id, hashed_password in users: - user_id_str = str(user_id) - conn.execute( - text( - "INSERT INTO accounts (id, user_id, account_id, provider_id, password, created_at, updated_at) " - "VALUES (gen_random_uuid()::text, :user_id, :account_id, 'credential', :password, now(), now())" - ), - {"user_id": user_id_str, "account_id": user_id_str, "password": hashed_password}, - ) + for user_id, hashed_password in users: + user_id_str = str(user_id) + conn.execute( + text( + "INSERT INTO accounts (id, user_id, account_id, provider_id, password, created_at, updated_at) " + "VALUES (gen_random_uuid()::text, :user_id, :account_id, 'credential', :password, now(), now())" + ), + {"user_id": user_id_str, "account_id": user_id_str, "password": hashed_password}, + ) def downgrade() -> None: - op.drop_table("verifications") - op.drop_table("accounts") - op.drop_index("ix_sessions_user_id", table_name="sessions") - op.drop_index("ix_sessions_token", table_name="sessions") - op.drop_table("sessions") - op.drop_column("users", "image") - op.drop_column("users", "email_verified") + op.execute(text("DROP INDEX IF EXISTS ix_accounts_user_id")) + op.execute(text("DROP TABLE IF EXISTS verifications")) + op.execute(text("DROP TABLE IF EXISTS accounts")) + op.execute(text("DROP INDEX IF EXISTS ix_sessions_user_id")) + op.execute(text("DROP INDEX IF EXISTS ix_sessions_token")) + op.execute(text("DROP TABLE IF EXISTS sessions")) + op.execute(text("ALTER TABLE users DROP COLUMN IF EXISTS image")) + op.execute(text("ALTER TABLE users DROP COLUMN IF EXISTS email_verified")) diff --git a/api/alembic/versions/003_make_users_hashed_password_nullable.py b/api/alembic/versions/003_make_users_hashed_password_nullable.py index 8aec2bc..573b0ad 100644 --- a/api/alembic/versions/003_make_users_hashed_password_nullable.py +++ b/api/alembic/versions/003_make_users_hashed_password_nullable.py @@ -19,8 +19,25 @@ depends_on = None def upgrade() -> None: - op.alter_column("users", "hashed_password", existing_type=sa.String(255), nullable=True) + conn = op.get_bind() + inspector = sa.inspect(conn) + + # Fresh DB — nothing to alter + if not inspector.has_table("users"): + return + + cols = {c["name"]: c for c in inspector.get_columns("users")} + if "hashed_password" in cols and not cols["hashed_password"]["nullable"]: + op.alter_column("users", "hashed_password", existing_type=sa.String(255), nullable=True) def downgrade() -> None: - op.alter_column("users", "hashed_password", existing_type=sa.String(255), nullable=False) + conn = op.get_bind() + inspector = sa.inspect(conn) + + if not inspector.has_table("users"): + return + + cols = {c["name"]: c for c in inspector.get_columns("users")} + if "hashed_password" in cols and cols["hashed_password"]["nullable"]: + op.alter_column("users", "hashed_password", existing_type=sa.String(255), nullable=False) diff --git a/api/alembic/versions/004_fix_user_id_text.py b/api/alembic/versions/004_fix_user_id_text.py index a52bf9d..648333c 100644 --- a/api/alembic/versions/004_fix_user_id_text.py +++ b/api/alembic/versions/004_fix_user_id_text.py @@ -25,7 +25,21 @@ depends_on = None def upgrade() -> None: - # Step 1: Drop existing FK constraints + conn = op.get_bind() + inspector = sa.inspect(conn) + + # Fresh DB — no tables yet, nothing to convert + if not inspector.has_table("users"): + return + + # Check if already TEXT (Base.metadata.create_all uses TEXT for fresh DB) + users_cols = {c["name"]: c for c in inspector.get_columns("users")} + if "id" in users_cols: + id_type = str(users_cols["id"]["type"]).lower() + if "text" in id_type and "uuid" not in id_type: + return # already TEXT — nothing to do + + # Step 1: Drop existing FK constraints (ignore if they don't exist) op.execute(text("ALTER TABLE user_store_accounts DROP CONSTRAINT IF EXISTS user_store_accounts_user_id_fkey")) op.execute(text("ALTER TABLE purchases DROP CONSTRAINT IF EXISTS purchases_user_id_fkey")) diff --git a/api/src/cartsnitch_api/models/user.py b/api/src/cartsnitch_api/models/user.py index 89390a3..9cbd4e8 100644 --- a/api/src/cartsnitch_api/models/user.py +++ b/api/src/cartsnitch_api/models/user.py @@ -4,7 +4,8 @@ import secrets from datetime import datetime from typing import TYPE_CHECKING -from sqlalchemy import DateTime, ForeignKey, String, Text, UniqueConstraint +import sqlalchemy as sa +from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text, UniqueConstraint from sqlalchemy.orm import Mapped, mapped_column, relationship from cartsnitch_api.constants import AccountStatus @@ -23,13 +24,20 @@ class User(TimestampMixin, Base): id: Mapped[str] = mapped_column(Text, primary_key=True) email: Mapped[str] = mapped_column(String(255), nullable=False, unique=True) - hashed_password: Mapped[str] = mapped_column(String(255), nullable=False) + hashed_password: Mapped[str | None] = mapped_column(String(255), nullable=True) display_name: Mapped[str | None] = mapped_column(String(100)) + email_verified: Mapped[bool] = mapped_column( + Boolean, nullable=False, server_default="false" + ) + image: Mapped[str | None] = mapped_column(Text, nullable=True) email_inbound_token: Mapped[str] = mapped_column( String(22), nullable=False, unique=True, default=lambda: secrets.token_urlsafe(16), + server_default=sa.text( + "replace(replace(trim(trailing '=' from encode(gen_random_bytes(16), 'base64')), '+', '-'), '/', '_')" + ), ) # Relationships From be75c7f254d41fe986794e250f28c08e352fb192 Mon Sep 17 00:00:00 2001 From: "Pawla Abdul (Bot)" Date: Sat, 4 Apr 2026 16:39:27 +0000 Subject: [PATCH 2/2] fix(api): add fresh-DB guards to migrations 002, 005, and 006 - 002: wrap add_column calls in has_table("users") guard - 005: add has_table + column-existence guard before add_column - 006: add has_table + column + default-existence guard before alter_column Co-Authored-By: Paperclip --- api/alembic/versions/002_better_auth_tables.py | 18 ++++++++---------- .../versions/005_add_email_inbound_token.py | 14 +++++++++++--- .../006_email_inbound_token_server_default.py | 10 ++++++++++ 3 files changed, 29 insertions(+), 13 deletions(-) diff --git a/api/alembic/versions/002_better_auth_tables.py b/api/alembic/versions/002_better_auth_tables.py index 169ed38..efa283f 100644 --- a/api/alembic/versions/002_better_auth_tables.py +++ b/api/alembic/versions/002_better_auth_tables.py @@ -25,16 +25,14 @@ def upgrade() -> None: inspector = sa.inspect(conn) # --- Extend users table for Better-Auth compatibility --- - existing_user_cols = ( - [c["name"] for c in inspector.get_columns("users")] - if inspector.has_table("users") - else [] - ) - - if "email_verified" not in existing_user_cols: - op.add_column("users", sa.Column("email_verified", sa.Boolean(), nullable=False, server_default="false")) - if "image" not in existing_user_cols: - op.add_column("users", sa.Column("image", sa.Text(), nullable=True)) + # Guard: on a fresh DB Base.metadata.create_all (called in env.py after migrations) + # creates the users table with all columns, so migration 002 must not re-run add_column. + if inspector.has_table("users"): + existing_user_cols = [c["name"] for c in inspector.get_columns("users")] + if "email_verified" not in existing_user_cols: + op.add_column("users", sa.Column("email_verified", sa.Boolean(), nullable=False, server_default="false")) + if "image" not in existing_user_cols: + op.add_column("users", sa.Column("image", sa.Text(), nullable=True)) # --- Create sessions table --- if not inspector.has_table("sessions"): diff --git a/api/alembic/versions/005_add_email_inbound_token.py b/api/alembic/versions/005_add_email_inbound_token.py index 4fb7c2c..c5cc2a9 100644 --- a/api/alembic/versions/005_add_email_inbound_token.py +++ b/api/alembic/versions/005_add_email_inbound_token.py @@ -18,6 +18,15 @@ depends_on = None def upgrade() -> None: + conn = op.get_bind() + inspector = sa.inspect(conn) + # Guard: on a fresh DB Base.metadata.create_all creates users table with the column already present + if not inspector.has_table("users"): + return + existing_cols = [c["name"] for c in inspector.get_columns("users")] + if "email_inbound_token" in existing_cols: + return + # Add column nullable first so existing rows can be backfilled op.add_column( "users", @@ -25,11 +34,10 @@ def upgrade() -> None: ) # Backfill existing users with unique tokens - connection = op.get_bind() - result = connection.execute(sa.text("SELECT id FROM users WHERE email_inbound_token IS NULL")) + result = conn.execute(sa.text("SELECT id FROM users WHERE email_inbound_token IS NULL")) for (user_id,) in result: token = secrets.token_urlsafe(16) - connection.execute( + conn.execute( sa.text("UPDATE users SET email_inbound_token = :token WHERE id = :id"), {"token": token, "id": user_id}, ) diff --git a/api/alembic/versions/006_email_inbound_token_server_default.py b/api/alembic/versions/006_email_inbound_token_server_default.py index ac1c678..e090016 100644 --- a/api/alembic/versions/006_email_inbound_token_server_default.py +++ b/api/alembic/versions/006_email_inbound_token_server_default.py @@ -15,6 +15,16 @@ depends_on = None def upgrade() -> None: + conn = op.get_bind() + inspector = sa.inspect(conn) + # Guard: on a fresh DB Base.metadata.create_all already sets the server_default + if not inspector.has_table("users"): + return + cols = {c["name"]: c for c in inspector.get_columns("users")} + if "email_inbound_token" not in cols: + return + if cols["email_inbound_token"].get("default") is not None: + return op.alter_column( "users", "email_inbound_token",