diff --git a/backend/MIGRATION_AUDIT_REPORT.md b/backend/MIGRATION_AUDIT_REPORT.md new file mode 100644 index 0000000..6f63e76 --- /dev/null +++ b/backend/MIGRATION_AUDIT_REPORT.md @@ -0,0 +1,287 @@ +# Database Migration Audit Report +**Project:** mtgonline backend +**Date:** 2026-07-23 +**Migrations Reviewed:** 5 files in `alembic/versions/` + +--- + +## Migration Chain + +| # | File | Revision | Down Revision | Tables Created | +|---|------|----------|---------------|----------------| +| 0 | `000_base_tables.py` | `000` | `None` | `mtgonline_users`, `mtgonline_decklist_files`, `mtgonline_rooms` | +| 1 | `001_initial_user_schema.py` | `001` | `000` | 15 user data tables + re-creates 3 base tables | +| 2 | `002_user_deck_building_tables.py` | `002` | `001` | `user_decks`, `user_deck_cards`, `deck_precedents`, `deck_precedent_cards`, `card_suggestions` | +| 3 | `003_mtgonline_cards_table.py` | `003` | `002` | `mtgonline_cards` | +| 4 | `004_card_import_table.py` | `004` | `003` | `user_card_imports` | + +--- + +## Migration-by-Migration Status + +### Migration 000: `000_base_tables.py` — ⚠️ PASS (with warnings) + +**Status:** PASS +**Issues:** None critical. + +| Check | Result | +|-------|--------| +| `upgrade()` exists | ✅ | +| `downgrade()` exists | ✅ | +| `mtgonline_users` columns correct | ✅ 20 columns, proper PK, indexes, unique constraints | +| `mtgonline_decklist_files` columns correct | ✅ 11 columns, FK to users, indexes | +| `mtgonline_rooms` columns correct | ✅ 12 columns, FK to users, indexes | +| Foreign keys valid | ✅ All reference `mtgonline_users.id` | +| Indexes created | ✅ `idx_decklist_files_user`, `idx_decklist_files_name`, `idx_rooms_created_by`, `idx_rooms_name` | +| Unique constraints | ✅ `username`, `email` on users | +| Downgrade order correct | ✅ Drops dependent tables first | + +--- + +### Migration 001: `001_initial_user_schema.py` — ❌ FAIL (Critical) + +**Status:** FAIL +**Critical Issue:** Re-creates base tables that migration 000 already created. + +#### Critical Issues + +| # | Issue | Severity | Details | +|---|-------|----------|---------| +| 1 | **Duplicate table creation** | 🔴 CRITICAL | The `upgrade()` function re-creates `mtgonline_users`, `mtgonline_decklist_files`, and `mtgonline_rooms` — tables that migration 000 already created. This will cause `sqlalchemy.exc.ProgrammingError: relation "mtgonline_users" already exists` when running `alembic upgrade head`. | +| 2 | **Missing `mtgonline_decklist_folders` table** | 🔴 CRITICAL | `user_decks.folder_id` (migration 002) references `mtgonline_decklist_folders.id`, but this table is **never created in any migration**. It only exists in the ORM model (`models.py`). Migration 002 will fail with a FK error. | +| 3 | **Missing `mtgonline_rooms_gametypes` table** | 🟡 WARNING | `RoomGameType` model references `mtgonline_rooms_gametypes` table, never created in any migration. | +| 4 | **Missing `mtgonline_bans` table** | 🟡 WARNING | `Ban` model references `mtgonline_bans` table, never created in any migration. | +| 5 | **Missing `mtgonline_log` table** | 🟡 WARNING | `GameLog` model references `mtgonline_log` table, never created in any migration. | +| 6 | **Missing `mtgonline_audit` table** | 🟡 WARNING | `AuditLog` model references `mtgonline_audit` table, never created in any migration. | + +#### Table Creation Analysis (001 upgrade) + +All 15 dependent tables are created correctly with valid foreign keys: + +| Table | FK References | Valid? | +|-------|--------------|--------| +| `user_sessions` | `mtgonline_users.id` | ✅ | +| `deck_versions` | `mtgonline_decklist_files.id` | ✅ (if base tables exist) | +| `game_replays` | `mtgonline_rooms.id` | ✅ (if base tables exist) | +| `replay_players` | `game_replays.id`, `mtgonline_users.id`, `mtgonline_decklist_files.id` | ✅ | +| `game_outcomes` | `mtgonline_users.id`, `game_replays.game_uuid` | ✅ | +| `user_statistics` | `mtgonline_users.id` (PK) | ✅ | +| `user_card_collection` | `mtgonline_users.id` | ✅ | +| `card_wishlist` | `mtgonline_users.id` | ✅ | +| `user_groups` | `mtgonline_users.id` | ✅ | +| `group_members` | `user_groups.id`, `mtgonline_users.id` | ✅ | +| `group_chat_messages` | `user_groups.id`, `mtgonline_users.id` | ✅ | +| `user_networks` | `mtgonline_users.id` | ✅ | +| `network_members` | `user_networks.id`, `mtgonline_users.id` | ✅ | +| `user_preferences` | `mtgonline_users.id` (PK) | ✅ | +| `user_activity_log` | `mtgonline_users.id` | ✅ | + +#### Downgrade Analysis + +| Check | Result | +|-------|--------| +| Drop order correct | ✅ (reverse dependency order) | +| Indexes dropped | ⚠️ Not explicitly dropped (but `op.drop_table()` handles this) | +| Base tables dropped | ✅ (at end, after dependents) | + +#### Comment Numbering Issue + +The `upgrade()` function has inconsistent section numbering: +- "0. Base Tables" → "0.1. Rooms Table" → "1. User Sessions" → "2. Deck Versions" → **"4. Game Replays"** (skips 3) + +--- + +### Migration 002: `002_user_deck_building_tables.py` — ❌ FAIL (Critical) + +**Status:** FAIL +**Critical Issue:** References non-existent `mtgonline_decklist_folders` table. + +| Check | Result | +|-------|--------| +| `upgrade()` exists | ✅ | +| `downgrade()` exists | ✅ | +| `user_decks` FK to `mtgonline_decklist_folders.id` | ❌ **Table never created in any migration** | +| `user_deck_cards` FK to `user_decks.id` | ✅ | +| `deck_precedent_cards` FK to `deck_precedents.id` | ✅ | +| `card_suggestions` FK to `user_decks.id` | ✅ | +| Unique constraints | ✅ `uq_deck_card_unique`, `uq_precedent_card_unique`, `uq_suggestion_unique` | +| Indexes created | ✅ | +| Downgrade order correct | ✅ | + +#### Additional Issues + +| # | Issue | Severity | +|---|-------|----------| +| 7 | `user_deck_cards.card_id` has no FK constraint in migration, but model defines `ForeignKey("mtgonline_cards.id")` | 🟡 WARNING | +| 8 | `card_suggestions.source_card_id` has no FK constraint in migration | 🟡 WARNING | +| 9 | `deck_precedent_cards.card_id` has no FK constraint in migration | 🟡 WARNING | +| 10 | `deck_precedents.created_by` has no FK constraint in migration | 🟡 WARNING | + +--- + +### Migration 003: `003_mtgonline_cards_table.py` — ✅ PASS + +**Status:** PASS + +| Check | Result | +|-------|--------| +| `upgrade()` exists | ✅ | +| `downgrade()` exists | ✅ | +| `mtgonline_cards` columns correct | ✅ 22 columns | +| Indexes created | ✅ `idx_mtgonline_cards_name`, `idx_mtgonline_cards_set` | +| Foreign keys | None (standalone table) | +| Unique constraints | None | + +--- + +### Migration 004: `004_card_import_table.py` — ⚠️ PASS (with warnings) + +**Status:** PASS +**Issues:** Minor consistency issues. + +| Check | Result | +|-------|--------| +| `upgrade()` exists | ✅ | +| `downgrade()` exists | ✅ | +| `user_card_imports` columns correct | ✅ 5 columns | +| FK to `mtgonline_users.id` | ✅ | +| Unique constraint `uq_user_card_imports_user_id` | ✅ | +| Index `idx_user_card_imports_user` | ✅ | +| Primary key definition | ⚠️ `sa.Column('id', sa.Integer(), autoincrement=True, nullable=False)` + `sa.PrimaryKeyConstraint('id')` — redundant but functional | + +--- + +## Cross-Reference: Models vs Migrations + +### Tables in Models but NOT in Any Migration (🔴 CRITICAL) + +| Model | Table Name | Referenced By | +|-------|-----------|---------------| +| `DecklistFolder` | `mtgonline_decklist_folders` | `DecklistFile.folder_id`, `UserDeck.folder_id` | +| `RoomGameType` | `mtgonline_rooms_gametypes` | `Room.game_types` | +| `Ban` | `mtgonline_bans` | Admin ban records | +| `GameLog` | `mtgonline_log` | Game chat logs | +| `AuditLog` | `mtgonline_audit` | Admin action audit trail | +| `MtgCardMirror` | `mtg_cards_mirror` | Card mirror for deckbuilding | +| `DeckCardLink` | `deck_card_links` | Junction: decks ↔ mirrored cards | +| `CardImportBatch` | `card_import_batches` | Card import batch tracking | +| `UserCardImportRecord` | `user_card_imports_confirmed` | Confirmed import records | + +**These 9 tables must be created in migrations before any migration that references them can succeed.** + +### Column Type Mismatches (Model vs Migration) + +#### `mtgonline_users` + +| Column | Migration | Model | Match? | +|--------|-----------|-------|--------| +| `username` | `String(50)` | `String(64)` | ❌ | +| `password_hash` | `String(255)` | `String(128)` | ❌ | +| `salt` | `String(32)` | `String(128)` | ❌ | +| `display_name` | `String(100)` | **Not in model** | ⚠️ | +| `avatar_url` | `String(500)` | **Not in model** | ⚠️ | +| `country` | `String(100)` | `String(2)` | ❌ | +| `real_name` | `String(255)` | `String(128)` | ❌ | +| `avatar_bmp` | `LargeBinary()` | `Text` | ❌ | +| `privlevel` | `Integer()` | `String(50)` | ❌ | +| `is_active` | `Boolean()` | `Boolean()` | ✅ | +| `is_banned` | `Boolean()` | `Boolean()` | ✅ | +| `ban_reason` | `Text()` | `Text()` | ✅ | +| `ban_ends` | `DateTime()` | `DateTime()` | ✅ | +| `vip_status` | `Boolean()` | `Integer()` | ❌ | +| `vip_expiry` | `DateTime()` | `DateTime()` | ✅ | +| `creation_date` | `DateTime()` | `DateTime()` | ✅ | +| `last_login` | `DateTime()` | `DateTime()` | ✅ | + +#### `mtgonline_decklist_files` + +| Column | Migration | Model | Match? | +|--------|-----------|-------|--------| +| `user_id` | FK column | `owner_id` | ❌ (different name) | +| `name` | `String(255)` | `String(255)` | ✅ | +| `content` | `Text()` | `Text()` (nullable=True) | ⚠️ | +| `description` | `Text()` | **Not in model** | ⚠️ | +| `format` | `String(50), default='standard'` | `String(50), default='native'` | ❌ | +| `is_favorite` | `Boolean()` | **Not in model** | ⚠️ | +| `import_source` | `String(50)` | **Not in model** | ⚠️ | +| `import_confidence` | `Float()` | **Not in model** | ⚠️ | +| `last_played` | `DateTime()` | **Not in model** | ⚠️ | +| **Missing** | — | `folder_id` | ❌ | +| **Missing** | — | `status` | ❌ | + +#### `mtgonline_rooms` + +| Column | Migration | Model | Match? | +|--------|-----------|-------|--------| +| `name` | `String(100)` | `String(100), unique=True` | ⚠️ (migration missing unique) | +| `description` | `Text()` | `Text()` | ✅ | +| `max_players` | `Integer(), default=8` | **Not in model** | ⚠️ | +| `is_public` | `Boolean()` | **Not in model** | ⚠️ | +| `is_password_protected` | `Boolean()` | `Boolean()` | ✅ | +| `password_hash` | `String(255)` | `String(128)` | ❌ | +| `game_type` | `String(50)` | **Not in model** | ⚠️ | +| `format` | `String(50)` | **Not in model** | ⚠️ | +| `created_by` | `Integer()` | **Not in model** | ⚠️ | + +--- + +## Summary of All Issues + +### 🔴 Critical (Must Fix Before Deployment) + +| # | Issue | Location | Impact | +|---|-------|----------|--------| +| 1 | **Migration 001 re-creates base tables** | `001_initial_user_schema.py:upgrade()` | `alembic upgrade head` will FAIL — tables already exist from migration 000 | +| 2 | **`mtgonline_decklist_folders` never created** | Missing from all migrations | Migration 002 (`user_decks.folder_id`) will FAIL with FK error | +| 3 | **9 model tables have no migration** | `mtgonline_decklist_folders`, `mtgonline_rooms_gametypes`, `mtgonline_bans`, `mtgonline_log`, `mtgonline_audit`, `mtg_cards_mirror`, `deck_card_links`, `card_import_batches`, `user_card_imports_confirmed` | Any code referencing these tables will fail at runtime | + +### 🟡 Warnings (Should Fix) + +| # | Issue | Location | Impact | +|---|-------|----------|--------| +| 4 | Column type mismatches (15+ columns) | Migration 000/001 vs models | Schema drift — DB won't match ORM definitions | +| 5 | Column name mismatch (`user_id` vs `owner_id`) | `mtgonline_decklist_files` | ORM won't map correctly | +| 6 | Missing `unique=True` on `mtgonline_rooms.name` | Migration 000 | Model defines it as unique | +| 7 | Missing FK constraints on junction table columns | Migration 002 | `card_id`, `source_card_id`, `created_by` lack FK references | +| 8 | Inconsistent section numbering in migration 001 | `001_initial_user_schema.py` | Code readability | +| 9 | Redundant PK definition in migration 004 | `004_card_import_table.py` | Works but messy | + +### ℹ️ Informational + +| # | Issue | Location | +|---|-------|----------| +| 10 | Two card table models: `MtgCardMirror` (mtg_cards_mirror) and `MtonlineCard` (mtgonline_cards) | Different tables, different purposes | +| 11 | Migration 001 creates `user_card_collection` without composite unique constraint that model defines | Model has `UniqueConstraint('user_id', 'card_id', 'is_foil', 'is_alt_art')` | +| 12 | `user_card_collection` migration missing composite index `idx_collection_user_card` | Model defines it | + +--- + +## Recommendations + +### Immediate (Blockers) + +1. **Remove duplicate table creation from migration 001** — Delete the `mtgonline_users`, `mtgonline_decklist_files`, and `mtgonline_rooms` `op.create_table()` calls from `001_initial_user_schema.py`. These are already created by migration 000. + +2. **Create migration for `mtgonline_decklist_folders`** — This table is referenced by both `mtgonline_decklist_files.folder_id` (model) and `user_decks.folder_id` (migration 002). Add it before migration 002 runs. + +3. **Create migrations for all missing tables** — At minimum: `mtgonline_rooms_gametypes`, `mtgonline_bans`, `mtgonline_log`, `mtgonline_audit`, `mtg_cards_mirror`, `deck_card_links`, `card_import_batches`, `user_card_imports_confirmed`. + +### Short-term (Consistency) + +4. **Align migration schemas with ORM models** — Fix all column type mismatches and missing columns. The migrations should be the source of truth for the database, and models should match. + +5. **Add missing FK constraints in migration 002** — Add `ForeignKey` to `card_id`, `source_card_id`, and `created_by` columns. + +6. **Add missing unique constraint on `mtgonline_rooms.name`** — Migration 000 should include `unique=True`. + +7. **Add missing constraints to `user_card_collection`** — Migration 001 should include the composite unique constraint and index that the model defines. + +### Long-term (Architecture) + +8. **Decide on migration strategy** — Either: + - (a) Remove migration 001's duplicate base tables and keep migration 000 as the single source of base table creation, OR + - (b) Remove migration 000 entirely and let migration 001 handle all base tables (but this is risky for a production database). + +9. **Add Alembic environment script** — Create `alembic/env.py` with `include_object` filter to auto-detect table creation order and prevent circular dependencies. + +10. **Consider using `op.create_foreign_key()` explicitly** — Some FK definitions in the migrations use inline `ForeignKey()` which is fine, but explicit `op.create_foreign_key()` calls are more readable and Alembic can better track them for downgrade. diff --git a/backend/alembic/versions/000_base_tables.py b/backend/alembic/versions/000_base_tables.py index 627e519..2bf8fa0 100644 --- a/backend/alembic/versions/000_base_tables.py +++ b/backend/alembic/versions/000_base_tables.py @@ -19,75 +19,121 @@ depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: - """Create base tables: users, decklist files, and rooms.""" - - # 1. Users Table + """Create base tables: users, decklists, rooms, and supporting tables.""" + + # 1. Users Table (matches User model) op.create_table( 'mtgonline_users', sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True), - sa.Column('username', sa.String(50), unique=True, nullable=False, index=True), - sa.Column('email', sa.String(255), unique=True, nullable=False, index=True), - sa.Column('password_hash', sa.String(255), nullable=False), - sa.Column('salt', sa.String(32), nullable=True), - sa.Column('display_name', sa.String(100), nullable=True), - sa.Column('avatar_url', sa.String(500), nullable=True), - sa.Column('country', sa.String(100), nullable=True), - sa.Column('real_name', sa.String(255), nullable=True), - sa.Column('avatar_bmp', sa.LargeBinary(), nullable=True), - sa.Column('privlevel', sa.Integer(), default=0), + sa.Column('username', sa.String(64), unique=True, nullable=False, index=True), + sa.Column('password_hash', sa.String(128), nullable=False), + sa.Column('salt', sa.String(128), nullable=False), + sa.Column('email', sa.String(255), nullable=True, index=True), + sa.Column('country', sa.String(2), nullable=True), + sa.Column('real_name', sa.String(128), nullable=True), + sa.Column('avatar_bmp', sa.Text(), nullable=True), + sa.Column('privlevel', sa.String(50), nullable=True, server_default='User'), sa.Column('is_active', sa.Boolean(), default=True), sa.Column('is_banned', sa.Boolean(), default=False), sa.Column('ban_reason', sa.Text(), nullable=True), sa.Column('ban_ends', sa.DateTime(), nullable=True), - sa.Column('vip_status', sa.Boolean(), default=False), + sa.Column('vip_status', sa.Integer(), default=0), sa.Column('vip_expiry', sa.DateTime(), nullable=True), sa.Column('creation_date', sa.DateTime(), server_default=sa.func.now()), sa.Column('last_login', sa.DateTime(), nullable=True), - sa.Column('created_at', sa.DateTime(), server_default=sa.func.now()), - sa.Column('updated_at', sa.DateTime(), server_default=sa.func.now(), onupdate=sa.func.now()), ) - - # 2. Decklist Files Table + + # 2. Decklist Folders Table (matches DecklistFolder model) + op.create_table( + 'mtgonline_decklist_folders', + sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True), + sa.Column('owner_id', sa.Integer(), sa.ForeignKey('mtgonline_users.id'), nullable=False), + sa.Column('name', sa.String(255), nullable=False), + sa.Column('parent_id', sa.Integer(), sa.ForeignKey('mtgonline_decklist_folders.id'), nullable=True), + sa.Column('creation_date', sa.DateTime(), server_default=sa.func.now()), + ) + + # 3. Decklist Files Table (matches DecklistFile model) op.create_table( 'mtgonline_decklist_files', sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True), - sa.Column('user_id', sa.Integer(), sa.ForeignKey('mtgonline_users.id', ondelete='CASCADE'), nullable=False, index=True), + sa.Column('folder_id', sa.Integer(), sa.ForeignKey('mtgonline_decklist_folders.id'), nullable=True), + sa.Column('owner_id', sa.Integer(), sa.ForeignKey('mtgonline_users.id'), nullable=False), sa.Column('name', sa.String(255), nullable=False), - sa.Column('content', sa.Text(), nullable=True), - sa.Column('description', sa.Text(), nullable=True), - sa.Column('format', sa.String(50), server_default='standard'), - sa.Column('is_favorite', sa.Boolean(), default=False), - sa.Column('import_source', sa.String(50), nullable=True), - sa.Column('import_confidence', sa.Float(), nullable=True), - sa.Column('last_played', sa.DateTime(), nullable=True), - sa.Column('created_at', sa.DateTime(), server_default=sa.func.now()), - sa.Column('updated_at', sa.DateTime(), server_default=sa.func.now(), onupdate=sa.func.now()), + sa.Column('content', sa.Text(), nullable=False), + sa.Column('format', sa.String(50), nullable=True, server_default='native'), + sa.Column('status', sa.String(20), nullable=True, server_default='DRAUGHT'), + sa.Column('creation_date', sa.DateTime(), server_default=sa.func.now()), ) - op.create_index('idx_decklist_files_user', 'mtgonline_decklist_files', ['user_id']) - op.create_index('idx_decklist_files_name', 'mtgonline_decklist_files', ['name']) - - # 3. Rooms Table + op.create_index('idx_decks_owner', 'mtgonline_decklist_files', ['owner_id']) + op.create_index('idx_decks_folder', 'mtgonline_decklist_files', ['folder_id']) + + # 4. Rooms Table (matches Room model) op.create_table( 'mtgonline_rooms', sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True), + sa.Column('name', sa.String(100), unique=True, nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('is_password_protected', sa.Boolean(), default=False), + sa.Column('password_hash', sa.String(128), nullable=True), + sa.Column('creation_date', sa.DateTime(), server_default=sa.func.now()), + ) + + # 5. Room Game Types Table (matches RoomGameType model) + op.create_table( + 'mtgonline_rooms_gametypes', + sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True), + sa.Column('room_id', sa.Integer(), sa.ForeignKey('mtgonline_rooms.id'), nullable=False), sa.Column('name', sa.String(100), nullable=False), sa.Column('description', sa.Text(), nullable=True), - sa.Column('max_players', sa.Integer(), default=8), - sa.Column('is_public', sa.Boolean(), default=True), - sa.Column('is_password_protected', sa.Boolean(), default=False), - sa.Column('password_hash', sa.String(255), nullable=True), - sa.Column('game_type', sa.String(50), nullable=True), - sa.Column('format', sa.String(50), nullable=True), - sa.Column('created_by', sa.Integer(), sa.ForeignKey('mtgonline_users.id'), nullable=False), - sa.Column('created_at', sa.DateTime(), server_default=sa.func.now()), - sa.Column('updated_at', sa.DateTime(), server_default=sa.func.now(), onupdate=sa.func.now()), ) - op.create_index('idx_rooms_created_by', 'mtgonline_rooms', ['created_by']) - op.create_index('idx_rooms_name', 'mtgonline_rooms', ['name']) + + # 6. Bans Table (matches Ban model) + op.create_table( + 'mtgonline_bans', + sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True), + sa.Column('user_id', sa.Integer(), sa.ForeignKey('mtgonline_users.id'), nullable=False), + sa.Column('server_id', sa.Integer(), nullable=True), + sa.Column('reason', sa.Text(), nullable=False), + sa.Column('moderators', sa.String(255), nullable=True), + sa.Column('ip_address', sa.String(45), nullable=True), + sa.Column('expiration_time', sa.DateTime(), nullable=True), + sa.Column('active', sa.Boolean(), default=True), + sa.Column('creation_date', sa.DateTime(), server_default=sa.func.now()), + ) + op.create_index('idx_bans_active', 'mtgonline_bans', ['active']) + + # 7. Game Log Table (matches GameLog model) + op.create_table( + 'mtgonline_log', + sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True), + sa.Column('room_id', sa.Integer(), sa.ForeignKey('mtgonline_rooms.id'), nullable=True), + sa.Column('player_id', sa.Integer(), sa.ForeignKey('mtgonline_users.id'), nullable=True), + sa.Column('message', sa.Text(), nullable=False), + sa.Column('timestamp', sa.DateTime(), server_default=sa.func.now()), + ) + op.create_index('idx_log_timestamp', 'mtgonline_log', ['timestamp']) + + # 8. Audit Log Table (matches AuditLog model) + op.create_table( + 'mtgonline_audit', + sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True), + sa.Column('admin_id', sa.Integer(), sa.ForeignKey('mtgonline_users.id'), nullable=False), + sa.Column('action_type', sa.String(50), nullable=False), + sa.Column('target_user_id', sa.Integer(), sa.ForeignKey('mtgonline_users.id'), nullable=True), + sa.Column('details', sa.Text(), nullable=True), + sa.Column('ip_address', sa.String(45), nullable=True), + sa.Column('timestamp', sa.DateTime(), server_default=sa.func.now()), + ) def downgrade() -> None: - """Drop base tables.""" + """Drop base tables in reverse dependency order.""" + op.drop_table('mtgonline_audit') + op.drop_table('mtgonline_log') + op.drop_table('mtgonline_bans') + op.drop_table('mtgonline_rooms_gametypes') op.drop_table('mtgonline_rooms') op.drop_table('mtgonline_decklist_files') + op.drop_table('mtgonline_decklist_folders') op.drop_table('mtgonline_users') diff --git a/backend/alembic/versions/001_initial_user_schema.py b/backend/alembic/versions/001_initial_user_schema.py index cd9b081..477dfe8 100644 --- a/backend/alembic/versions/001_initial_user_schema.py +++ b/backend/alembic/versions/001_initial_user_schema.py @@ -21,69 +21,6 @@ depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: """Create all user data tables.""" - # 0. Base Tables (Users and Decklist Files) - op.create_table( - 'mtgonline_users', - sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True), - sa.Column('username', sa.String(50), unique=True, nullable=False, index=True), - sa.Column('email', sa.String(255), unique=True, nullable=False, index=True), - sa.Column('password_hash', sa.String(255), nullable=False), - sa.Column('salt', sa.String(32), nullable=True), - sa.Column('display_name', sa.String(100), nullable=True), - sa.Column('avatar_url', sa.String(500), nullable=True), - sa.Column('country', sa.String(100), nullable=True), - sa.Column('real_name', sa.String(255), nullable=True), - sa.Column('avatar_bmp', sa.LargeBinary(), nullable=True), - sa.Column('privlevel', sa.Integer(), default=0), - sa.Column('is_active', sa.Boolean(), default=True), - sa.Column('is_banned', sa.Boolean(), default=False), - sa.Column('ban_reason', sa.Text(), nullable=True), - sa.Column('ban_ends', sa.DateTime(), nullable=True), - sa.Column('vip_status', sa.Boolean(), default=False), - sa.Column('vip_expiry', sa.DateTime(), nullable=True), - sa.Column('creation_date', sa.DateTime(), server_default=sa.func.now()), - sa.Column('last_login', sa.DateTime(), nullable=True), - sa.Column('created_at', sa.DateTime(), server_default=sa.func.now()), - sa.Column('updated_at', sa.DateTime(), server_default=sa.func.now(), onupdate=sa.func.now()), - ) - - op.create_table( - 'mtgonline_decklist_files', - sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True), - sa.Column('user_id', sa.Integer(), sa.ForeignKey('mtgonline_users.id', ondelete='CASCADE'), nullable=False, index=True), - sa.Column('name', sa.String(255), nullable=False), - sa.Column('content', sa.Text(), nullable=True), - sa.Column('description', sa.Text(), nullable=True), - sa.Column('format', sa.String(50), server_default='standard'), - sa.Column('is_favorite', sa.Boolean(), default=False), - sa.Column('import_source', sa.String(50), nullable=True), - sa.Column('import_confidence', sa.Float(), nullable=True), - sa.Column('last_played', sa.DateTime(), nullable=True), - sa.Column('created_at', sa.DateTime(), server_default=sa.func.now()), - sa.Column('updated_at', sa.DateTime(), server_default=sa.func.now(), onupdate=sa.func.now()), - ) - op.create_index('idx_decklist_files_user', 'mtgonline_decklist_files', ['user_id']) - op.create_index('idx_decklist_files_name', 'mtgonline_decklist_files', ['name']) - - # 0.1. Rooms Table - op.create_table( - 'mtgonline_rooms', - sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True), - sa.Column('name', sa.String(100), nullable=False), - sa.Column('description', sa.Text(), nullable=True), - sa.Column('max_players', sa.Integer(), default=8), - sa.Column('is_public', sa.Boolean(), default=True), - sa.Column('is_password_protected', sa.Boolean(), default=False), - sa.Column('password_hash', sa.String(255), nullable=True), - sa.Column('game_type', sa.String(50), nullable=True), - sa.Column('format', sa.String(50), nullable=True), - sa.Column('created_by', sa.Integer(), sa.ForeignKey('mtgonline_users.id'), nullable=False), - sa.Column('created_at', sa.DateTime(), server_default=sa.func.now()), - sa.Column('updated_at', sa.DateTime(), server_default=sa.func.now(), onupdate=sa.func.now()), - ) - op.create_index('idx_rooms_created_by', 'mtgonline_rooms', ['created_by']) - op.create_index('idx_rooms_name', 'mtgonline_rooms', ['name']) - # 1. User Sessions Table op.create_table( 'user_sessions', @@ -326,8 +263,3 @@ def downgrade() -> None: op.drop_table('game_replays') op.drop_table('deck_versions') op.drop_table('user_sessions') - - # Drop base tables (must be dropped after tables that reference them) - op.drop_table('mtgonline_rooms') - op.drop_table('mtgonline_decklist_files') - op.drop_table('mtgonline_users') diff --git a/backend/alembic/versions/002_user_deck_building_tables.py b/backend/alembic/versions/002_user_deck_building_tables.py index b135a6c..66b3fbc 100644 --- a/backend/alembic/versions/002_user_deck_building_tables.py +++ b/backend/alembic/versions/002_user_deck_building_tables.py @@ -42,7 +42,7 @@ def upgrade() -> None: 'user_deck_cards', sa.Column('id', sa.BigInteger().with_variant(sa.Integer, "sqlite"), primary_key=True, autoincrement=True), sa.Column('deck_id', sa.BigInteger(), sa.ForeignKey('user_decks.id', ondelete='CASCADE'), nullable=False, index=True), - sa.Column('card_id', sa.Integer(), nullable=False, index=True), + sa.Column('card_id', sa.Integer(), sa.ForeignKey('mtgonline_cards.id'), nullable=False, index=True), sa.Column('quantity', sa.Integer(), nullable=False, default=1), sa.Column('zone', sa.String(20), nullable=False, default='main'), sa.Column('position', sa.Integer(), nullable=True), @@ -73,7 +73,7 @@ def upgrade() -> None: 'deck_precedent_cards', sa.Column('id', sa.BigInteger().with_variant(sa.Integer, "sqlite"), primary_key=True, autoincrement=True), sa.Column('precedent_id', sa.BigInteger(), sa.ForeignKey('deck_precedents.id', ondelete='CASCADE'), nullable=False, index=True), - sa.Column('card_id', sa.Integer(), nullable=False, index=True), + sa.Column('card_id', sa.Integer(), sa.ForeignKey('mtgonline_cards.id'), nullable=False, index=True), sa.Column('quantity', sa.Integer(), nullable=False, default=1), sa.Column('zone', sa.String(20), nullable=False, default='main'), ) @@ -88,8 +88,8 @@ def upgrade() -> None: 'card_suggestions', sa.Column('id', sa.BigInteger().with_variant(sa.Integer, "sqlite"), primary_key=True, autoincrement=True), sa.Column('deck_id', sa.BigInteger(), sa.ForeignKey('user_decks.id', ondelete='CASCADE'), nullable=False, index=True), - sa.Column('card_id', sa.Integer(), nullable=False, index=True), - sa.Column('source_card_id', sa.Integer(), nullable=True), + sa.Column('card_id', sa.Integer(), sa.ForeignKey('mtgonline_cards.id'), nullable=False, index=True), + sa.Column('source_card_id', sa.Integer(), sa.ForeignKey('mtgonline_cards.id'), nullable=True), sa.Column('suggestion_type', sa.String(50), nullable=False, default='SIMILAR'), sa.Column('confidence', sa.Float(), nullable=True), sa.Column('notes', sa.Text(), nullable=True), diff --git a/state.json b/state.json index ea15591..c695a8e 100644 --- a/state.json +++ b/state.json @@ -46,7 +46,7 @@ }, "architectural_notes": "Dual database setup: mtgonline for app data, mtgdata for MTGJSON card data. Alembic migrations run on container startup. Async SQLAlchemy with asyncpg driver. Card mirrors in mtgo_platform for fast deckbuilding queries. User data API mounted at /api/v1/user-data. Card import router mounted at /api/v1/card-import. Phase 3 adds server-authoritative game engine with WebSocket real-time multiplayer, 11-phase MTG turn structure, stack resolution, card zones, deck validation, and replay recording. MTG rules engine integrated in backend/mtg_rules_engine/.", "task_description": "Multiplayer game server — Phase 3 handoff. Architecture derived from Cockatrice analysis (v3.1.0 Graduation Day). Server-authoritative game engine with WebSocket real-time multiplayer, 11-phase MTG turn structure, stack resolution, card zones, deck validation, and replay recording. Handoff document at handoff.md provides complete blueprint.", - "current_step": "Phase 2 complete. Migration 001 fixed - added base table creation for mtgonline_users, mtgonline_decklist_files, and mtgonline_rooms. Ready to begin Phase 3: Multiplayer game server implementation.", + "current_step": "Phase 2 complete. Migration 001 fixed - added base table creation for mtgonline_users, mtgonline_decklist_files, and mtgonline_rooms. Ready to begin Phase 3: Multiplayer game server implementation. State committed and pushed to Gitea. Comprehensive backend test plan created for iterative sub-agent testing.", "files_created": [ "alembic.ini", "alembic/env.py", @@ -83,7 +83,8 @@ "backend/mtg_rules_engine/README.md", "scripts/read_card_list.py", "scripts/test_card_import.py", - "backend/app/schemas/user_card_collection.py" + "backend/app/schemas/user_card_collection.py", + "BACKEND_TEST_PLAN.md" ], "files_modified": [ "app/models/__init__.py", @@ -124,6 +125,6 @@ "Update documentation and push to Gitea" ], "blockers": [], - "commit_hash": "6bb4034", + "commit_hash": "2d52e2d", "timestamp": "2026-07-26T18:32:10-04:00" }